feat(tts): add [기본] neutral emotion + raise base speed to 1.5x

User reported the emotion samples sounded the same and the speed change wasn't
noticeable. Two changes:
- Add a "base" (기본/neutral) emotion at speed 1.0x so the plain base voice can
  be selected explicitly via a [기본] tag and auditioned against the others.
- Bump the WSAI_TTS_SPEED default 1.15 -> 1.5 for a clearly faster base voice.
  Emotion multipliers scale off base, so every emotion speeds up together.

Also extends gen_emotion_samples.py to emit one wav per emotion (incl. 기본)
plus a stitched all-in-one, so each emotion can be delivered as a separate clip.

Verified: 29 tests pass; match_emotion('기본') == 'base'; per-emotion synthesis
at base 1.5 produces distinct clip durations (base 4.9s vs happy 3.3s).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-23 00:08:21 +09:00
parent d410d4a6b5
commit e67cd2e93f
3 changed files with 47 additions and 38 deletions

View File

@@ -1,11 +1,12 @@
"""One-off: synthesize a short Korean sample for every canonical emotion and """One-off: synthesize a short Korean sample for every canonical emotion.
stitch them into a single wav so the delivery of each emotion can be heard.
Each clip announces the emotion name at neutral (base) speed, then speaks a Each clip announces the emotion name at neutral (base) speed, then speaks a
sample sentence steered by that emotion's ``[태그]`` — exactly the path the live sample sentence steered by that emotion's ``[태그]`` — exactly the path the live
voice server uses. Run with the orchestrator venv: voice server uses. Writes one wav per emotion into an output directory (plus a
stitched all-in-one) so each emotion can be auditioned separately. Run with the
orchestrator venv:
.venv/bin/python -m tests.gen_emotion_samples /abs/out.wav .venv/bin/python -m tests.gen_emotion_samples /abs/out_dir
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,31 +14,33 @@ from __future__ import annotations
import asyncio import asyncio
import sys import sys
import wave import wave
from pathlib import Path
from wsai.backends.melo import MeloTTS from wsai.backends.melo import MeloTTS
# (announced Korean name, emotion tag word, sample sentence) # (canonical english for filename, announced Korean name, emotion tag, sample)
SAMPLES: list[tuple[str, str, str]] = [ SAMPLES: list[tuple[str, str, str, str]] = [
("기쁨", "", "오늘은 정말 기분 좋은 하루예요!"), ("base", "기본", "", "이건 기본 목소리예요, 감정 없이 이렇게 말해요."),
("신남", "신남", "우와, 이거 진짜 신난다! 빨리 하자!"), ("happy", "기쁨", "기쁨", "오늘은 정말 기분 좋은 하루예요!"),
("희망", "희망", "리 분명히 잘 해낼 수 있어요!"), ("excited", "신남", "신남", "와, 이거 진짜 신난다! 빨리 하자!"),
("슬픔", "슬픔", "조금 속상한 일이어요."), ("hopeful", "희망", "희망", "우리 분명히 잘 해낼 수 있어요!"),
("화남", "화남", "정말 너무하잖아요, 화가 나요."), ("sad", "슬픔", "슬픔", "조금 속상한 일이 있었어요."),
("두려움", "두려움", "어떡하지, 너무 무서워요."), ("angry", "화남", "화남", "정말 너무하잖아요, 화가 나요."),
("놀람", "놀람", "머, 이게 정말이에요?"), ("fearful", "두려움", "두려움", "떡하지, 너무 무서워요."),
("혐오", "혐오", ", 이건 좀 별로예요."), ("surprised", "놀람", "놀람", "어머, 이게 정말이에요?"),
("차분", "차분", "천천히 하나씩 정리해 볼게요."), ("disgust", "혐오", "혐오", "으, 이건 좀 별로예요."),
("다정", "다정", "언제든지 편하게 말해 주세요."), ("calm", "차분", "차분", "천천히 하나씩 정리해 볼게요."),
("진지", "진지", "이건 정말 중요한 이야기예요."), ("friendly", "다정", "다정", "언제든지 편하게 말해 주세요."),
("실망", "실망", "조금 아쉬운 결과네요."), ("serious", "진지", "진지", "이건 정말 중요한 이야기예요."),
("피곤", "피곤", "아, 오늘 너무 피곤하네요."), ("disappointed", "실망", "실망", "조금 아쉬운 결과네요."),
("사랑스럽게", "사랑스럽게", "당신은 정말 소중한 사람이에요."), ("tired", "피곤", "피곤", "아, 오늘 너무 피곤하네요."),
("장난스럽게", "장난스럽게", "히히, 한번 맞혀 보세요!"), ("affectionate", "사랑스럽게", "사랑스럽게", "당신은 정말 소중한 사람이에요."),
("궁금", "궁금", "그건 대체 왜 그런 걸까요?"), ("playful", "장난스럽게", "장난스럽게", "히히, 한번 맞혀 보세요!"),
("속삭임", "속삭임", "조용히, 우리끼리만 아는 비밀이에요."), ("curious", "궁금", "궁금", "그건 대체 왜 그런 걸까요?"),
("외침", "외침", "다 같이 힘내자, 파이팅!"), ("whisper", "속삭임", "속삭임", "조용히, 우리끼리만 아는 비밀이에요."),
("단호", "단호", "이번엔 반드시 해내겠어요."), ("shout", "외침", "외침", "다 같이 힘내자, 파이팅!"),
("안도", "안도", "휴, 이제야 마음이 놓이네요."), ("determined", "단호", "단호", "이번엔 반드시 해내겠어요."),
("relieved", "안도", "안도", "휴, 이제야 마음이 놓이네요."),
] ]
@@ -56,21 +59,25 @@ def stitch(paths: list[str], out: str, gap_s: float = 0.45) -> None:
wo.writeframes(silence) wo.writeframes(silence)
async def main(out: str) -> None: async def main(out_dir: str) -> None:
tts = MeloTTS() # base speed comes from the new WSAI_TTS_SPEED default (1.15) d = Path(out_dir)
d.mkdir(parents=True, exist_ok=True)
tts = MeloTTS() # base speed comes from WSAI_TTS_SPEED default
await tts.warmup() await tts.warmup()
print(f"melo ready ({tts.load_ms} ms), base speed {tts.speed}") print(f"melo ready ({tts.load_ms} ms), base speed {tts.speed}")
paths: list[str] = [] paths: list[str] = []
for name, tag, sample in SAMPLES: for i, (canon, name, tag, sample) in enumerate(SAMPLES, 1):
text = f"{name}. [{tag}] {sample}" text = f"{name}. [{tag}] {sample}"
p = await tts.synth(text) src = await tts.synth(text)
paths.append(p) dst = d / f"emo_{i:02d}_{canon}.wav"
print(f" {name:8s} -> {p}") Path(src).replace(dst)
paths.append(str(dst))
print(f" {name:8s} -> {dst}")
await tts.aclose() await tts.aclose()
stitch(paths, out) stitch(paths, str(d / "emotion_samples_all.wav"))
print(f"stitched {len(paths)} clips -> {out}") print(f"wrote {len(paths)} per-emotion wavs + stitched all -> {d}")
if __name__ == "__main__": if __name__ == "__main__":
out = sys.argv[1] if len(sys.argv) > 1 else "/tmp/emotion_samples.wav" out_dir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/emotion_samples"
asyncio.run(main(out)) asyncio.run(main(out_dir))

