feat(voice): express [감정] tags via pitch/speed instead of speaking them

Emotion tags now steer delivery rather than being read aloud. parse_segments()
splits a reply on [감정] tags: a recognised emotion word switches the pitch and
speed of the text that follows (and is dropped), while a non-emotion bracket
(e.g. [1번]) keeps its inner words as spoken content. Emotions can change
mid-reply, so a single turn is synthesised as several pitch-shifted segments and
concatenated in the melo worker (librosa pitch_shift, warmed at startup).

The emotion vocabulary is grounded in Azure Neural TTS speaking styles plus
Ekman's basic emotions, with Korean synonyms. The brain persona is updated to
emit inline tags from that set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-22 10:14:49 +09:00
parent f585ed7b76
commit 4db73bf69f
5 changed files with 286 additions and 6 deletions

76
tests/test_emotion.py Normal file
View File

@@ -0,0 +1,76 @@
"""Emotion-tag parsing for expressive TTS. A ``[감정]`` tag must steer the
pitch/speed of the text that follows without being spoken; a bracket that is NOT
a known emotion word must be kept as ordinary spoken content."""
from wsai.backends.emotion import (
EMOTION_PARAMS,
match_emotion,
parse_segments,
)
BASE = 1.3
def test_emotion_tag_is_not_spoken_and_sets_delivery():
segs = parse_segments("[기쁨] 오늘 날씨 좋다", BASE)
assert len(segs) == 1
assert "기쁨" not in segs[0].text # the tag word is dropped
assert segs[0].text == "오늘 날씨 좋다"
mult, semis = EMOTION_PARAMS["happy"]
assert segs[0].speed == BASE * mult
assert segs[0].pitch == semis
def test_midreply_emotion_change_splits_segments():
segs = parse_segments("[속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어!", BASE)
assert len(segs) == 2
assert segs[0].text == "정말 힘들었겠다."
assert segs[1].text == "하지만 넌 할 수 있어!"
assert segs[0].pitch == EMOTION_PARAMS["sad"][1]
assert segs[1].pitch == EMOTION_PARAMS["hopeful"][1]
def test_non_emotion_bracket_is_spoken_without_brackets():
segs = parse_segments("[기쁨] 첫째는 [1번] 항목이야", BASE)
assert len(segs) == 1
# "1번" is not an emotion -> read it; brackets themselves are gone.
assert "1번" in segs[0].text
assert "[" not in segs[0].text and "]" not in segs[0].text
assert segs[0].pitch == EMOTION_PARAMS["happy"][1]
def test_text_before_first_tag_is_neutral():
segs = parse_segments("잠깐만. [신남] 찾았다!", BASE)
assert segs[0].text == "잠깐만."
assert segs[0].speed == BASE and segs[0].pitch == 0.0
assert segs[1].pitch == EMOTION_PARAMS["excited"][1]
def test_plain_text_is_one_neutral_segment():
segs = parse_segments("그냥 평범한 문장이야", BASE)
assert len(segs) == 1
assert segs[0].speed == BASE and segs[0].pitch == 0.0
def test_empty_input_yields_no_segments():
assert parse_segments("", BASE) == []
assert parse_segments(" ", BASE) == []
def test_only_emotion_tags_yield_no_segments():
# A reply that is nothing but tags has nothing to say.
assert parse_segments("[기쁨][신남]", BASE) == []
def test_match_emotion_is_synonym_and_space_tolerant():
assert match_emotion("속상함") == "sad"
assert match_emotion(" 힘 차게 ") == "hopeful" # squeezed + trimmed
assert match_emotion("행복하게") == "happy"
assert match_emotion("메모") is None # not an emotion
def test_persona_examples_are_all_recognised():
# Every emotion the brain persona advertises must resolve, or it would be
# read aloud instead of shaping the voice.
for word in ["힘차게", "궁금", "반가움", "차분하게", "웃으며", "속상함"]:
assert match_emotion(word) is not None, word