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>
148 lines
7.7 KiB
Python
148 lines
7.7 KiB
Python
"""Emotion tags for expressive TTS.
|
|
|
|
Claude can sprinkle ``[감정]`` tags through a reply to colour the delivery, e.g.
|
|
|
|
"[속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어!"
|
|
|
|
An emotion tag is NOT spoken — instead it shifts the *following* text's pitch and
|
|
speed until the next tag. A bracketed word that is NOT a known emotion is left as
|
|
ordinary spoken content (the brackets are dropped, the words are read).
|
|
|
|
The emotion vocabulary is grounded in the de-facto industry set used by Azure
|
|
Neural TTS speaking styles (cheerful, sad, angry, excited, friendly, hopeful,
|
|
terrified, shouting, whispering) together with Ekman's six basic emotions
|
|
(happiness, sadness, anger, fear, surprise, disgust). Each canonical emotion maps
|
|
to a ``(speed_multiplier, pitch_semitones)`` pair; the multiplier scales the base
|
|
synthesis speed and the semitone offset is applied as a pitch shift on the wav.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
# Canonical emotion -> (speed multiplier relative to base, pitch shift in semitones).
|
|
# Kept deliberately modest so delivery stays natural, not cartoonish.
|
|
#
|
|
# Pitch is held at 0.0 for every emotion: librosa's post-hoc pitch_shift on Melo
|
|
# output produced a robotic, "monster"-sounding artefact (worse when stacked on a
|
|
# fast base speed). Emotion is therefore conveyed by speed only — natural and
|
|
# 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.
|
|
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
|
|
"excited": (1.15, 0.0), # 신남 / excited
|
|
"hopeful": (1.10, 0.0), # 희망 / 힘차게
|
|
"sad": (0.90, 0.0), # 슬픔 / sad
|
|
"angry": (1.12, 0.0), # 화남 / angry
|
|
"fearful": (1.12, 0.0), # 두려움 / terrified
|
|
"surprised": (1.05, 0.0), # 놀람 / surprise
|
|
"disgust": (0.96, 0.0), # 혐오 / disgust
|
|
"calm": (0.95, 0.0), # 차분 / calm
|
|
"friendly": (1.00, 0.0), # 다정 / friendly
|
|
"serious": (0.97, 0.0), # 진지 / serious
|
|
"disappointed":(0.92, 0.0), # 실망 / disappointed
|
|
"tired": (0.90, 0.0), # 피곤 / 지침
|
|
"affectionate":(0.98, 0.0), # 사랑스럽게 / affectionate
|
|
"playful": (1.08, 0.0), # 장난스럽게 / playful
|
|
"whisper": (0.92, 0.0), # 속삭임 / whispering
|
|
"shout": (1.05, 0.0), # 외침 / shouting
|
|
"determined": (1.05, 0.0), # 단호 / determined
|
|
"relieved": (0.95, 0.0), # 안도 / relieved
|
|
"curious": (1.03, 0.0), # 궁금 / curious
|
|
}
|
|
|
|
# Every spelling Claude might realistically emit, mapped to a canonical emotion.
|
|
# False negatives (reading an emotion word aloud) are harmless; false positives
|
|
# (silently dropping real content) are not — so match spellings exactly rather
|
|
# than fuzzily.
|
|
_SYNONYMS: dict[str, str] = {}
|
|
|
|
|
|
def _register(canonical: str, *words: str) -> None:
|
|
for w in words:
|
|
_SYNONYMS[_norm(w)] = canonical
|
|
|
|
|
|
def _norm(word: str) -> str:
|
|
# Compare on a squeezed, lower-cased form so "힘 차게" == "힘차게".
|
|
return re.sub(r"\s+", "", word).lower()
|
|
|
|
|
|
_register("base", "기본", "기본목소리", "기본톤", "보통", "평범", "무감정", "default", "neutral", "normal", "plain")
|
|
_register("happy", "기쁨", "기쁘게", "기뻐", "기뻐하며", "행복", "행복하게", "행복하게도", "즐겁게", "즐거움", "밝게", "반가움", "반갑게", "반가워", "cheerful", "happy", "joyful")
|
|
_register("excited", "신남", "신나게", "신나서", "흥분", "들뜬", "들떠서", "설렘", "설레며", "excited", "thrilled")
|
|
_register("hopeful", "희망", "희망차게", "힘차게", "힘내", "힘내서", "응원", "응원하며", "격려", "hopeful", "encouraging")
|
|
_register("sad", "슬픔", "슬프게", "슬퍼", "슬퍼하며", "속상함", "속상하게", "속상해", "우울", "우울하게", "안타깝게", "울먹이며", "sad", "sorrowful")
|
|
_register("angry", "화남", "화나게", "화나서", "화가남", "분노", "분노하며", "짜증", "짜증내며", "angry", "furious")
|
|
_register("fearful", "두려움", "두렵게", "무섭게", "무서워하며", "불안", "불안하게", "겁먹은", "겁먹고", "떨리는", "fearful", "terrified", "anxious")
|
|
_register("surprised", "놀람", "놀라며", "놀랍게", "놀라서", "깜짝", "경악", "surprised", "shocked")
|
|
_register("disgust", "혐오", "역겹게", "질색", "disgust", "disgusted")
|
|
_register("calm", "차분", "차분하게", "침착", "침착하게", "담담하게", "잔잔하게", "calm", "gentle")
|
|
_register("friendly", "다정", "다정하게", "친근", "친근하게", "부드럽게", "따뜻하게", "friendly", "warm")
|
|
_register("serious", "진지", "진지하게", "무겁게", "엄숙하게", "serious", "solemn")
|
|
_register("disappointed", "실망", "실망스럽게", "실망하며", "낙담", "disappointed")
|
|
_register("tired", "피곤", "피곤하게", "지침", "지쳐서", "지친", "힘없이", "tired", "weary", "exhausted")
|
|
_register("affectionate", "사랑스럽게", "애정", "애정어린", "다정스럽게", "affectionate", "loving")
|
|
_register("playful", "장난스럽게", "장난치며", "유쾌하게", "익살스럽게", "웃으며", "웃으면서", "playful", "teasing")
|
|
_register("curious", "궁금", "궁금하게", "궁금해하며", "궁금해서", "호기심", "curious", "inquisitive")
|
|
_register("whisper", "속삭임", "속삭이며", "조용히", "나지막이", "whisper", "whispering")
|
|
_register("shout", "외침", "외치며", "큰소리로", "소리치며", "우렁차게", "shout", "shouting")
|
|
_register("determined", "단호", "단호하게", "결연하게", "당당하게", "determined", "confident")
|
|
_register("relieved", "안도", "안도하며", "안심", "안심하며", "relieved")
|
|
|
|
|
|
def match_emotion(inner: str) -> str | None:
|
|
"""Return the canonical emotion for a bracket's inner text, or None if the
|
|
text is not a recognised emotion word (and should therefore be spoken)."""
|
|
return _SYNONYMS.get(_norm(inner))
|
|
|
|
|
|
@dataclass
|
|
class Segment:
|
|
text: str
|
|
speed: float
|
|
pitch: float # semitones; 0.0 == no shift
|
|
|
|
|
|
_TAG_RE = re.compile(r"\[([^\[\]]*)\]")
|
|
|
|
|
|
def parse_segments(text: str, base_speed: float) -> list[Segment]:
|
|
"""Split ``text`` into consecutive spoken segments, each carrying the speed
|
|
and pitch implied by the most recent emotion tag.
|
|
|
|
* An emotion tag switches the active emotion for everything after it and is
|
|
not spoken.
|
|
* A non-emotion bracket keeps its inner words as spoken text (brackets gone).
|
|
* Text before any tag is spoken with neutral delivery (base speed, no shift).
|
|
"""
|
|
segments: list[Segment] = []
|
|
cur_speed, cur_pitch = base_speed, 0.0
|
|
buf: list[str] = []
|
|
|
|
def flush() -> None:
|
|
joined = "".join(buf).strip()
|
|
if joined:
|
|
segments.append(Segment(joined, cur_speed, cur_pitch))
|
|
buf.clear()
|
|
|
|
pos = 0
|
|
for m in _TAG_RE.finditer(text):
|
|
emotion = match_emotion(m.group(1))
|
|
buf.append(text[pos:m.start()])
|
|
pos = m.end()
|
|
if emotion is None:
|
|
# Not an emotion — read the bracket's contents, drop the brackets.
|
|
buf.append(m.group(1))
|
|
else:
|
|
# Emotion tag — everything so far belongs to the previous emotion;
|
|
# flush it, then switch delivery for what follows.
|
|
flush()
|
|
mult, semis = EMOTION_PARAMS[emotion]
|
|
cur_speed, cur_pitch = base_speed * mult, semis
|
|
buf.append(text[pos:])
|
|
flush()
|
|
return segments # empty when there is nothing speakable (blank or all-tags)
|