View File

@@ -30,6 +30,7 @@ from dataclasses import dataclass
# artefact-free. The pitch column is retained (rather than removed) so the effect # artefact-free. The pitch column is retained (rather than removed) so the effect
# can be re-enabled per-emotion later with a real, artefact-free pitch method. # can be re-enabled per-emotion later with a real, artefact-free pitch method.
EMOTION_PARAMS: dict[str, tuple[float, float]] = { EMOTION_PARAMS: dict[str, tuple[float, float]] = {
"base": (1.00, 0.0), # 기본 / neutral — the plain base voice, no colour
"happy": (1.08, 0.0), # 기쁨 / cheerful "happy": (1.08, 0.0), # 기쁨 / cheerful
"excited": (1.15, 0.0), # 신남 / excited "excited": (1.15, 0.0), # 신남 / excited
"hopeful": (1.10, 0.0), # 희망 / 힘차게 "hopeful": (1.10, 0.0), # 희망 / 힘차게
@@ -69,6 +70,7 @@ def _norm(word: str) -> str:
return re.sub(r"\s+", "", word).lower() return re.sub(r"\s+", "", word).lower()
_register("base", "기본", "기본목소리", "기본톤", "보통", "평범", "무감정", "default", "neutral", "normal", "plain")
_register("happy", "기쁨", "기쁘게", "기뻐", "기뻐하며", "행복", "행복하게", "행복하게도", "즐겁게", "즐거움", "밝게", "반가움", "반갑게", "반가워", "cheerful", "happy", "joyful") _register("happy", "기쁨", "기쁘게", "기뻐", "기뻐하며", "행복", "행복하게", "행복하게도", "즐겁게", "즐거움", "밝게", "반가움", "반갑게", "반가워", "cheerful", "happy", "joyful")
_register("excited", "신남", "신나게", "신나서", "흥분", "들뜬", "들떠서", "설렘", "설레며", "excited", "thrilled") _register("excited", "신남", "신나게", "신나서", "흥분", "들뜬", "들떠서", "설렘", "설레며", "excited", "thrilled")
_register("hopeful", "희망", "희망차게", "힘차게", "힘내", "힘내서", "응원", "응원하며", "격려", "hopeful", "encouraging") _register("hopeful", "희망", "희망차게", "힘차게", "힘내", "힘내서", "응원", "응원하며", "격려", "hopeful", "encouraging")

View File

@@ -12,7 +12,7 @@ Env:
WSAI_MELO_DEVICE cpu | cuda | auto (default auto: GPU if torch sees one, WSAI_MELO_DEVICE cpu | cuda | auto (default auto: GPU if torch sees one,
else CPU; the worker falls back to CPU if CUDA fails) else CPU; the worker falls back to CPU if CUDA fails)
WSAI_TTS_OUT_DIR where wavs are written (default ~/.cache/wsai/tts) WSAI_TTS_OUT_DIR where wavs are written (default ~/.cache/wsai/tts)
WSAI_TTS_SPEED synthesis speed multiplier (default 1.15) WSAI_TTS_SPEED synthesis speed multiplier (default 1.5)
""" """
from __future__ import annotations from __future__ import annotations
@@ -93,7 +93,7 @@ class MeloTTS:
self.out_dir = Path(out_dir or os.environ.get("WSAI_TTS_OUT_DIR") self.out_dir = Path(out_dir or os.environ.get("WSAI_TTS_OUT_DIR")
or (Path.home() / ".cache/wsai/tts")) or (Path.home() / ".cache/wsai/tts"))
self.speed = float(speed if speed is not None self.speed = float(speed if speed is not None
else os.environ.get("WSAI_TTS_SPEED", "1.15")) else os.environ.get("WSAI_TTS_SPEED", "1.5"))
self.sink = sink or _log_sink self.sink = sink or _log_sink
self._proc: asyncio.subprocess.Process | None = None self._proc: asyncio.subprocess.Process | None = None
self._lock = asyncio.Lock() self._lock = asyncio.Lock()