Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2aecfae0e6 |
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 + 句子时间戳计算.
|
||||
|
||||
|
||||
@@ -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": "厌恶",
|
||||
"厌恶": "厌恶",
|
||||
"讨厌": "厌恶",
|
||||
"严肃": "严肃",
|
||||
|
||||
@@ -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("") == ""
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user