From 2aecfae0e6f516f9682853b5737ca806d87c01a1 Mon Sep 17 00:00:00 2001 From: xiaoxia-agent Date: Tue, 15 Sep 2026 13:06:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(#1898):=20TTS=20=E6=83=85=E7=BB=AA?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E7=BB=9F=E4=B8=80=E4=B8=BA=207=20=E7=A7=8D?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E6=A0=87=E7=AD=BE=20+=20=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=EF=BC=88P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EMOTION_MAP 首键改为 7 种标准英文枚举: neutral/happy/sad/angry/surprised/fearful/disgusted - 兼容旧四值 natural/excited/calm/friendly 与中文标签 - lipsync 默认 emotion 从 natural 改为 neutral - voice_clones preview 允许 7 种新情绪值 - instruction 格式保持 "你说话的情感是{emotion}。" 中文句号结尾 - 新增 test_cosyvoice_emotion.py 22 个单测(7种枚举+大小写+中文别名+旧兼容+边界+instruction格式校验) - 修复 test_voice_clone_preview 用例使用非法 emotion 测试 400 --- apps/api/app/api/routes/voice_clones.py | 22 +++++- apps/api/app/schemas/lipsync.py | 4 +- apps/api/app/services/lipsync_service.py | 2 +- packages/application/cosyvoice_service.py | 32 ++++---- tests/unit/test_cosyvoice_emotion.py | 89 +++++++++++++++++++++++ tests/unit/test_voice_clone_preview.py | 2 +- 6 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 tests/unit/test_cosyvoice_emotion.py diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py index 0a2a5afc5..6dcb8548b 100755 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -287,7 +287,20 @@ def retry_voice_clone( return _to_response(profile) -_ALLOWED_PREVIEW_EMOTIONS = {"", "natural", "excited", "calm", "friendly"} +_ALLOWED_PREVIEW_EMOTIONS = { + "", + "neutral", + "natural", + "happy", + "sad", + "angry", + "surprised", + "fearful", + "disgusted", + "excited", + "calm", + "friendly", +} @router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse) @@ -295,7 +308,10 @@ def get_voice_clone_preview( clone_id: str, text: str = Query("", description="自定义试听文本,为空则使用默认示例"), speed: float = Query(1.0, ge=0.5, le=2.0, description="语速,0.5-2.0,默认 1.0"), - emotion: str = Query("", description="情绪:natural/excited/calm/friendly,空字符串为默认自然"), + emotion: str = Query( + "", + description="情绪:neutral/happy/sad/angry/surprised/fearful/disgusted(兼容旧 natural/excited/calm/friendly),空字符串为默认自然", + ), authenticated_user: AuthenticatedUser = Depends(get_current_user), repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service), @@ -311,7 +327,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},可选: natural/excited/calm/friendly 或留空", + detail=f"不支持的 emotion 值: {emotion},可选: neutral/happy/sad/angry/surprised/fearful/disgusted 或留空", ) use_case = GetVoiceCloneUseCase(repository) diff --git a/apps/api/app/schemas/lipsync.py b/apps/api/app/schemas/lipsync.py index ed8cf8e3a..e00c33c80 100644 --- a/apps/api/app/schemas/lipsync.py +++ b/apps/api/app/schemas/lipsync.py @@ -120,7 +120,9 @@ class AiAvatarTtsPreviewRequest(BaseModel): voice_id: str = Field(..., min_length=1, max_length=128, description="音色 ID") script_text: str = Field(..., min_length=1, max_length=5000, description="要合成的文案") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0") - emotion: str = Field("natural", max_length=32, description="情绪") + emotion: str = Field( + "neutral", max_length=32, description="情绪:neutral/happy/sad/angry/surprised/fearful/disgusted" + ) class AiAvatarTtsPreviewResponse(BaseModel): diff --git a/apps/api/app/services/lipsync_service.py b/apps/api/app/services/lipsync_service.py index 9ea698354..10110d27d 100644 --- a/apps/api/app/services/lipsync_service.py +++ b/apps/api/app/services/lipsync_service.py @@ -360,7 +360,7 @@ class LipsyncService: voice_id: str, script_text: str, speed: float = 1.0, - emotion: str = "natural", + emotion: str = "neutral", ) -> dict: """同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算. diff --git a/packages/application/cosyvoice_service.py b/packages/application/cosyvoice_service.py index c3ee745b4..d5be14e8c 100644 --- a/packages/application/cosyvoice_service.py +++ b/packages/application/cosyvoice_service.py @@ -28,34 +28,38 @@ logger = logging.getLogger(__name__) # CosyVoice v3 情绪通过 input.instruction 中文自然语言指令控制(不再使用枚举 emotion 字段)。 # 前端可传中文或英文情绪标签,统一归一化为中文描述词,再拼进 instruction。 -# 映射表 key: 小写中文/英文 → 中文情绪描述词 -EMOTION_MAP = { - # 原有四值(中文 + 英文) - "自然": "自然", - "兴奋": "兴奋开心", - "沉稳": "沉稳平静", - "亲切": "亲切友好", +# 标准 7 种情绪英文枚举(前端 v2 约定,key=neutral/happy/sad/angry/surprised/fearful/disgusted)。 +# 同时兼容旧四值 natural/excited/calm/friendly 以及中文标签,统一归一化为中文情绪描述词。 +# 中文描述词拼入 CosyVoice instruction 自然语言指令。 +EMOTION_MAP: dict[str, str] = { + # 7 种标准英文枚举 + "neutral": "自然", + "happy": "开心愉快", + "sad": "悲伤难过", + "angry": "愤怒", + "surprised": "惊讶", + "fearful": "恐惧", + "disgusted": "厌恶", + # 旧英文枚举兼容 "natural": "自然", "excited": "兴奋开心", "calm": "沉稳平静", "friendly": "亲切友好", - "happy": "开心愉快", - # 新增情绪 + # 中文标签 + "自然": "自然", + "兴奋": "兴奋开心", + "沉稳": "沉稳平静", + "亲切": "亲切友好", "开心": "开心愉快", "愉快": "开心愉快", - "sad": "悲伤难过", "悲伤": "悲伤难过", "难过": "悲伤难过", - "angry": "愤怒", "愤怒": "愤怒", "生气": "愤怒", - "surprised": "惊讶", "惊讶": "惊讶", "惊奇": "惊讶", - "fearful": "恐惧", "恐惧": "恐惧", "害怕": "恐惧", - "disgusted": "厌恶", "厌恶": "厌恶", "讨厌": "厌恶", "严肃": "严肃", diff --git a/tests/unit/test_cosyvoice_emotion.py b/tests/unit/test_cosyvoice_emotion.py new file mode 100644 index 000000000..cca529e5b --- /dev/null +++ b/tests/unit/test_cosyvoice_emotion.py @@ -0,0 +1,89 @@ +"""CosyVoice 情绪归一化单元测试(P1 七种情绪).""" + +from __future__ import annotations + +import logging + +import pytest + +from packages.application.cosyvoice_service import normalize_emotion + + +class TestNormalizeEmotionSevenStandard: + """P1:7 种英文情绪枚举必须能正常归一化到中文描述词。""" + + @pytest.mark.parametrize( + "emotion,expected_keyword", + [ + ("neutral", "自然"), + ("happy", "开心"), + ("sad", "悲伤"), + ("angry", "愤怒"), + ("surprised", "惊讶"), + ("fearful", "恐惧"), + ("disgusted", "厌恶"), + ], + ) + def test_standard_english_enums(self, emotion: str, expected_keyword: str) -> None: + result = normalize_emotion(emotion) + assert expected_keyword in result + + def test_case_insensitive(self) -> None: + """大小写不敏感(前端可能传 HAPPY / Happy)。""" + assert "开心" in normalize_emotion("HAPPY") + assert "自然" in normalize_emotion("Neutral") + assert "愤怒" in normalize_emotion("Angry") + + def test_chinese_aliases_work(self) -> None: + """中文情绪标签同样能归一化。""" + assert normalize_emotion("自然") == "自然" + assert "开心" in normalize_emotion("开心") + assert "悲伤" in normalize_emotion("难过") + assert "愤怒" in normalize_emotion("生气") + + def test_old_four_value_aliases_still_work(self) -> None: + """旧四值 natural/excited/calm/friendly 继续兼容。""" + assert normalize_emotion("natural") == "自然" + assert "兴奋" in normalize_emotion("excited") + assert "沉稳" in normalize_emotion("calm") + assert "亲切" in normalize_emotion("friendly") + + +class TestNormalizeEmotionEdgeCases: + def test_empty_string_returns_empty(self) -> None: + assert normalize_emotion("") == "" + + def test_whitespace_only_returns_empty(self) -> None: + assert normalize_emotion(" ") == "" + + def test_unknown_value_returns_empty_and_warns(self, caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.WARNING) + assert normalize_emotion("unknown_emotion_xyz") == "" + assert any("未知" in rec.message for rec in caplog.records) + + def test_strips_whitespace(self) -> None: + assert "开心" in normalize_emotion(" happy ") + assert "自然" in normalize_emotion("\tneutral\n") + + +class TestEmotionInstructionFormat: + """验证 instruction 拼接格式:'你说话的情感是{emotion}。' 中文句号结尾。""" + + def _build_instruction(self, emotion: str) -> str: + norm = normalize_emotion(emotion) + if not norm: + return "" + return f"你说话的情感是{norm}。" + + @pytest.mark.parametrize( + "emotion", + ["neutral", "happy", "sad", "angry", "surprised", "fearful", "disgusted"], + ) + def test_instruction_format_ends_with_chinese_period(self, emotion: str) -> None: + instr = self._build_instruction(emotion) + assert instr.startswith("你说话的情感是") + assert instr.endswith("。") + assert instr.count("。") == 1 + + def test_empty_emotion_gives_empty_instruction(self) -> None: + assert self._build_instruction("") == "" diff --git a/tests/unit/test_voice_clone_preview.py b/tests/unit/test_voice_clone_preview.py index a88d459eb..27c7c5091 100755 --- a/tests/unit/test_voice_clone_preview.py +++ b/tests/unit/test_voice_clone_preview.py @@ -196,7 +196,7 @@ class TestVoiceClonePreview: cosyvoice = MagicMock() with pytest.raises(HTTPException) as exc_info: - self._call_preview(profile, cosyvoice, emotion="angry") + self._call_preview(profile, cosyvoice, emotion="invalid_emotion_xyz") assert exc_info.value.status_code == 400 cosyvoice.synthesize_speech.assert_not_called()