diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py index b6db583b3..ccf67d5ab 100644 --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -9,7 +9,7 @@ from typing import Literal, Optional from app.api.routes._helpers import get_user_plan from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository +from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository from app.schemas.voice import ( PresetVoiceItemResponse, PresetVoiceListResponse, @@ -27,6 +27,7 @@ from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository +from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand from packages.application.voice_library.use_cases import ( CreateVoiceLibraryUseCase, @@ -37,11 +38,18 @@ from packages.application.voice_library.use_cases import ( QuotaExceededError, UpdateVoiceLibraryUseCase, ) -from packages.domain.preset_voices import PRESET_VOICES +from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id from packages.ports.user_repository import UserRepository router = APIRouter() +# 预置音色试听音频缓存(内存缓存,减少重复TTS调用) +# key: voice_id, value: (audio_url, timestamp) +_preset_preview_cache: dict[str, tuple[str, float]] = {} +PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL +# 每个预置音色的默认试听文本 +PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。" + def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository: return SQLAlchemyVoiceLibraryRepository(session) @@ -216,6 +224,58 @@ def list_preset_voices() -> PresetVoiceListResponse: return PresetVoiceListResponse(items=items, total=len(items)) +@router.get("/presets/{voice_id}/preview") +def get_preset_voice_preview( + voice_id: str, + text: str = Query("", description="自定义试听文本,为空则使用默认示例"), + cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service), +) -> dict: + """获取预置音色试听音频(实时 TTS 合成)。 + + - 首次调用会合成并缓存7天 + - 相同 voice_id 重复调用直接返回缓存的音频URL + - 可传入自定义 text 参数试听不同文本 + """ + import time + + preset = get_preset_voice_by_id(voice_id) + if preset is None: + raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}") + + # 有自定义文本时不缓存 + use_cache = not text.strip() + + if use_cache and voice_id in _preset_preview_cache: + audio_url, cached_at = _preset_preview_cache[voice_id] + if time.time() - cached_at < PREVIEW_CACHE_TTL: + return {"voice_id": voice_id, "audio_url": audio_url, "cached": True} + + # 合成试听音频 + preview_text = text.strip() or PREVIEW_TEMPLATE.format(name=preset.name) + try: + result = cosyvoice.synthesize_speech( + text=preview_text, + voice_id=preset.voice_id, + format="mp3", + speed=1.0, + ) + except CosyVoiceError as e: + raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e + + audio_url = result.audio_url + + # 缓存(仅默认试听文本) + if use_cache: + _preset_preview_cache[voice_id] = (audio_url, time.time()) + + return { + "voice_id": voice_id, + "audio_url": audio_url, + "text": preview_text, + "cached": False, + } + + # ==================== 原有 CRUD 端点(保持向后兼容)====================