feat: add DashScope custom voice enrollment flow
This commit is contained in:
@@ -20,6 +20,7 @@ from schemas import (
|
||||
ModelResourceUpsert,
|
||||
)
|
||||
from services.auth import require_admin
|
||||
from services.dashscope_voice import VoiceRequest, manage_voice
|
||||
from services.interface_catalog import validate_fields
|
||||
from services.masking import mask_secrets, merge_secrets
|
||||
from services.model_resource_tester import test_model_resource
|
||||
@@ -377,3 +378,16 @@ async def delete_model_resource(
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/model-resources/dashscope/voice")
|
||||
async def manage_dashscope_voice(request: VoiceRequest, session: AsyncSession = Depends(get_session)):
|
||||
"""Create/query a voice using draft settings and server-held credentials."""
|
||||
stored = {}
|
||||
if request.resource_id:
|
||||
row = await session.get(ModelResource, request.resource_id)
|
||||
if not row or row.interface_type != "dashscope-tts":
|
||||
raise HTTPException(404, "百炼 TTS 模型资源不存在")
|
||||
stored = row.secrets or {}
|
||||
key = merge_secrets({"apiKey": request.api_key}, stored).get("apiKey", "")
|
||||
return await manage_voice(request, key)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""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, "音色服务连接异常或返回无效,请稍后查询;创建结果不确定时不要重复创建")
|
||||
@@ -0,0 +1,25 @@
|
||||
import unittest
|
||||
from fastapi import HTTPException
|
||||
from services.dashscope_voice import VoiceRequest, enrollment_payload
|
||||
|
||||
|
||||
class VoiceEnrollmentTests(unittest.TestCase):
|
||||
def request(self, **changes):
|
||||
return VoiceRequest.model_validate({"apiUrl": "wss://dashscope.aliyuncs.com/api-ws/v1/inference", "model": "cosyvoice-v3-plus", "action": "create_voice", "audio": "data:audio/wav;base64,UklGRg==", **changes})
|
||||
|
||||
def test_creation_and_region(self):
|
||||
url, payload = enrollment_payload(self.request())
|
||||
self.assertEqual(url, "https://dashscope.aliyuncs.com/api/v1/services/audio/tts/customization")
|
||||
self.assertEqual(payload["input"]["target_model"], "cosyvoice-v3-plus")
|
||||
|
||||
def test_no_credentials_to_arbitrary_host(self):
|
||||
with self.assertRaises(HTTPException):
|
||||
enrollment_payload(self.request(apiUrl="https://evil.example"))
|
||||
|
||||
def test_query_needs_id(self):
|
||||
with self.assertRaises(HTTPException):
|
||||
enrollment_payload(self.request(action="query_voice"))
|
||||
|
||||
def test_invalid_audio(self):
|
||||
with self.assertRaises(HTTPException):
|
||||
enrollment_payload(self.request(audio="data:audio/wav;base64,???"))
|
||||
Reference in New Issue
Block a user