fix: update TTS voice mapping to only use valid Edge TTS voices

Previously the voice map referenced 10+ non-existent Edge TTS voice names
(xiaohan, xiaochen, xiaoshuang, etc.), causing NoAudioReceived errors and
fallback to browser default TTS. Now only maps to the 6 actually available
Chinese voices (xiaoxiao, xiaoyi, yunxi, yunyang, yunjian, yunxia).
Added voice speed adjustments and fixed --rate argument format for Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 17:06:51 +08:00
parent fbf8dbbe86
commit 3483c6b3be
7 changed files with 307 additions and 138 deletions

View File

@@ -28,14 +28,50 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/voice", tags=["voice"])
# Edge TTS 中文语音映射 (OpenAI voice name -> Edge TTS Chinese voice)
# Edge TTS 中文语音映射 (voice name -> Edge TTS Chinese voice)
# 注意edge-tts 免费版仅支持以下中文语音:
# 女声xiaoxiao, xiaoyi
# 男声yunxi, yunyang, yunjian, yunxia
# 方言liaoning-Xiaobei, shaanxi-Xiaoni
_EDGE_VOICE_MAP = {
"alloy": "zh-CN-YunxiNeural",
"echo": "zh-CN-YunyangNeural",
"fable": "zh-CN-XiaoxiaoNeural",
"onyx": "zh-CN-YunjianNeural",
"nova": "zh-CN-XiaoyiNeural",
"shimmer": "zh-CN-XiaoxiaoNeural",
# ── 女声 ──
"xiaoxiao": "zh-CN-XiaoxiaoNeural", # 晓晓 · 温柔
"xiaoyi": "zh-CN-XiaoyiNeural", # 晓怡 · 活泼
# ── 男声 ──
"yunxi": "zh-CN-YunxiNeural", # 云希 · 阳光
"yunyang": "zh-CN-YunyangNeural", # 云扬 · 专业
"yunjian": "zh-CN-YunjianNeural", # 云健 · 激情
"yunxia": "zh-CN-YunxiaNeural", # 云夏 · 可爱
# ── 女声变体(使用语速微调区分)──
"xiaochen": "zh-CN-XiaoxiaoNeural", # 晓辰 · 温和xiaoxiao 慢速)
"xiaohan": "zh-CN-XiaoyiNeural", # 晓涵 · 知性xiaoyi 慢速)
# ── 教师女声(映射到可用女声)──
"teacher1": "zh-CN-XiaoxiaoNeural", # 晓晓老师 · 温柔教导
"teacher2": "zh-CN-XiaoyiNeural", # 晓怡老师 · 活泼教学
# ── 兼容旧 OpenAI 风格名称 ──
"alloy": "zh-CN-YunxiNeural",
"echo": "zh-CN-YunyangNeural",
"fable": "zh-CN-XiaoxiaoNeural",
"onyx": "zh-CN-YunjianNeural",
"nova": "zh-CN-XiaoyiNeural",
"shimmer": "zh-CN-XiaoxiaoNeural",
}
# 语音语速微调 (voice -> adjusted speed multiplier)
# 通过微调语速来区分映射到同一 Edge TTS 语音的不同选项
_VOICE_SPEED_ADJUST = {
"xiaochen": 0.90, # 晓辰 · 稍慢更温和
"xiaohan": 0.90, # 晓涵 · 稍慢更知性
"teacher1": 0.95, # 晓晓老师 · 稍慢有教导感
"teacher2": 0.95, # 晓怡老师 · 稍慢有教学感
}
# 新增语音 → OpenAI TTS 降级映射
_OPENAI_FALLBACK_VOICE = {
"xiaoxiao": "nova", "xiaoyi": "nova",
"xiaochen": "nova", "xiaohan": "nova",
"yunxia": "alloy",
"teacher1": "nova", "teacher2": "nova",
}
@@ -49,8 +85,14 @@ class TtsRequest(BaseModel):
"""文字转语音请求"""
text: str = Field(..., min_length=1, max_length=4000, description="要合成的文字")
voice: str = Field(
default="alloy",
description="语音风格:alloy / echo / fable / onyx / nova / shimmer",
default="xiaoxiao",
description="语音风格:xiaoxiao/xiaoyi/xiaochen/xiaohan/xiaomeng/xiaomo/xiaorui/xiaoshuang/xiaoxuan/xiaoyan/xiaozhen(女) yunxi/yunyang/yunjian/yunfeng/yunze/yunhao(男)",
)
speed: float = Field(
default=1.0,
ge=0.5,
le=2.0,
description="语速0.5(慢)到 2.0(快),默认 1.0",
)
@@ -150,8 +192,8 @@ async def text_to_voice(
优先使用 OpenAI TTS需配置有效 OPENAI_API_KEY否则使用免费 Edge TTS。
Android 端使用 ExoPlayer 播放返回的 audio_url。
"""
valid_voices = {"alloy", "echo", "fable", "onyx", "nova", "shimmer"}
voice = req.voice if req.voice in valid_voices else "alloy"
valid_voices = set(_EDGE_VOICE_MAP.keys())
voice = req.voice if req.voice in valid_voices else "xiaoxiao"
text = req.text.strip()
if len(text) > 4000:
@@ -161,6 +203,10 @@ async def text_to_voice(
filename = f"tts_{current_user.id}_{int(time.time())}.mp3"
filepath = _TTS_DIR / filename
# 语速微调:某些语音选项通过微调语速来区分
speed_adj = _VOICE_SPEED_ADJUST.get(voice, 1.0)
adjusted_speed = req.speed * speed_adj
# 尝试 OpenAI TTS
api_key = (getattr(settings, "OPENAI_API_KEY", "") or "").strip()
base_url = (
@@ -172,6 +218,10 @@ async def text_to_voice(
if api_key and api_key not in ("your-openai-api-key", "sk-your-"):
import httpx
# OpenAI TTS 仅支持 alloy/echo/fable/onyx/nova/shimmer其他语音用映射
openai_voices = {"alloy", "echo", "fable", "onyx", "nova", "shimmer"}
openai_voice = voice if voice in openai_voices else _OPENAI_FALLBACK_VOICE.get(voice, "nova")
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
@@ -180,12 +230,17 @@ async def text_to_voice(
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={"model": "tts-1", "voice": voice, "input": text},
json={
"model": "tts-1",
"voice": openai_voice,
"input": text,
"speed": adjusted_speed,
},
)
if resp.status_code == 200:
filepath.write_bytes(resp.content)
logger.info("OpenAI TTS 完成: user=%s text_len=%d voice=%s", current_user.id, len(req.text), voice)
logger.info("OpenAI TTS 完成: user=%s text_len=%d voice=%s speed=%.1f", current_user.id, len(req.text), voice, adjusted_speed)
return TtsResponse(
audio_url=f"/api/v1/voice/audio/{filename}",
text_length=len(req.text),
@@ -202,10 +257,10 @@ async def text_to_voice(
# 回退Edge TTS免费无需 API KEY
if use_edge:
edge_voice = _EDGE_VOICE_MAP.get(voice, "zh-CN-YunxiNeural")
edge_voice = _EDGE_VOICE_MAP.get(voice, "zh-CN-XiaoxiaoNeural")
try:
await _edge_tts_synthesize(text, edge_voice, str(filepath))
logger.info("Edge TTS 完成: user=%s text_len=%d edge_voice=%s", current_user.id, len(req.text), edge_voice)
await _edge_tts_synthesize(text, edge_voice, str(filepath), speed=adjusted_speed)
logger.info("Edge TTS 完成: user=%s text_len=%d edge_voice=%s speed=%.1f", current_user.id, len(req.text), edge_voice, adjusted_speed)
return TtsResponse(
audio_url=f"/api/v1/voice/audio/{filename}",
text_length=len(req.text),
@@ -216,22 +271,29 @@ async def text_to_voice(
raise HTTPException(status_code=502, detail=f"TTS 服务不可用: {exc}")
async def _edge_tts_synthesize(text: str, voice: str, output_path: str) -> None:
async def _edge_tts_synthesize(
text: str, voice: str, output_path: str, speed: float = 1.0,
) -> None:
"""使用 edge-tts 命令行工具合成语音。"""
# 使用子进程方式调用 edge-tts CLI独立进程避开 asyncio 事件循环冲突)
# 查找 edge-tts 可执行文件
import shutil
exe = shutil.which("edge-tts") or shutil.which("edge-tts", path=(
os.environ.get("PATH", "") + os.pathsep +
os.path.join(os.path.dirname(sys.executable), "Scripts") + os.pathsep +
os.path.join(os.path.dirname(sys.executable), "..", "Scripts")
))
if not exe:
exe = "edge-tts" # fallback, let subprocess try PATH
exe = "edge-tts"
logger.debug("edge-tts exe: %s", exe)
rate_str = f"{int((speed - 1.0) * 100):+d}%"
logger.debug("edge-tts exe: %s text_len=%d rate=%s", exe, len(text), rate_str)
proc = await asyncio.create_subprocess_exec(
exe, "--text", text, "--voice", voice, "--write-media", output_path,
exe,
"--text", text,
"--voice", voice,
f"--rate={rate_str}",
"--write-media", output_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -240,7 +302,9 @@ async def _edge_tts_synthesize(text: str, voice: str, output_path: str) -> None:
err_str = stderr.decode(errors="replace") if stderr else ""
if proc.returncode != 0:
raise RuntimeError(f"edge-tts CLI 失败 (exit={proc.returncode}): {err_str or out_str}")
raise RuntimeError(
f"edge-tts CLI 失败 (exit={proc.returncode}): {err_str or out_str}"
)
if not Path(output_path).is_file():
raise RuntimeError(f"edge-tts 未生成输出文件: {out_str} {err_str}")