fix: 修复预置音色voice_id并填充preview_url #1216
@@ -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=["活泼", "女声", "短视频"],
|
||||
),
|
||||
]
|
||||
|
||||
Executable
+180
@@ -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()
|
||||
@@ -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
@@ -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。"""
|
||||
|
||||
Regular → Executable
+4
-4
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user