"""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. EMOTION_PARAMS: dict[str, tuple[float, float]] = { "happy": (1.08, 2.0), # 기쁨 / cheerful "excited": (1.15, 3.0), # 신남 / excited "hopeful": (1.10, 1.5), # 희망 / 힘차게 "sad": (0.90, -2.5), # 슬픔 / sad "angry": (1.12, 1.0), # 화남 / angry "fearful": (1.12, 2.0), # 두려움 / terrified "surprised": (1.05, 3.0), # 놀람 / surprise "disgust": (0.96, -1.0), # 혐오 / disgust "calm": (0.95, -1.0), # 차분 / calm "friendly": (1.00, 1.0), # 다정 / friendly "serious": (0.97, -1.0), # 진지 / serious "disappointed":(0.92, -2.0), # 실망 / disappointed "tired": (0.90, -2.0), # 피곤 / 지침 "affectionate":(0.98, 1.0), # 사랑스럽게 / affectionate "playful": (1.08, 2.0), # 장난스럽게 / playful "whisper": (0.92, -1.5), # 속삭임 / whispering "shout": (1.05, 2.5), # 외침 / shouting "determined": (1.05, 0.5), # 단호 / determined "relieved": (0.95, 0.5), # 안도 / relieved "curious": (1.03, 1.5), # 궁금 / 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("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)