The dashboard voice-turn path ran _speech_text() before MeloTTS.synth, which rewrote a leading "[힘차게] 안녕!" into "힘차게, 안녕!" — reading the first emotion aloud and destroying the tag before the TTS emotion parser could use it. Make _speech_text() a pass-through so every emotion tag (including the first) reaches synth intact and shapes pitch/speed instead of being spoken. Adds a regression test covering the leading-tag case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
91 lines
3.4 KiB
Python
91 lines
3.4 KiB
Python
"""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,
|
|
)
|
|
from wsai.dashboard import _speech_text
|
|
|
|
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
|
|
|
|
|
|
def test_voice_turn_keeps_leading_emotion_tag_for_tts_parser():
|
|
text = "[속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어!"
|
|
|
|
spoken = _speech_text(text)
|
|
segs = parse_segments(spoken, BASE)
|
|
|
|
assert spoken == text
|
|
assert segs[0].text == "정말 힘들었겠다."
|
|
assert segs[0].pitch == EMOTION_PARAMS["sad"][1]
|
|
assert segs[1].text == "하지만 넌 할 수 있어!"
|
|
assert segs[1].pitch == EMOTION_PARAMS["hopeful"][1]
|