Files

257 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Edge TTS 服务 v2.0 - 免费开源中文语音合成,支持背景音乐混音
"""
import asyncio
import tempfile
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
import edge_tts
# pydub 混音支持(必须在 import pydub 之前将 ffmpeg 注入 PATH)
_FFMPEG_BIN = r"C:\Users\maxf\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-8.1-full_build\bin"
if os.path.isdir(_FFMPEG_BIN) and _FFMPEG_BIN not in os.environ.get("PATH", ""):
os.environ["PATH"] = _FFMPEG_BIN + os.pathsep + os.environ.get("PATH", "")
try:
from pydub import AudioSegment
PYDUB_AVAILABLE = True
except ImportError:
PYDUB_AVAILABLE = False
app = FastAPI(title="Edge TTS Server", version="2.0")
# BGM 目录
BGM_DIR = Path(__file__).parent / "bgm"
BGM_DIR.mkdir(exist_ok=True)
# 中文常用语音
VOICES = {
"xiaoxiao": "zh-CN-XiaoxiaoNeural", # 晓晓 女声 温柔
"xiaoyi": "zh-CN-XiaoyiNeural", # 小艺 女声 活泼
"yunjian": "zh-CN-YunjianNeural", # 云健 男声 新闻
"yunxi": "zh-CN-YunxiNeural", # 云希 男声 年轻
"xiaochen": "zh-CN-XiaochenNeural", # 晓晨 女声 成熟
}
DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural"
class TTSRequest(BaseModel):
text: str
voice: str = "xiaoxiao" # 语音名称
rate: str = "+0%" # 语速,如 "+10%" 或 "-10%"
volume: str = "+0%" # 音量
bgm: str = "" # 背景音乐文件名(bgm/ 目录下),留空则不混音
bgm_volume: float = 0.3 # 背景音乐音量比例(0.0~1.0,默认 0.3)
@app.get("/")
def root():
return {
"message": "Edge TTS Server v2.0",
"voices": list(VOICES.keys()),
"bgm_support": PYDUB_AVAILABLE,
"usage": "POST /tts with {text, voice?, rate?, volume?, bgm?, bgm_volume?}"
}
@app.get("/voices")
def list_voices():
"""列出可用语音"""
return {
"voices": {
k: {"id": v, "description": get_voice_desc(k)}
for k, v in VOICES.items()
}
}
@app.get("/bgm")
def list_bgm():
"""列出可用背景音乐"""
if not BGM_DIR.exists():
return {"bgm": []}
files = [f.name for f in BGM_DIR.iterdir()
if f.suffix.lower() in (".mp3", ".wav", ".ogg", ".flac")]
return {"bgm": files, "directory": str(BGM_DIR)}
def get_voice_desc(key: str) -> str:
desc = {
"xiaoxiao": "晓晓-女声-温柔自然",
"xiaoyi": "小艺-女声-活泼轻快",
"yunjian": "云健-男声-新闻播报",
"yunxi": "云希-男声-年轻活力",
"xiaochen": "晓晨-女声-成熟稳重",
}
return desc.get(key, "未知")
def mix_with_bgm(tts_path: str, bgm_name: str, bgm_volume: float) -> str:
"""将 TTS 音频与背景音乐混音,返回混音后的临时文件路径"""
bgm_path = BGM_DIR / bgm_name
if not bgm_path.exists():
raise FileNotFoundError(f"背景音乐文件不存在: {bgm_name}")
# 加载 TTS 音频
tts_audio = AudioSegment.from_mp3(tts_path)
tts_duration = len(tts_audio)
# 加载 BGM 并调整音量
bgm_audio = AudioSegment.from_file(str(bgm_path))
# bgm_volume: 0.0~1.0 → 转换为 dB 调整(0.3 ≈ -10dB)
volume_db = 20 * (bgm_volume ** 0.5 - 1) * 5 if bgm_volume > 0 else -60
bgm_audio = bgm_audio + volume_db
# 若 BGM 比 TTS 短则循环,截取与 TTS 等长
if len(bgm_audio) < tts_duration:
loops = (tts_duration // len(bgm_audio)) + 1
bgm_audio = bgm_audio * loops
bgm_audio = bgm_audio[:tts_duration]
# 淡入淡出(前后各 2 秒,使过渡自然)
fade_ms = min(2000, tts_duration // 4)
bgm_audio = bgm_audio.fade_in(fade_ms).fade_out(fade_ms)
# 混音叠加
mixed = tts_audio.overlay(bgm_audio)
# 输出到临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as out:
out_path = out.name
mixed.export(out_path, format="mp3", bitrate="128k")
return out_path
async def generate_tts_with_retry(text: str, voice_id: str, rate: str, volume: str, max_retries: int = 3):
"""带重试机制的 TTS 生成函数"""
last_error = None
tmp_path = None
for attempt in range(max_retries):
try:
communicate = edge_tts.Communicate(
text=text,
voice=voice_id,
rate=rate,
volume=volume,
)
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as tmp:
tmp_path = tmp.name
await communicate.save(tmp_path)
return tmp_path
except Exception as e:
last_error = e
error_msg = str(e)
if "403" in error_msg or "Invalid response status" in error_msg:
if attempt < max_retries - 1:
wait_time = 2 ** attempt
print(f"第 {attempt + 1} 次尝试失败,{wait_time}秒后重试...")
await asyncio.sleep(wait_time)
continue
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
raise last_error
raise last_error
@app.post("/tts")
async def text_to_speech(req: TTSRequest):
"""文字转语音,返回 MP3 文件(可选背景音乐混音)"""
if not req.text or len(req.text) > 5000:
raise HTTPException(status_code=400, detail="文本不能为空或超过5000字")
voice_id = VOICES.get(req.voice, DEFAULT_VOICE)
tmp_path = None
mixed_path = None
try:
tmp_path = await generate_tts_with_retry(
text=req.text,
voice_id=voice_id,
rate=req.rate,
volume=req.volume,
max_retries=3
)
# 如果指定了 BGM 且 pydub 可用,进行混音
if req.bgm and PYDUB_AVAILABLE:
try:
mixed_path = mix_with_bgm(tmp_path, req.bgm, req.bgm_volume)
os.unlink(tmp_path) # 删除纯 TTS 临时文件
output_path = mixed_path
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
# 混音失败时回退到纯 TTS
print(f"混音失败,回退到纯TTS: {e}")
output_path = tmp_path
elif req.bgm and not PYDUB_AVAILABLE:
raise HTTPException(status_code=503, detail="pydub 未安装,无法进行混音。请运行: pip install pydub")
else:
output_path = tmp_path
return FileResponse(
output_path,
media_type="audio/mpeg",
filename="tts_output.mp3",
background=None
)
except HTTPException:
raise
except Exception as e:
error_msg = str(e)
if "403" in error_msg or "Invalid response status" in error_msg:
raise HTTPException(
status_code=503,
detail="TTS 服务暂时不可用(微软服务返回 403),请稍后重试。如问题持续,请更新:pip install --upgrade edge-tts"
)
raise HTTPException(status_code=500, detail=f"TTS失败: {error_msg}")
@app.post("/tts/stream")
async def text_to_speech_stream(req: TTSRequest):
"""流式返回音频(边生成边播放,不支持 BGM 混音)"""
if not req.text or len(req.text) > 5000:
raise HTTPException(status_code=400, detail="文本不能为空或超过5000字")
voice_id = VOICES.get(req.voice, DEFAULT_VOICE)
async def audio_stream():
communicate = edge_tts.Communicate(
text=req.text,
voice=voice_id,
rate=req.rate,
volume=req.volume,
)
async for chunk in communicate.stream():
if chunk["type"] == "audio":
yield chunk["data"]
return StreamingResponse(
audio_stream(),
media_type="audio/mpeg",
)
@app.get("/tts/get")
async def tts_get(text: str, voice: str = "xiaoxiao", rate: str = "+0%", volume: str = "+0%"):
"""GET 方式调用(方便测试)"""
req = TTSRequest(text=text, voice=voice, rate=rate, volume=volume)
return await text_to_speech(req)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)