feat: 配音列表 API 增强 — 预置音色 + 克隆音色统一接口 #164

Merged
xiaoxia merged 2 commits from feature/task-304-voice-list-api into develop 2026-07-02 10:40:40 +08:00
5 changed files with 598 additions and 7 deletions
+146 -4
View File
@@ -1,11 +1,20 @@
"""Voice library CRUD routes."""
"""Voice library CRUD routes — Phase 3 增强版.
支持预置音色和克隆音色的统一列表。
"""
from __future__ import annotations
from typing import Optional
from typing import Literal, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.voice import (
PresetVoiceItemResponse,
PresetVoiceListResponse,
UnifiedVoiceItemResponse,
UnifiedVoiceListResponse,
)
from app.schemas.voice_library import (
CreateVoiceLibraryRequest,
ListVoiceLibraryResponse,
@@ -26,6 +35,7 @@ from packages.application.voice_library.use_cases import (
QuotaExceededError,
UpdateVoiceLibraryUseCase,
)
from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository
router = APIRouter()
@@ -55,6 +65,45 @@ def _to_response(item) -> VoiceLibraryItemResponse:
)
def _to_unified_response(item) -> UnifiedVoiceItemResponse:
"""将数据库音色转换为统一响应格式。"""
return UnifiedVoiceItemResponse(
id=item.id,
type="clone",
name=item.name,
description=item.text,
gender="unknown",
language="zh-CN",
voice_id=item.voice_id,
voice_provider=item.voice_provider or "cosyvoice",
audio_url=item.audio_url,
duration=item.duration,
file_size=item.file_size,
status=item.status,
tags=item.tags,
user_id=item.user_id,
project_id=item.project_id,
created_at=item.created_at,
updated_at=item.updated_at,
)
def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
"""将预置音色转换为统一响应格式。"""
return UnifiedVoiceItemResponse(
id=preset.voice_id,
type="preset",
name=preset.name,
description=preset.description,
gender=preset.gender,
language=preset.language,
voice_id=preset.voice_id,
voice_provider="cosyvoice",
preview_url=preset.preview_url,
tags=preset.tags or [],
)
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.find_by_id(user_id)
if user is None:
@@ -62,14 +111,107 @@ def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
return getattr(user, "subscription_plan", "free") or "free"
@router.get("", response_model=ListVoiceLibraryResponse)
def list_voices(
# ==================== 统一配音列表(预置 + 克隆)====================
@router.get("", response_model=UnifiedVoiceListResponse)
def list_voices_unified(
type: Optional[Literal["preset", "clone"]] = Query(
None,
description="音色类型过滤:preset=仅预置,clone=仅克隆,不传=全部",
),
status_filter: Optional[str] = Query(None, alias="status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
) -> UnifiedVoiceListResponse:
"""获取配音列表(预置音色 + 用户克隆音色)。
- 不传 type:返回预置音色 + 用户克隆音色,预置音色在前
- type=preset:仅返回预置音色
- type=clone:仅返回用户克隆音色
"""
user_id = authenticated_user.user.id
items: list[UnifiedVoiceItemResponse] = []
preset_count = 0
clone_count = 0
has_preset = type is None or type == "preset"
has_clone = type is None or type == "clone"
# 获取预置音色
if has_preset:
preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES]
preset_count = len(preset_items)
# 获取克隆音色
if has_clone:
use_case = ListVoiceLibraryUseCase(voice_repository)
clone_items_raw = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
clone_items = [_to_unified_response(i) for i in clone_items_raw]
clone_count = voice_repository.count_by_user(user_id, status=status_filter) if status_filter else voice_repository.count_by_user(user_id)
# 组装结果
if type == "preset":
items = preset_items[skip : skip + limit]
total = preset_count
elif type == "clone":
items = clone_items
total = clone_count
else:
# 全量模式:预置在前,克隆补位
all_items = preset_items + clone_items
total = preset_count + clone_count
items = all_items[skip : skip + limit]
return UnifiedVoiceListResponse(
items=items,
total=total,
preset_count=preset_count if has_preset else 0,
clone_count=clone_count if has_clone else 0,
)
# ==================== 预置音色专用端点 ====================
@router.get("/presets", response_model=PresetVoiceListResponse)
def list_preset_voices() -> PresetVoiceListResponse:
"""获取预置音色列表。
不需要认证,返回所有系统预置的 CosyVoice 音色。
"""
items = [
PresetVoiceItemResponse(
voice_id=p.voice_id,
name=p.name,
description=p.description,
gender=p.gender,
language=p.language,
preview_url=p.preview_url,
tags=p.tags or [],
)
for p in PRESET_VOICES
]
return PresetVoiceListResponse(items=items, total=len(items))
# ==================== 原有 CRUD 端点(保持向后兼容)====================
@router.get("/legacy", response_model=ListVoiceLibraryResponse)
def list_voices_legacy(
status_filter: Optional[str] = Query(None, alias="status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
) -> ListVoiceLibraryResponse:
"""原有配音列表接口(仅返回用户克隆音色)。
保留用于向后兼容,新客户端请使用 GET /api/v1/voices。
"""
user_id = authenticated_user.user.id
use_case = ListVoiceLibraryUseCase(voice_repository)
items = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
+127
View File
@@ -0,0 +1,127 @@
"""统一配音响应 Schema — Phase 3 CosyVoice 集成.
支持预置音色和克隆音色的统一响应格式。
"""
from __future__ import annotations
from datetime import datetime
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
class UnifiedVoiceItemResponse(BaseModel):
"""统一配音项响应。
同时支持预置音色(type=preset)和克隆音色(type=clone)。
"""
id: str
"""音色 ID(预置音色为 voice_id,克隆音色为数据库 ID)"""
type: Literal["preset", "clone"]
"""音色类型:preset=预置音色,clone=用户克隆音色"""
name: str
"""音色展示名称"""
description: str = ""
"""音色描述"""
gender: str = "unknown"
"""性别:male/female/unknown"""
language: str = "zh-CN"
"""语言代码"""
voice_id: str = ""
"""CosyVoice 模型音色名"""
voice_provider: str = "cosyvoice"
"""语音服务商"""
audio_url: str = ""
"""音频 URL(克隆音色为上传的音频,预置音色为空)"""
preview_url: str = ""
"""预览音频 URL(预置音色可能有)"""
duration: float = 0
"""音频时长(秒)"""
file_size: int = 0
"""文件大小(字节)"""
status: str = "completed"
"""状态"""
tags: List[str] = Field(default_factory=list)
"""标签列表"""
# 克隆音色特有字段
user_id: Optional[str] = None
"""所属用户 ID(仅克隆音色)"""
project_id: Optional[str] = None
"""所属项目 ID(仅克隆音色)"""
voice_clone_profile_id: Optional[str] = None
"""关联的音色克隆档案 ID(仅克隆音色)"""
created_at: Optional[datetime] = None
"""创建时间(仅克隆音色)"""
updated_at: Optional[datetime] = None
"""更新时间(仅克隆音色)"""
class UnifiedVoiceListResponse(BaseModel):
"""统一配音列表响应。"""
items: list[UnifiedVoiceItemResponse]
"""音色列表(预置音色在前)"""
total: int = 0
"""总数"""
preset_count: int = 0
"""预置音色数量"""
clone_count: int = 0
"""克隆音色数量"""
class PresetVoiceItemResponse(BaseModel):
"""预置音色项响应。"""
voice_id: str
"""CosyVoice 模型音色名"""
name: str
"""中文展示名"""
description: str
"""音色描述"""
gender: str
"""性别"""
language: str = "zh-CN"
"""语言代码"""
preview_url: str = ""
"""预览音频 URL"""
tags: List[str] = Field(default_factory=list)
"""标签列表"""
class PresetVoiceListResponse(BaseModel):
"""预置音色列表响应。"""
items: list[PresetVoiceItemResponse]
"""预置音色列表"""
total: int = 0
"""总数"""
@@ -110,15 +110,17 @@ class SQLAlchemyVoiceLibraryRepository:
self.session.commit()
return True
def count_by_user(self, user_id: str) -> int:
return (
def count_by_user(self, user_id: str, *, status: Optional[str] = None) -> int:
query = (
self.session.query(VoiceLibraryModel)
.filter(
VoiceLibraryModel.user_id == user_id,
VoiceLibraryModel.status != "deleted",
)
.count()
)
if status:
query = query.filter(VoiceLibraryModel.status == status)
return query.count()
@staticmethod
def _model_to_entity(model: VoiceLibraryModel) -> VoiceLibraryItem:
+133
View File
@@ -0,0 +1,133 @@
"""预置音色配置 — Phase 3 CosyVoice 集成.
定义系统预置的 CosyVoice 音色列表,不存数据库,以配置方式提供。
音色 ID 对应阿里云 CosyVoice 模型中的真实音色名。
参考: https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class PresetVoice:
"""预置音色定义。
Attributes:
voice_id: CosyVoice 模型音色名(如 longxiaochun
name: 中文展示名
description: 音色描述
gender: 性别(male/female
language: 语言代码(如 zh-CN
preview_url: 预览音频 URL(可选)
tags: 标签列表
"""
voice_id: str
name: str
description: str
gender: str
language: str = "zh-CN"
preview_url: str = ""
tags: list[str] | None = None
def to_dict(self) -> dict:
"""序列化为字典。"""
return {
"voice_id": self.voice_id,
"name": self.name,
"description": self.description,
"gender": self.gender,
"language": self.language,
"preview_url": self.preview_url,
"tags": self.tags or [],
}
# 预置音色列表(阿里云 CosyVoice 真实可用音色)
PRESET_VOICES: list[PresetVoice] = [
PresetVoice(
voice_id="longxiaochun",
name="龙小淳",
description="温柔女声,适合情感类内容",
gender="female",
language="zh-CN",
tags=["温柔", "女声", "情感"],
),
PresetVoice(
voice_id="longxiaoxia",
name="龙小夏",
description="知性女声,适合新闻播报",
gender="female",
language="zh-CN",
tags=["知性", "女声", "播报"],
),
PresetVoice(
voice_id="longxiaochen",
name="龙小晨",
description="磁性男声,适合有声书",
gender="male",
language="zh-CN",
tags=["磁性", "男声", "有声书"],
),
PresetVoice(
voice_id="longyue",
name="龙悦",
description="甜美女声,适合广告配音",
gender="female",
language="zh-CN",
tags=["甜美", "女声", "广告"],
),
PresetVoice(
voice_id="longshu",
name="龙书",
description="沉稳男声,适合教育讲解",
gender="male",
language="zh-CN",
tags=["沉稳", "男声", "教育"],
),
PresetVoice(
voice_id="longjing",
name="龙静",
description="优雅女声,适合纪录片解说",
gender="female",
language="zh-CN",
tags=["优雅", "女声", "纪录片"],
),
PresetVoice(
voice_id="longbo",
name="龙博",
description="浑厚男声,适合科技类内容",
gender="male",
language="zh-CN",
tags=["浑厚", "男声", "科技"],
),
PresetVoice(
voice_id="longtian",
name="龙甜",
description="活泼女声,适合短视频配音",
gender="female",
language="zh-CN",
tags=["活泼", "女声", "短视频"],
),
]
def get_preset_voices() -> list[PresetVoice]:
"""获取所有预置音色。"""
return PRESET_VOICES
def get_preset_voice_by_id(voice_id: str) -> PresetVoice | None:
"""根据 voice_id 获取预置音色。"""
for voice in PRESET_VOICES:
if voice.voice_id == voice_id:
return voice
return None
def is_preset_voice(voice_id: str) -> bool:
"""判断是否为预置音色。"""
return get_preset_voice_by_id(voice_id) is not None
+187
View File
@@ -0,0 +1,187 @@
"""预置音色配置单元测试 — Phase 3 CosyVoice 集成."""
from __future__ import annotations
import pytest
from packages.domain.preset_voices import (
PRESET_VOICES,
PresetVoice,
get_preset_voice_by_id,
get_preset_voices,
is_preset_voice,
)
class TestPresetVoice:
"""测试 PresetVoice 数据类。"""
def test_preset_voice_fields(self) -> None:
"""预置音色字段完整。"""
voice = PresetVoice(
voice_id="test_voice",
name="测试音色",
description="测试描述",
gender="female",
language="zh-CN",
preview_url="https://example.com/preview.mp3",
tags=["测试"],
)
assert voice.voice_id == "test_voice"
assert voice.name == "测试音色"
assert voice.description == "测试描述"
assert voice.gender == "female"
assert voice.language == "zh-CN"
assert voice.preview_url == "https://example.com/preview.mp3"
assert voice.tags == ["测试"]
def test_preset_voice_defaults(self) -> None:
"""预置音色默认值。"""
voice = PresetVoice(
voice_id="test",
name="测试",
description="描述",
gender="male",
)
assert voice.language == "zh-CN"
assert voice.preview_url == ""
assert voice.tags is None
def test_preset_voice_frozen(self) -> None:
"""预置音色不可变。"""
voice = PresetVoice(
voice_id="test",
name="测试",
description="描述",
gender="male",
)
with pytest.raises(AttributeError):
voice.name = "新名称"
def test_preset_voice_to_dict(self) -> None:
"""序列化。"""
voice = PresetVoice(
voice_id="longxiaochun",
name="龙小淳",
description="温柔女声",
gender="female",
tags=["温柔", "女声"],
)
result = voice.to_dict()
assert result["voice_id"] == "longxiaochun"
assert result["name"] == "龙小淳"
assert result["description"] == "温柔女声"
assert result["gender"] == "female"
assert result["language"] == "zh-CN"
assert result["preview_url"] == ""
assert result["tags"] == ["温柔", "女声"]
def test_preset_voice_to_dict_no_tags(self) -> None:
"""tags 为 None 时序列化为空列表。"""
voice = PresetVoice(
voice_id="test",
name="测试",
description="描述",
gender="male",
)
result = voice.to_dict()
assert result["tags"] == []
class TestPresetVoicesConfig:
"""测试预置音色配置列表。"""
def test_preset_voices_not_empty(self) -> None:
"""预置音色列表不为空。"""
assert len(PRESET_VOICES) >= 5
def test_preset_voices_has_8_voices(self) -> None:
"""应有 8 个预置音色。"""
assert len(PRESET_VOICES) == 8
def test_all_voices_have_required_fields(self) -> None:
"""所有预置音色都有必填字段。"""
for voice in PRESET_VOICES:
assert voice.voice_id, f"voice_id 为空: {voice}"
assert voice.name, f"name 为空: {voice}"
assert voice.description, f"description 为空: {voice}"
assert voice.gender in ("male", "female"), f"gender 无效: {voice}"
assert voice.language == "zh-CN", f"language 应为 zh-CN: {voice}"
def test_all_voices_have_unique_ids(self) -> None:
"""所有预置音色 ID 唯一。"""
ids = [v.voice_id for v in PRESET_VOICES]
assert len(ids) == len(set(ids)), "存在重复的 voice_id"
def test_all_voices_have_unique_names(self) -> None:
"""所有预置音色名称唯一。"""
names = [v.name for v in PRESET_VOICES]
assert len(names) == len(set(names)), "存在重复的 name"
def test_cosyvoice_voice_ids(self) -> None:
"""音色 ID 应为 CosyVoice 真实可用的音色名。"""
expected_ids = {
"longxiaochun",
"longxiaoxia",
"longxiaochen",
"longyue",
"longshu",
"longjing",
"longbo",
"longtian",
}
actual_ids = {v.voice_id for v in PRESET_VOICES}
assert actual_ids == expected_ids
def test_gender_distribution(self) -> None:
"""男女音色分布合理。"""
male_count = sum(1 for v in PRESET_VOICES if v.gender == "male")
female_count = sum(1 for v in PRESET_VOICES if v.gender == "female")
assert male_count == 3
assert female_count == 5
def test_all_have_tags(self) -> None:
"""所有预置音色都有标签。"""
for voice in PRESET_VOICES:
assert voice.tags is not None, f"tags 为 None: {voice.name}"
assert len(voice.tags) >= 2, f"标签太少: {voice.name}"
class TestPresetVoiceHelpers:
"""测试辅助函数。"""
def test_get_preset_voices(self) -> None:
"""get_preset_voices 返回完整列表。"""
voices = get_preset_voices()
assert voices == PRESET_VOICES
assert len(voices) == 8
def test_get_preset_voice_by_id_found(self) -> None:
"""按 ID 查找存在的音色。"""
voice = get_preset_voice_by_id("longxiaochun")
assert voice is not None
assert voice.name == "龙小淳"
assert voice.voice_id == "longxiaochun"
def test_get_preset_voice_by_id_not_found(self) -> None:
"""按 ID 查找不存在的音色。"""
voice = get_preset_voice_by_id("nonexistent_voice")
assert voice is None
def test_is_preset_voice_true(self) -> None:
"""判断预置音色返回 True。"""
assert is_preset_voice("longxiaochun") is True
assert is_preset_voice("longxiaoxia") is True
assert is_preset_voice("longbo") is True
def test_is_preset_voice_false(self) -> None:
"""判断非预置音色返回 False。"""
assert is_preset_voice("nonexistent") is False
assert is_preset_voice("") is False
assert is_preset_voice("user_custom_voice") is False