diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py index d56a8fd92..82f8a0b44 100755 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -318,8 +318,12 @@ _ALLOWED_PREVIEW_EMOTIONS = { "悲伤", "愤怒", "惊奇", + "吃惊", "害怕", "讨厌", + # 灵应 P1 指定别名 + "中性", + "伤心", "沉稳", "亲切", } @@ -349,7 +353,7 @@ def get_voice_clone_preview( if emotion not in _ALLOWED_PREVIEW_EMOTIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"不支持的 emotion 值: {emotion},可选: neutral/happy/sad/angry/surprised/fearful/disgusted 或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶,或留空", + detail=f"不支持的 emotion 值: {emotion},可选: neutral/happy/sad/angry/surprised/fearful/disgusted 或中文 中立/中性/开心/难过/伤心/生气/愤怒/惊讶/吃惊/恐惧/害怕/厌恶/讨厌 或留空", ) use_case = GetVoiceCloneUseCase(repository) diff --git a/packages/application/cosyvoice_service.py b/packages/application/cosyvoice_service.py index 334c06e94..38df07560 100644 --- a/packages/application/cosyvoice_service.py +++ b/packages/application/cosyvoice_service.py @@ -58,8 +58,12 @@ EMOTION_MAP: dict[str, str] = { "悲伤": "sad", "愤怒": "angry", "惊奇": "surprised", + "吃惊": "surprised", "害怕": "fearful", "讨厌": "disgusted", + # ── 灵应派任务指定的中文别名(中性/伤心/愤怒 等)── + "中性": "neutral", + "伤心": "sad", # ── 旧英文 4 枚举兼容(natural/excited/calm/friendly 归并到最接近的标准值)── "natural": "neutral", "excited": "happy", @@ -68,6 +72,56 @@ EMOTION_MAP: dict[str, str] = { } +# ── 支持 emotion Instruct 的 v3-flash 系统音色白名单(官方音色列表标注"Instruct:支持"且支持情感值)── +# 这些音色的 instruction 必须使用中文固定格式 "你说话的情感是。"; +# longanhuan_v3 虽然 Instruct 支持,但只支持方言 instruct(请用<方言>表达。),不支持 emotion,故不列入。 +_SYSTEM_VOICES_WITH_EMOTION_INSTRUCT: frozenset[str] = frozenset( + { + "longanyang", # 龙安洋(标杆音色) + "longanhuan", # 龙安欢 + "longhuhu_v3", # 龙呼呼 + } +) + + +def _is_cloned_voice(voice_id: str) -> bool: + """判断一个 voice_id 是否为克隆/设计音色(非系统预置音色)。 + + 所有以 "long"/"loong" 开头的是系统预置音色(longxiaochun_v3/longanyang/loongabby_v3 等), + 其余视为用户克隆音色/设计音色,支持任意中英文自然语言 instruction。 + """ + if not voice_id: + return False + v = voice_id.lower() + return not (v.startswith("long") or v.startswith("loong")) + + +def build_emotion_instruction(voice_id: str, emotion_enum: str) -> str: + """根据 voice 类型构造符合官方规范的 emotion instruction. + + - 克隆/设计音色(非 long*/loong* 前缀):英文自然语言 "Speak in a {emotion} tone.", + DashScope 对克隆音色允许任意自然语言指令。 + - 系统音色中 emotion-instruct 可用的(longanyang/longanhuan/longhuhu_v3): + 严格按官方中文固定格式 "你说话的情感是{emotion}。",结尾中文句号不可省。 + - 其他系统音色(含默认 longxiaochun_v3 等绝大多数 v3 系统音色):官方不支持 Instruct, + 返回空串(调用方据此不传 instruction,避免被 API 报错或忽略)。 + + Args: + voice_id: CosyVoice voice 参数 + emotion_enum: 已归一化的 7 种英文枚举之一(neutral/happy/sad/...) + + Returns: + 拼接好的 instruction 字符串;不支持时返回空串 + """ + if not emotion_enum: + return "" + if _is_cloned_voice(voice_id): + return f"Speak in a {emotion_enum} tone." + if voice_id in _SYSTEM_VOICES_WITH_EMOTION_INSTRUCT: + return f"你说话的情感是{emotion_enum}。" + return "" + + def normalize_emotion(emotion: str) -> str: """将前端情绪值归一化为 CosyVoice v3 官方英文枚举,用于拼入 instruction. @@ -563,10 +617,11 @@ class CosyVoiceService: "rate": speed, "volume": volume, } - # 情绪 → instruction 严格按官方格式: 你说话的情感是{emotion_enum}。(结尾中文句号) + # 情绪 → instruction(按 voice 类型选择格式) norm_emotion = normalize_emotion(emotion) - if norm_emotion: - input_payload["instruction"] = f"你说话的情感是{norm_emotion}。" + emotion_instruction = build_emotion_instruction(voice_id, norm_emotion) + if emotion_instruction: + input_payload["instruction"] = emotion_instruction # 语言 → language_hints 数组(仅取第一个元素生效); # 系统音色(非克隆/非 voice_id 中包含下划线以外的短 ID)仅传 zh/en,其他语言不传避免报错 norm_lang = normalize_language(language) diff --git a/tests/unit/test_ai_avatar_emotion_tts_lipsync.py b/tests/unit/test_ai_avatar_emotion_tts_lipsync.py index c3b01c33c..635555873 100644 --- a/tests/unit/test_ai_avatar_emotion_tts_lipsync.py +++ b/tests/unit/test_ai_avatar_emotion_tts_lipsync.py @@ -46,8 +46,11 @@ def test_normalize_emotion_chinese_values(): assert normalize_emotion("恐惧") == "fearful" assert normalize_emotion("厌恶") == "disgusted" assert normalize_emotion("中立") == "neutral" + assert normalize_emotion("中性") == "neutral" assert normalize_emotion("难过") == "sad" + assert normalize_emotion("伤心") == "sad" assert normalize_emotion("生气") == "angry" + assert normalize_emotion("吃惊") == "surprised" def test_normalize_emotion_invalid_defaults_neutral(): @@ -99,21 +102,45 @@ def _make_service_with_captured_client(captured: dict): return svc -def test_submit_synthesize_payload_uses_instruction_and_language_hints(): - """#1898: emotion 通过 instruction 按官方格式传递(你说话的情感是{英文枚举}。);语言用 language_hints.""" +def test_submit_synthesize_payload_uses_instruction_for_cloned_voice(): + """#1898: 克隆/设计音色传 emotion 时 instruction 走英文 'Speak in a {emotion} tone.' 格式; + 不支持 Instruct 的系统音色(含默认 longxiaochun_v3)不传 instruction。""" captured: dict = {} svc = _make_service_with_captured_client(captured) - svc.submit_synthesize_task(text="你好", voice_id="longxiaochun_v3", speed=1.5, emotion="兴奋", language="zh-CN") + # 克隆音色(非 long/loong 前缀)→ 英文 tone 格式 + svc.submit_synthesize_task(text="你好", voice_id="myclone_voice", speed=1.5, emotion="兴奋", language="zh-CN") inp = captured["json"]["input"] - # 不再传 emotion 枚举字段 assert "emotion" not in inp - # 情绪通过 instruction 中文指令 - assert inp["instruction"] == "你说话的情感是happy。" - # rate 字段保持 + assert inp["instruction"] == "Speak in a happy tone." assert inp["rate"] == 1.5 - # 系统音色 zh → language_hints=["zh"] + # 克隆音色不做 language_hints 限制 + assert inp["language_hints"] == ["zh"] + + +def test_submit_synthesize_payload_uses_chinese_instruction_for_emotion_system_voice(): + """longanyang 等支持 emotion instruct 的系统音色 → 中文固定格式 '你说话的情感是{emotion}。'。""" + captured: dict = {} + svc = _make_service_with_captured_client(captured) + + svc.submit_synthesize_task(text="你好", voice_id="longanyang", emotion="开心", language="zh-CN") + + inp = captured["json"]["input"] + assert inp["instruction"] == "你说话的情感是happy。" + assert inp["language_hints"] == ["zh"] + + +def test_submit_synthesize_payload_default_voice_omits_instruction_even_with_emotion(): + """默认音色 longxiaochun_v3 官方不支持 Instruct,即使传 emotion 也不应拼 instruction, + 避免被 API 忽略或报错。""" + captured: dict = {} + svc = _make_service_with_captured_client(captured) + + svc.submit_synthesize_task(text="你好", voice_id="longxiaochun_v3", emotion="开心", language="zh-CN") + + inp = captured["json"]["input"] + assert "instruction" not in inp assert inp["language_hints"] == ["zh"] @@ -128,14 +155,15 @@ def test_submit_synthesize_payload_omits_instruction_when_emotion_empty(): assert inp["language_hints"] == ["en"] -def test_submit_synthesize_payload_english_emotion_maps_to_chinese(): +def test_submit_synthesize_payload_cloned_voice_english_emotion(): + """克隆音色 + 英文 emotion 枚举 → 英文 tone 格式。""" captured: dict = {} svc = _make_service_with_captured_client(captured) - svc.submit_synthesize_task(text="hi", voice_id="longxiaochun_v3", emotion="sad") + svc.submit_synthesize_task(text="hi", voice_id="myclone_voice", emotion="sad") inp = captured["json"]["input"] - assert inp["instruction"] == "你说话的情感是sad。" + assert inp["instruction"] == "Speak in a sad tone." # ── 对口型 TTS 直生分支 ───────────────────────────────────────────────── diff --git a/tests/unit/test_cosyvoice_emotion.py b/tests/unit/test_cosyvoice_emotion.py index 4a486da50..7cacdcb41 100644 --- a/tests/unit/test_cosyvoice_emotion.py +++ b/tests/unit/test_cosyvoice_emotion.py @@ -1,15 +1,18 @@ -"""CosyVoice EMOTION_MAP 与 normalize_emotion 单测(P1 修复 #1898). +"""CosyVoice EMOTION_MAP / normalize_emotion / build_emotion_instruction 单测(P1 修复 #1898). 覆盖: - 7 种标准英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted - (CosyVoice v3 官方 instruction 情感值,必须原样拼入 "你说话的情感是<值>。") + (CosyVoice v3 官方 emotion 值) - 大小写不敏感 - 前端中文 7 标签(中立/开心/难过/生气/惊讶/恐惧/厌恶)→ 英文枚举 -- 常见中文别名 -- 旧英文 4 枚举兼容(natural/excited/calm/friendly)→ 归并到最接近的标准值 +- 灵应指定别名(中性/伤心/愤怒/吃惊)→ 英文枚举 +- 常见中文别名与旧英文 4 枚举兼容 - 空串/空白/None 边界 - 未知值默认 neutral(warning 日志) -- instruction 拼接格式严格符合官方要求("你说话的情感是{emotion_enum}。",单中文句号) +- build_emotion_instruction 三路分支: + · 克隆/设计音色 → "Speak in a {emotion} tone." + · 支持 emotion Instruct 的系统音色(longanyang/longanhuan/longhuhu_v3)→ "你说话的情感是{emotion}。" + · 默认系统音色(含 longxiaochun_v3)→ 返回空串(不传 instruction) """ from __future__ import annotations @@ -20,10 +23,11 @@ import pytest from packages.application.cosyvoice_service import ( EMOTION_MAP, + build_emotion_instruction, normalize_emotion, ) -# 7 种官方英文枚举及其期望归一化结果 +# 7 种官方英文枚举 SEVEN_STANDARD_ENUMS = [ "neutral", "happy", @@ -45,7 +49,15 @@ FRONTEND_CN_LABELS = [ ("厌恶", "disgusted"), ] -# 常见中文别名 → 期望英文枚举 +# 灵应派任务补充的中文别名 +LINGYING_CN_ALIASES = [ + ("中性", "neutral"), + ("伤心", "sad"), + ("愤怒", "angry"), + ("吃惊", "surprised"), +] + +# 其他常见中文别名 CN_ALIASES = [ ("自然", "neutral"), ("愉快", "happy"), @@ -53,7 +65,6 @@ CN_ALIASES = [ ("快乐", "happy"), ("兴奋", "happy"), ("悲伤", "sad"), - ("愤怒", "angry"), ("惊奇", "surprised"), ("害怕", "fearful"), ("讨厌", "disgusted"), @@ -67,10 +78,31 @@ OLD_FOUR_ENUMS = [ ("friendly", "happy"), ] +# 支持 emotion Instruct 的系统音色(白名单) +SYSTEM_EMOTION_VOICES = ["longanyang", "longanhuan", "longhuhu_v3"] + +# 不支持 Instruct 的典型系统音色(含默认音色 longxiaochun_v3) +NON_INSTRUCT_SYSTEM_VOICES = [ + "longxiaochun_v3", + "longxiaoxia_v3", + "longsanshu_v3", + "longyue_v3", + "longyingjing_v3", + "loongabby_v3", + "loongandy_v3", + "longfei_v3", +] + +# 克隆/设计音色(非 long/loong 前缀) +CLONED_VOICE_IDS = [ + "myclone_abc123", + "xiaoming_20260915", + "clone_voice_42", + "custom_voice_test", +] + class TestEmotionMapSevenStandard: - """7 种标准英文枚举必须映射到自身(CosyVoice 官方值)。""" - @pytest.mark.parametrize("enum_val", SEVEN_STANDARD_ENUMS) def test_standard_enum_maps_to_self(self, enum_val: str) -> None: assert enum_val in EMOTION_MAP @@ -82,27 +114,27 @@ class TestEmotionMapSevenStandard: @pytest.mark.parametrize("enum_val", SEVEN_STANDARD_ENUMS) def test_normalize_case_insensitive(self, enum_val: str) -> None: - """大小写不敏感:NEUTRAL/Happy/Angry 都能正确归一。""" assert normalize_emotion(enum_val.upper()) == enum_val assert normalize_emotion(enum_val.capitalize()) == enum_val assert normalize_emotion(f" {enum_val} ") == enum_val def test_neutral_maps_to_neutral(self) -> None: - """neutral 必须映射到 neutral(默认情绪)。""" assert normalize_emotion("neutral") == "neutral" class TestFrontendCnLabels: - """前端中文 7 标签必须映射到对应英文枚举。""" - @pytest.mark.parametrize("cn,expected", FRONTEND_CN_LABELS) def test_cn_label_normalizes_to_enum(self, cn: str, expected: str) -> None: assert normalize_emotion(cn) == expected -class TestBackwardCompatAliases: - """旧英文 4 枚举和中文别名必须兼容映射到标准值。""" +class TestLingyingSpecAliases: + @pytest.mark.parametrize("cn,expected", LINGYING_CN_ALIASES) + def test_lingying_aliases(self, cn: str, expected: str) -> None: + assert normalize_emotion(cn) == expected + +class TestBackwardCompatAliases: @pytest.mark.parametrize("old_key,expected", OLD_FOUR_ENUMS) def test_old_four_enums(self, old_key: str, expected: str) -> None: assert normalize_emotion(old_key) == expected @@ -113,11 +145,8 @@ class TestBackwardCompatAliases: class TestNormalizeEmotionEdgeCases: - """空串、空白、None、未知值等边界。""" - @pytest.mark.parametrize("empty_val", ["", None]) def test_empty_or_none_returns_empty(self, empty_val) -> None: - """空串/None 返回空串——调用方据此不传 instruction(CosyVoice 走默认自然情绪)。""" assert normalize_emotion(empty_val) == "" @pytest.mark.parametrize("ws", [" ", "\t", "\n", " \n "]) @@ -125,7 +154,6 @@ class TestNormalizeEmotionEdgeCases: assert normalize_emotion(ws) == "" def test_unknown_value_defaults_to_neutral_with_warning(self, caplog) -> None: - """未知值:不能失败,默认返回 neutral,并打 warning 日志。""" caplog.set_level(logging.WARNING) result = normalize_emotion("not_a_real_emotion_xyz") assert result == "neutral" @@ -134,79 +162,82 @@ class TestNormalizeEmotionEdgeCases: def test_strips_leading_trailing_whitespace(self) -> None: assert normalize_emotion(" happy ") == "happy" assert normalize_emotion(" 生气 ") == "angry" + assert normalize_emotion(" 中性 ") == "neutral" -class TestEmotionInstructionFormat: - """instruction 严格符合 CosyVoice v3 官方要求: - 格式:"你说话的情感是。" - - 必须以"你说话的情感是"开头 - - 必须以中文句号"。"结尾 - - 情感值必须是 7 种英文枚举之一 - - 只允许一个中文句号(结尾),防止多句注入 - """ - - PREFIX = "你说话的情感是" - SUFFIX = "。" - - def _build_instruction(self, emotion: str) -> str: - return f"{self.PREFIX}{normalize_emotion(emotion)}{self.SUFFIX}" - +class TestBuildEmotionInstructionClonedVoice: + @pytest.mark.parametrize("voice_id", CLONED_VOICE_IDS) @pytest.mark.parametrize("enum_val", SEVEN_STANDARD_ENUMS) - def test_instruction_starts_with_prefix(self, enum_val: str) -> None: - assert self._build_instruction(enum_val).startswith(self.PREFIX) - - @pytest.mark.parametrize("enum_val", SEVEN_STANDARD_ENUMS) - def test_instruction_ends_with_single_cn_period(self, enum_val: str) -> None: - inst = self._build_instruction(enum_val) - assert inst.endswith(self.SUFFIX) - assert inst.count("。") == 1, f"instruction 只能有一个中文句号,实际: {inst!r}" - - @pytest.mark.parametrize("enum_val", SEVEN_STANDARD_ENUMS) - def test_instruction_contains_english_enum(self, enum_val: str) -> None: - """instruction 中情感值必须是英文枚举,不能是中文描述词。""" - inst = self._build_instruction(enum_val) - # 取出情感值部分(去掉前缀和句号) - emotion_part = inst[len(self.PREFIX) : -len(self.SUFFIX)] - assert emotion_part == enum_val - # 必须是纯 ASCII 英文(英文枚举值) - assert emotion_part.isascii() - - @pytest.mark.parametrize("cn,expected", FRONTEND_CN_LABELS) - def test_cn_label_produces_correct_instruction(self, cn: str, expected: str) -> None: - """中文标签拼出的 instruction 情感值必须是英文枚举。""" - inst = self._build_instruction(cn) - assert inst == f"{self.PREFIX}{expected}{self.SUFFIX}" + def test_cloned_voice_uses_english_tone_format(self, voice_id: str, enum_val: str) -> None: + inst = build_emotion_instruction(voice_id, enum_val) + assert inst == f"Speak in a {enum_val} tone." + assert inst.isascii(), f"克隆音色 instruction 必须是纯 ASCII 英文: {inst!r}" @pytest.mark.parametrize( - "example", + "cn,expected", + FRONTEND_CN_LABELS + LINGYING_CN_ALIASES + CN_ALIASES, + ) + def test_cloned_voice_chinese_input_english_output(self, cn: str, expected: str) -> None: + norm = normalize_emotion(cn) + inst = build_emotion_instruction("myclone_voice", norm) + assert inst == f"Speak in a {expected} tone." + assert inst.isascii() + + def test_cloned_voice_empty_emotion_returns_empty(self) -> None: + assert build_emotion_instruction("myclone", "") == "" + + @pytest.mark.parametrize("voice_id", CLONED_VOICE_IDS) + def test_cloned_voice_unknown_emotion_falls_back_neutral(self, voice_id: str) -> None: + norm = normalize_emotion("unknown_xyz") + assert norm == "neutral" + inst = build_emotion_instruction(voice_id, norm) + assert inst == "Speak in a neutral tone." + + +class TestBuildEmotionInstructionSystemVoiceEmotion: + CN_PREFIX = "你说话的情感是" + CN_SUFFIX = "。" + + @pytest.mark.parametrize("voice_id", SYSTEM_EMOTION_VOICES) + @pytest.mark.parametrize("enum_val", SEVEN_STANDARD_ENUMS) + def test_system_emotion_voice_cn_fixed_format(self, voice_id: str, enum_val: str) -> None: + inst = build_emotion_instruction(voice_id, enum_val) + assert inst == f"{self.CN_PREFIX}{enum_val}{self.CN_SUFFIX}" + assert inst.count("。") == 1 + mid = inst[len(self.CN_PREFIX) : -len(self.CN_SUFFIX)] + assert mid == enum_val + assert mid.isascii(), f"系统音色 emotion 值必须是纯 ASCII 英文枚举: {inst!r}" + + @pytest.mark.parametrize("voice_id", SYSTEM_EMOTION_VOICES) + def test_system_emotion_voice_empty_returns_empty(self, voice_id: str) -> None: + assert build_emotion_instruction(voice_id, "") == "" + + +class TestBuildEmotionInstructionNonInstructSystemVoice: + @pytest.mark.parametrize("voice_id", NON_INSTRUCT_SYSTEM_VOICES) + @pytest.mark.parametrize("enum_val", SEVEN_STANDARD_ENUMS) + def test_non_instruct_voice_returns_empty(self, voice_id: str, enum_val: str) -> None: + assert build_emotion_instruction(voice_id, enum_val) == "" + + def test_default_voice_longxiaochun_v3_no_instruction(self) -> None: + assert build_emotion_instruction("longxiaochun_v3", "happy") == "" + assert build_emotion_instruction("longxiaochun_v3", "neutral") == "" + + +class TestBuildEmotionInstructionEdgeCases: + def test_empty_voice_id_treated_as_system(self) -> None: + assert build_emotion_instruction("", "happy") == "" + + @pytest.mark.parametrize( + "voice_id,enum_val,expected", [ - "neutral", - "happy", - "sad", - "angry", - "中立", - "开心", - "难过", - "生气", + ("MYCLONE_VOICE", "happy", "Speak in a happy tone."), + ("CloneVoice", "sad", "Speak in a sad tone."), ], ) - def test_example_matches_official_doc_format(self, example: str) -> None: - """对照官方示例 "你说话的情感是neutral。" 格式。""" - inst = self._build_instruction(example) - # 官方格式示例:你说话的情感是neutral。 - assert inst.startswith(self.PREFIX) - assert inst.endswith(self.SUFFIX) - # 中间必须是英文枚举 - mid = inst[len(self.PREFIX) : -len(self.SUFFIX)] - assert mid in SEVEN_STANDARD_ENUMS + def test_voice_id_case_handling(self, voice_id: str, enum_val: str, expected: str) -> None: + assert build_emotion_instruction(voice_id, enum_val) == expected - def test_empty_emotion_produces_no_instruction(self) -> None: - """空 emotion 不应拼 instruction(调用方据此跳过字段,CosyVoice 走默认)。""" - assert normalize_emotion("") == "" - assert normalize_emotion(" ") == "" - assert normalize_emotion(None) == "" - - def test_unknown_emotion_still_produces_valid_instruction(self) -> None: - """未知 emotion 默认 neutral,仍能产生合法 instruction,不会导致合成失败。""" - inst = self._build_instruction("unknown_xyz") - assert inst == f"{self.PREFIX}neutral{self.SUFFIX}" + def test_loong_prefix_is_system_voice(self) -> None: + assert build_emotion_instruction("loongandy_v3", "happy") == "" + assert build_emotion_instruction("loongabby_v3", "angry") == ""