"""DashScope voice enrollment, separate from the real-time synthesis path.""" import base64 import re from typing import Literal from urllib.parse import urlsplit import httpx from fastapi import HTTPException from pydantic import BaseModel, Field from services.pipecat.dashscope_tts import SUPPORTED_MODELS class VoiceRequest(BaseModel): resource_id: str | None = Field(default=None, alias="resourceId") api_key: str = Field(default="", alias="apiKey", max_length=4096) api_url: str = Field(alias="apiUrl", max_length=2048) model: str action: Literal["create_voice", "query_voice"] prefix: str = Field(default="assistant", pattern=r"^[A-Za-z0-9]{1,10}$") audio: str = Field(default="", max_length=14_000_000) voice_id: str = Field(default="", alias="voiceId", max_length=512) def enrollment_payload(request: VoiceRequest): host = urlsplit(request.api_url).hostname or "" allowed = host in {"dashscope.aliyuncs.com", "dashscope-intl.aliyuncs.com"} or re.fullmatch( r"[a-zA-Z0-9-]+\.(cn-beijing|ap-southeast-1)\.maas\.aliyuncs\.com", host) if not allowed: raise HTTPException(400, "音色复刻仅支持百炼北京或新加坡官方域名") if request.model not in SUPPORTED_MODELS or request.model == "cosyvoice-v1": raise HTTPException(400, "当前模型不支持此声音复刻流程") data = {"action": request.action} if request.action == "create_voice": match = re.fullmatch(r"data:audio/(wav|x-wav|mpeg|mp3|mp4|m4a|x-m4a);base64,(.+)", request.audio) if not match: raise HTTPException(400, "请上传 WAV、MP3 或 M4A 音频") try: raw = base64.b64decode(match[2], validate=True) except ValueError: raise HTTPException(400, "音频编码不合法") if not raw or len(raw) > 10 * 1024 * 1024: raise HTTPException(400, "音频必须非空且不超过 10 MB") data.update(target_model=request.model, prefix=request.prefix, audio=request.audio) else: if not request.voice_id: raise HTTPException(400, "缺少音色 ID") data["voice_id"] = request.voice_id return f"https://{host}/api/v1/services/audio/tts/customization", {"model": "voice-enrollment", "input": data} async def manage_voice(request: VoiceRequest, api_key: str): if not api_key: raise HTTPException(400, "请先填写 API Key") url, payload = enrollment_payload(request) try: async with httpx.AsyncClient(timeout=60) as client: response = await client.post(url, json=payload, headers={"Authorization": f"Bearer {api_key}"}) if response.is_error: raise HTTPException(502, f"百炼音色服务请求失败(HTTP {response.status_code}),请检查凭据、模型和录音要求") output = response.json()["output"] if request.action == "create_voice": return {"voiceId": output["voice_id"], "status": "DEPLOYING"} if output.get("target_model") != request.model: raise HTTPException(400, "此音色绑定的模型与当前选择不一致") return {"voiceId": request.voice_id, "status": output["status"]} except (httpx.HTTPError, ValueError, KeyError, TypeError): raise HTTPException(502, "音色服务连接异常或返回无效,请稍后查询;创建结果不确定时不要重复创建")