Files
watch_sceen_ai/tests/gen_emotion_samples.py
EJClaw e67cd2e93f 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>
2026-08-23 00:08:21 +09:00

84 lines
4.2 KiB
Python

"""One-off: synthesize a short Korean sample for every canonical emotion.
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
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_dir
"""
from __future__ import annotations
import asyncio
import sys
import wave
from pathlib import Path
from wsai.backends.melo import MeloTTS
# (canonical english for filename, announced Korean name, emotion tag, sample)
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", "안도", "안도", "휴, 이제야 마음이 놓이네요."),
]
def stitch(paths: list[str], out: str, gap_s: float = 0.45) -> None:
with wave.open(paths[0], "rb") as w0:
nch, sw, fr = w0.getnchannels(), w0.getsampwidth(), w0.getframerate()
silence = b"\x00" * (int(fr * gap_s) * sw * nch)
with wave.open(out, "wb") as wo:
wo.setnchannels(nch)
wo.setsampwidth(sw)
wo.setframerate(fr)
for i, p in enumerate(paths):
with wave.open(p, "rb") as w:
wo.writeframes(w.readframes(w.getnframes()))
if i < len(paths) - 1:
wo.writeframes(silence)
async def main(out_dir: str) -> None:
d = Path(out_dir)
d.mkdir(parents=True, exist_ok=True)
tts = MeloTTS() # base speed comes from WSAI_TTS_SPEED default
await tts.warmup()
print(f"melo ready ({tts.load_ms} ms), base speed {tts.speed}")
paths: list[str] = []
for i, (canon, name, tag, sample) in enumerate(SAMPLES, 1):
text = f"{name}. [{tag}] {sample}"
src = await tts.synth(text)
dst = d / f"emo_{i:02d}_{canon}.wav"
Path(src).replace(dst)
paths.append(str(dst))
print(f" {name:8s} -> {dst}")
await tts.aclose()
stitch(paths, str(d / "emotion_samples_all.wav"))
print(f"wrote {len(paths)} per-emotion wavs + stitched all -> {d}")
if __name__ == "__main__":
out_dir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/emotion_samples"
asyncio.run(main(out_dir))