Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia-agent 2aecfae0e6 feat(#1898): TTS 情绪枚举统一为 7 种英文标签 + 单测补全(P1)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
AI Code Review / AI Code Review (pull_request) Successful in 6m29s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 10m1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m15s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m51s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m57s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m53s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m44s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 1m22s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m38s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 25h57m47s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 26h16m36s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 26h16m43s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 26h16m1s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 25h57m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 26h16m0s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 26h16m4s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 26h16m4s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 26h16m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 26h16m5s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 25h57m9s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 25h57m11s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 25h57m11s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 26h5m58s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 26h5m59s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 26h15m58s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 26h15m59s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 26h16m6s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 26h51m40s
- 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
2026-09-15 13:06:37 +08:00
6 changed files with 131 additions and 20 deletions
+19 -3
View File
@@ -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)
+3 -1
View File
@@ -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):
+1 -1
View File
@@ -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 + 句子时间戳计算.
+18 -14
View File
@@ -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": "厌恶",
"厌恶": "厌恶",
"讨厌": "厌恶",
"严肃": "严肃",
+89
View File
@@ -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("") == ""
+1 -1
View File
@@ -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()