fix: 修复预置音色voice_id并填充preview_url #1216

Merged
xiaoxia merged 4 commits from feat/preset-voice-previews into develop 2026-08-02 08:55:14 +08:00
5 changed files with 221 additions and 35 deletions
+26 -20
View File
@@ -46,63 +46,68 @@ class PresetVoice:
}
# 预置音色列表(阿里云 CosyVoice 真实可用音色)
# 预置音色列表(阿里云 CosyVoice v3-flash 真实可用音色)
# 参考: https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list
PRESET_VOICES: list[PresetVoice] = [
PresetVoice(
voice_id="longxiaochun_v3",
name="龙小淳",
description="温柔女声,适合情感类内容",
description="知性积极女声,适合语音助手",
gender="female",
language="zh-CN",
tags=["温柔", "女声", "情感"],
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/a8fb29a2-7d44-9f16-9dab-f17fa806cf3d.mp3?Expires=1785716123&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=c74kFX%2BcDpmuBny2dPBkvak01oY%3D",
tags=["知性", "女声", "助手"],
),
PresetVoice(
voice_id="longxiaoxia_v3",
name="龙小夏",
description="知性女声,适合新闻播报",
description="沉稳权威女声,适合新闻播报",
gender="female",
language="zh-CN",
tags=["知性", "女声", "播报"],
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/d065a6da-de0d-9ad2-b64a-00aaf9aa3dea.mp3?Expires=1785716203&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=9hRh7xbmS8eDdiBDDFUatvad1jg%3D",
tags=["沉稳", "女声", "播报"],
),
PresetVoice(
voice_id="longxiaochen_v3",
name="小晨",
description="磁性男声,适合有声书",
voice_id="longsanshu_v3",
name="三叔",
description="沉稳质感男声,适合有声书",
gender="male",
language="zh-CN",
tags=["磁性", "男声", "有声书"],
tags=["沉稳", "男声", "有声书"],
),
PresetVoice(
voice_id="longyue_v3",
name="龙悦",
description="甜美女声,适合广告配音",
description="温暖磁性女声,适合广告配音",
gender="female",
language="zh-CN",
tags=["甜美", "女声", "广告"],
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/1ea86db3-4dc9-927e-9b76-c83d89f6788c.mp3?Expires=1785716207&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=Vg0ckgSgPzgNQ%2FNdeJzrE%3D",
tags=["温暖", "女声", "广告"],
),
PresetVoice(
voice_id="longshu_v3",
name="龙书",
description="沉稳男声,适合教育讲解",
description="沉稳青年男声,适合教育讲解",
gender="male",
language="zh-CN",
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/6b47a5ce-ae21-995a-ac8b-5e55ed04ec45.mp3?Expires=1785716209&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=zm6WGmthpwTjf8PzgNQ%2FNdeJzrE%3D",
tags=["沉稳", "男声", "教育"],
),
PresetVoice(
voice_id="longjing_v3",
name="龙静",
description="优雅女声,适合纪录片解说",
voice_id="longyingjing_v3",
name="",
description="低调冷静女声,适合纪录片解说",
gender="female",
language="zh-CN",
tags=["优雅", "女声", "纪录片"],
tags=["冷静", "女声", "纪录片"],
),
PresetVoice(
voice_id="longbo_v3",
name="",
description="浑厚男声,适合科技类内容",
voice_id="longshuo_v3",
name="",
description="博才干练男声,适合科技类内容",
gender="male",
language="zh-CN",
tags=["浑厚", "男声", "科技"],
tags=["干练", "男声", "科技"],
),
PresetVoice(
voice_id="longtian_v3",
@@ -110,6 +115,7 @@ PRESET_VOICES: list[PresetVoice] = [
description="活泼女声,适合短视频配音",
gender="female",
language="zh-CN",
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/a38e4c05-25bf-9640-92d6-0b4a14555cdb.mp3?Expires=1785716215&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=WtbsqqfR4A6OhpL%2B%2Fe0lh7YBMWM%3D",
tags=["活泼", "女声", "短视频"],
),
]
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Seed test voice clone profiles for staging E2E testing.
Usage:
# On staging (inside Docker container):
docker exec -it staging-api python scripts/seed_test_voice_clones.py
# Locally (requires DATABASE_URL env):
DATABASE_URL=postgresql+psycopg://... python scripts/seed_test_voice_clones.py
Creates test VoiceCloneProfile records for known staging test accounts,
so that the frontend E2E tests can verify clone voice list and preview.
"""
from __future__ import annotations
import logging
import os
import sys
from datetime import datetime, timezone
from uuid import uuid4
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
# Test accounts and their seed data
SEED_DATA = [
{
"email": "18314979086@163.com",
"clones": [
{
"name": "测试音色-温柔女声",
"description": "用于E2E测试的克隆音色(温柔女声)",
"voice_id": "longxiaochun_v3",
"gender": "female",
"language": "zh-CN",
},
{
"name": "测试音色-磁性男声",
"description": "用于E2E测试的克隆音色(磁性男声)",
"voice_id": "longsanshu_v3",
"gender": "male",
"language": "zh-CN",
},
],
},
]
def get_db_session():
"""Create a SQLAlchemy session from environment."""
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
database_url = os.environ.get("DATABASE_URL", "")
if not database_url:
# Try loading from shared config
try:
from packages.shared.config import get_shared_settings
settings = get_shared_settings()
database_url = settings.effective_database_url
except Exception:
logger.error("DATABASE_URL not set and could not load from config")
sys.exit(1)
engine = create_engine(database_url)
Session = sessionmaker(bind=engine)
return Session()
except ImportError:
logger.error("sqlalchemy not installed")
sys.exit(1)
def find_user(session, email: str) -> dict | None:
"""Find user by email."""
from sqlalchemy import text
result = session.execute(text("SELECT id, email, display_name FROM users WHERE email = :email"), {"email": email})
row = result.fetchone()
if row:
return {"id": row[0], "email": row[1], "display_name": row[2]}
return None
def clone_exists(session, user_id: str, name: str) -> bool:
"""Check if a clone profile with this name already exists for the user."""
from sqlalchemy import text
result = session.execute(
text("SELECT COUNT(*) FROM voice_clone_profiles WHERE user_id = :uid AND name = :name AND status != 'deleted'"),
{"uid": user_id, "name": name},
)
return result.scalar() > 0
def create_clone_profile(session, user_id: str, clone_data: dict) -> str:
"""Create a VoiceCloneProfile record."""
from sqlalchemy import text
profile_id = uuid4().hex
now = datetime.now(timezone.utc)
session.execute(
text("""
INSERT INTO voice_clone_profiles
(id, user_id, name, description, source_audio_url, voice_id,
voice_model, language, gender, status, error_message,
retry_count, max_retries, metadata, created_at, updated_at)
VALUES
(:id, :uid, :name, :desc, :audio_url, :voice_id,
:voice_model, :lang, :gender, :status, '',
0, 3, '{}', :now, :now)
"""),
{
"id": profile_id,
"uid": user_id,
"name": clone_data["name"],
"desc": clone_data.get("description", ""),
"audio_url": "",
"voice_id": clone_data["voice_id"],
"voice_model": "cosyvoice-v3-flash",
"lang": clone_data.get("language", "zh-CN"),
"gender": clone_data.get("gender", "unknown"),
"status": "ready",
"now": now,
},
)
return profile_id
def main():
logger.info("=== Seed Test Voice Clones ===")
session = get_db_session()
total_created = 0
total_skipped = 0
for account in SEED_DATA:
email = account["email"]
user = find_user(session, email)
if not user:
logger.warning("User not found: %s — skipping", email)
continue
logger.info("Found user: %s (id=%s, name=%s)", email, user["id"], user["display_name"])
for clone_data in account.get("clones", []):
name = clone_data["name"]
if clone_exists(session, user["id"], name):
logger.info(" Clone '%s' already exists — skipping", name)
total_skipped += 1
continue
try:
profile_id = create_clone_profile(session, user["id"], clone_data)
logger.info(" Created clone '%s' (id=%s, voice_id=%s)", name, profile_id, clone_data["voice_id"])
total_created += 1
except Exception as e:
logger.error(" Failed to create clone '%s': %s", name, e)
session.rollback()
continue
try:
session.commit()
logger.info("=== Done: %d created, %d skipped ===", total_created, total_skipped)
except Exception as e:
logger.error("Failed to commit: %s", e)
session.rollback()
sys.exit(1)
finally:
session.close()
if __name__ == "__main__":
main()
+7 -7
View File
@@ -147,20 +147,20 @@ class TestPresetVoicesList:
v = next(v for v in PRESET_VOICES if v.voice_id == "longxiaochun_v3")
assert v.name == "龙小淳"
assert v.gender == "female"
assert "温柔" in v.description
assert "知性" in v.description
def test_longxiaochen_voice(self):
v = next(v for v in PRESET_VOICES if v.voice_id == "longxiaochen_v3")
assert v.name == "小晨"
def test_longsanshu_voice(self):
v = next(v for v in PRESET_VOICES if v.voice_id == "longsanshu_v3")
assert v.name == "三叔"
assert v.gender == "male"
def test_male_voices_count(self):
males = [v for v in PRESET_VOICES if v.gender == "male"]
assert len(males) == 3 # 龙小晨/龙书/龙
assert len(males) == 3 # 龙三叔/龙书/龙
def test_female_voices_count(self):
females = [v for v in PRESET_VOICES if v.gender == "female"]
assert len(females) == 5 # 龙小淳/龙小夏/龙悦/龙静/龙甜
assert len(females) == 5 # 龙小淳/龙小夏/龙悦/龙静/龙甜
def test_all_have_tags(self):
for v in PRESET_VOICES:
@@ -200,7 +200,7 @@ class TestGetPresetVoiceById:
assert v.name == "龙小淳"
def test_get_existing_male(self):
v = get_preset_voice_by_id("longxiaochen_v3")
v = get_preset_voice_by_id("longsanshu_v3")
assert v is not None
assert v.gender == "male"
Regular → Executable
+4 -4
View File
@@ -129,11 +129,11 @@ class TestPresetVoicesConfig:
expected_ids = {
"longxiaochun_v3",
"longxiaoxia_v3",
"longxiaochen_v3",
"longsanshu_v3",
"longyue_v3",
"longshu_v3",
"longjing_v3",
"longbo_v3",
"longyingjing_v3",
"longshuo_v3",
"longtian_v3",
}
actual_ids = {v.voice_id for v in PRESET_VOICES}
@@ -178,7 +178,7 @@ class TestPresetVoiceHelpers:
"""判断预置音色返回 True。"""
assert is_preset_voice("longxiaochun_v3") is True
assert is_preset_voice("longxiaoxia_v3") is True
assert is_preset_voice("longbo_v3") is True
assert is_preset_voice("longshuo_v3") is True
def test_is_preset_voice_false(self) -> None:
"""判断非预置音色返回 False。"""
+4 -4
View File
@@ -132,7 +132,7 @@ class TestIsPresetVoice:
"""判断是否预置音色测试"""
def test_existing_is_preset(self):
assert is_preset_voice("longxiaochen_v3") is True
assert is_preset_voice("longsanshu_v3") is True
def test_nonexistent_not_preset(self):
assert is_preset_voice("custom_voice_123") is False
@@ -149,11 +149,11 @@ class TestPresetVoiceSamples:
[
("longxiaochun_v3", "龙小淳", "female"),
("longxiaoxia_v3", "龙小夏", "female"),
("longxiaochen_v3", "小晨", "male"),
("longsanshu_v3", "三叔", "male"),
("longyue_v3", "龙悦", "female"),
("longshu_v3", "龙书", "male"),
("longjing_v3", "龙静", "female"),
("longbo_v3", "", "male"),
("longyingjing_v3", "", "female"),
("longshuo_v3", "", "male"),
("longtian_v3", "龙甜", "female"),
],
)