13 Commits
15 changed files with 1825 additions and 35 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFCTCCAvGgAwIBAgIUBWu1dbsZGPwTcg/pzECc8otDEr4wDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQyMTEwMjgyMVoXDTI3MDQy
MTEwMjgyMVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF
AAOCAg8AMIICCgKCAgEA79zUZ4lGsVwv/1bJHq6xkKeszWDrC4qeHuiNOLX/7MCK
zk/GEcbRbTp1TYZg0+g+ixEmpaXa3jxhaYCVwMpinLfgpfL6FNmNPtxocXdNYm7K
s0+czmiaaBiutNluXC0az8QYt/BR00FwHOFuj3wX0olrUMWLhGELtRO921+9NF1W
GDYpnOo1smOHyIXuF/XboRQt2BlWEg6NKgXWUjqSDfzBan/aESlSFg0pLHzsYC2O
61hRn47LhWKhZ7tdSyLrSEVhnlXApjVDOsd7ZHUbY7/r3/tJ+DJXUTIAarpUnupO
SOZh1NtQPpg9wa2KPeWlF1yNEDkvLlER3kqB/nOqGhxh7u5VGXR9R9ZoFUsHsuLP
ru5d+UCWakBROSKc0K0vidGiZKqaiIfyTgpnvov+7nyL8y6QatQJ8bqCrPF91otn
WjWfr5Xr+iNyWnF/SP9Hoem693+wwL+7StmyfcS/1wChiqNFgceWbMK+0dGiqE9O
zoXQUuTmR3VCZ1pJaSZPqa4icmoYlAO99leqNE/SYvUk3LGs2pelDVIcfPSXgd1R
sbqt66EKMwwE2faQcMeNqNsFQXJ0bnJGKH7nZKevn/pkdG/F9G/KtyIbglqU7hM0
7vIVKBbuu23fUfAFjvoTForjD/MGYoZguLbzccfGi9Dmd/Ge4es3ej25wnlrPO0C
AwEAAaNTMFEwHQYDVR0OBBYEFI861NylCN4wI9WNQD3+5U4YGAmyMB8GA1UdIwQY
MBaAFI861NylCN4wI9WNQD3+5U4YGAmyMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggIBADMWyaukkWDtyukXsRoaD3xikLSEUPaCU9QdQ1S9WRqOtAI4
oqgngSEIYgyRyfkOMTY8LoQgjNXW+je0poVE4yPrdW6CA0VZrr4uWY/HvFn9+JL3
5GhNiH8JjeJBnsVw3DA9fG+B0BhYmRxQqei9HDU8QSE0J9eaQUrNftXRoFOOQg3k
8rXj6BTZaIitsw/YHNSdnvDECqAxPam0BwqQXx/U0IadZ3AZvdJBf0uad0yAFkFU
7fJStheEEbjva14P4Tuthoh53uSyiTsZm1OBgJkauaXNhmjKijb9J+AfYYEV1lHL
R1TPm3p3KsZSjYLH8tkjO8ns+o81AmMGMzIrpM0qrlO4uSPjtz80a/eFe6LPIIgr
FKs0ZOWhuP7eA/o/TxPqQXoTHFDuAhxg37NfeveAtEGDW1yAcadkLyswDfCm/XUV
JJXbyySOaCw6sVOmbbl5LzVt+EJryM9YwCUQ15sBFwg6DXxxfDdNtLObjgaI+iL2
5Zqs6DmXYt/YMhChIPDIZN047pIbxRLJjLwmcmynLlQLlwsbL3ljqN3aB6zp66sT
mBKdlHnSHZW+ExRR1eG3wW3i8GzfIt6t8Rd/YfV90edoQtsqEa5G62rkh+C6hDvm
HREqe6efjDZd0yppNtYjGrWH1LG9Caw1NgBH4XvO//rHN12GkxCiLRp24URU
-----END CERTIFICATE-----
+52
View File
@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
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
pvgmLr3zMMOd7hLpj+sXBsd3L6YLOw==
-----END PRIVATE KEY-----
BIN
View File
Binary file not shown.
+43 -3
View File
@@ -4,9 +4,10 @@
import os
import uvicorn
import aiohttp
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
# 导入后端服务
@@ -24,6 +25,32 @@ app.add_middleware(
allow_headers=["*"],
)
# ChatTTS 音频代理(解决 HTTPS 页面访问 HTTP 资源问题)
@app.get("/chattts/audio/{filename}")
async def proxy_chattts_audio(filename: str):
"""代理 ChatTTS 音频文件"""
chattts_url = os.getenv("CHATTTS_URL", "http://192.168.2.5:12002")
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{chattts_url}/audio/{filename}",
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status != 200:
return Response(content=b'{"detail":"Audio not found"}', status_code=404, media_type="application/json")
audio_data = await resp.read()
return Response(
content=audio_data,
media_type="audio/wav",
headers={"Cache-Control": "public, max-age=3600"}
)
except Exception as e:
return Response(content=f'{"detail":"{str(e)}"}'.encode(), status_code=500, media_type="application/json")
# 挂载 API
app.mount("/api", api_app)
@@ -33,10 +60,23 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
async def index():
"""主页"""
"""主页(原版)"""
return FileResponse("static/index.html")
@app.get("/tts")
async def tts_page():
"""TTS版本页面"""
return FileResponse("static/tts.html")
if __name__ == "__main__":
PORT = int(os.getenv("PORT", "19019"))
uvicorn.run(app, host="0.0.0.0", port=PORT)
SSL_KEY = os.getenv("SSL_KEY", "key.pem")
SSL_CERT = os.getenv("SSL_CERT", "cert.pem")
# 检查是否有 SSL 证书
if os.path.exists(SSL_KEY) and os.path.exists(SSL_CERT):
uvicorn.run(app, host="0.0.0.0", port=PORT, ssl_keyfile=SSL_KEY, ssl_certfile=SSL_CERT)
else:
uvicorn.run(app, host="0.0.0.0", port=PORT)
+3 -1
View File
@@ -1,4 +1,6 @@
fastapi==0.110.0
uvicorn==0.27.1
python-multipart==0.0.9
aiohttp==3.9.3
aiohttp==3.9.3
edge-tts==6.1.9
requests==2.31.0
+138 -5
View File
@@ -12,8 +12,12 @@ import aiohttp
from fastapi import FastAPI, UploadFile, File, HTTPException, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel
# 导入 TTS 服务
from tts_service import tts_manager, AUDIO_DIR
# 配置
MODEL_SERVICE_URL = os.getenv("MODEL_SERVICE_URL", "http://localhost:19018")
PORT = int(os.getenv("PORT", "19019"))
@@ -58,7 +62,7 @@ async def root():
return {"status": "ok", "service": "voice-chat-web"}
@app.get("/api/status", response_model=StatusResponse)
@app.get("/status", response_model=StatusResponse)
async def get_status():
"""检查服务状态"""
try:
@@ -81,7 +85,7 @@ async def get_status():
)
@app.post("/api/voice/chat", response_model=VoiceResponse)
@app.post("/voice/chat", response_model=VoiceResponse)
async def voice_chat(
audio: UploadFile = File(..., description="音频文件"),
conversation_id: Optional[str] = Form(None, description="对话ID")
@@ -131,7 +135,48 @@ async def voice_chat(
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/conversation/{conversation_id}")
@app.post("/voice/text", response_model=VoiceResponse)
async def text_chat(
text: str = Form(..., description="文本消息"),
conversation_id: Optional[str] = Form(None, description="对话ID")
):
"""
文字聊天接口
转发到模型服务
"""
try:
async with aiohttp.ClientSession() as session:
form = aiohttp.FormData()
form.add_field('text', text)
if conversation_id:
form.add_field('conversation_id', conversation_id)
async with session.post(
f"{MODEL_SERVICE_URL}/api/voice/text",
data=form,
timeout=aiohttp.ClientTimeout(total=120)
) as resp:
if resp.status != 200:
error_text = await resp.text()
logger.error(f"Model service error: {error_text}")
raise HTTPException(status_code=resp.status, detail=error_text)
data = await resp.json()
return VoiceResponse(
reply=data["reply"],
conversation_id=data["conversation_id"],
timestamp=data.get("timestamp", datetime.now().isoformat())
)
except aiohttp.ClientError as e:
logger.error(f"Connection error: {e}")
raise HTTPException(status_code=503, detail="模型服务连接失败")
except Exception as e:
logger.error(f"Text chat error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/conversation/{conversation_id}")
async def delete_conversation(conversation_id: str):
"""删除对话"""
try:
@@ -146,8 +191,96 @@ async def delete_conversation(conversation_id: str):
raise HTTPException(status_code=500, detail=str(e))
# 静态文件(前端页面)
app.mount("/static", StaticFiles(directory="static"), name="static")
# ========== TTS 相关接口 ==========
class TTSSettings(BaseModel):
"""TTS 设置"""
provider: str = "none"
voice: Optional[str] = None
class TTSResponse(BaseModel):
"""TTS 响应"""
audio_url: Optional[str]
provider: str
@app.get("/tts/providers")
async def get_tts_providers():
"""获取可用的 TTS 方案列表"""
providers = tts_manager.list_providers()
voices = tts_manager.get_edge_voices()
return {
"providers": providers,
"voices": voices,
"current": tts_manager.current_provider
}
@app.post("/tts/settings")
async def set_tts_settings(settings: TTSSettings):
"""设置 TTS 方案"""
tts_manager.set_provider(settings.provider)
# 设置音色(仅 Edge TTS
if settings.provider == "edge" and settings.voice:
provider = tts_manager.get_provider("edge")
if hasattr(provider, 'set_voice'):
provider.set_voice(settings.voice)
return {
"provider": settings.provider,
"voice": settings.voice
}
@app.post("/tts/synthesize")
async def synthesize_tts(text: str = Form(...), provider: Optional[str] = Form(None)):
"""
合成语音
返回音频文件 URL
"""
try:
audio_url = await tts_manager.synthesize(text, provider)
return TTSResponse(
audio_url=audio_url,
provider=provider or tts_manager.current_provider
)
except Exception as e:
logger.error(f"TTS synthesis error: {e}")
raise HTTPException(status_code=500, detail=str(e))
# 挂载音频文件目录
app.mount("/audio", StaticFiles(directory=AUDIO_DIR), name="audio")
# ChatTTS 音频代理(解决 HTTPS 页面访问 HTTP 资源问题)
@app.get("/chattts/audio/{filename}")
async def proxy_chattts_audio(filename: str):
"""代理 ChatTTS 音频文件"""
import aiohttp
chattts_url = os.getenv("CHATTTS_URL", "http://192.168.2.5:12002")
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{chattts_url}/audio/{filename}",
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status != 200:
raise HTTPException(status_code=404, detail="Audio not found")
audio_data = await resp.read()
return Response(
content=audio_data,
media_type="audio/wav",
headers={"Cache-Control": "public, max-age=3600"}
)
except Exception as e:
logger.error(f"Proxy audio error: {e}")
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
+261 -26
View File
@@ -127,6 +127,50 @@
font-weight: bold;
}
.text-section {
margin: 20px 0;
}
.text-input-wrapper {
display: flex;
gap: 10px;
}
.text-input {
flex: 1;
padding: 12px 15px;
border: 2px solid #eee;
border-radius: 10px;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.text-input:focus {
border-color: #667eea;
}
.send-text-btn {
padding: 12px 20px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 15px;
cursor: pointer;
transition: all 0.2s;
}
.send-text-btn:hover {
transform: scale(1.05);
}
.send-text-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.waveform {
display: flex;
justify-content: center;
@@ -196,6 +240,40 @@
line-height: 1.5;
}
.audio-content {
display: flex;
align-items: center;
}
.play-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 15px;
border-radius: 20px;
border: none;
background: rgba(255,255,255,0.2);
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
}
.play-btn:hover {
background: rgba(255,255,255,0.3);
}
.play-btn.playing {
background: rgba(255,255,255,0.4);
}
.play-icon {
font-size: 16px;
}
.duration {
color: rgba(255,255,255,0.8);
}
.loading {
display: flex;
justify-content: center;
@@ -285,6 +363,13 @@
</div>
</div>
<div class="text-section">
<div class="text-input-wrapper">
<input type="text" id="textInput" placeholder="输入文字消息..." class="text-input">
<button id="sendTextBtn" class="send-text-btn">发送</button>
</div>
</div>
<div class="chat-section" id="chatSection">
<div class="hint">开始你的第一次语音对话吧!</div>
</div>
@@ -304,6 +389,9 @@
let audioChunks = [];
let conversationId = null;
let audioContext = null;
let audioStream = null;
let scriptProcessor = null;
let recordedBuffers = [];
// 元素
const recordBtn = document.getElementById('recordBtn');
@@ -313,6 +401,46 @@
const clearBtn = document.getElementById('clearBtn');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const textInput = document.getElementById('textInput');
const sendTextBtn = document.getElementById('sendTextBtn');
// 发送文字消息
async function sendText(text) {
if (!text.trim()) return;
try {
showLoading();
const formData = new FormData();
formData.append('text', text);
if (conversationId) {
formData.append('conversation_id', conversationId);
}
const resp = await fetch(`${API_URL}/voice/text`, {
method: 'POST',
body: formData
});
if (!resp.ok) {
const error = await resp.text();
throw new Error(error);
}
const data = await resp.json();
conversationId = data.conversation_id;
// 显示消息
addMessage('user', text);
addMessage('assistant', data.reply);
textInput.value = '';
} catch (e) {
console.error('发送失败:', e);
showError('发送失败: ' + e.message);
}
}
// 检查服务状态
async function checkStatus() {
@@ -333,10 +461,58 @@
}
}
// 创建 WAV 文件
function createWavFile(audioBuffer, sampleRate = 16000) {
const numChannels = 1;
const bitsPerSample = 16;
const bytesPerSample = bitsPerSample / 8;
const blockAlign = numChannels * bytesPerSample;
const byteRate = sampleRate * blockAlign;
const dataSize = audioBuffer.length * bytesPerSample;
const headerSize = 44;
const totalSize = headerSize + dataSize;
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
// WAV header
writeString(view, 0, 'RIFF');
view.setUint32(4, totalSize - 8, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); // fmt chunk size
view.setUint16(20, 1, true); // audio format (PCM)
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
writeString(view, 36, 'data');
view.setUint32(40, dataSize, true);
// 写入音频数据
floatTo16BitPCM(view, 44, audioBuffer);
return new Blob([buffer], { type: 'audio/wav' });
}
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
function floatTo16BitPCM(view, offset, input) {
for (let i = 0; i < input.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
// 初始化录音
async function initAudio() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audioStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
@@ -344,22 +520,21 @@
}
});
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// 创建 MediaRecorder
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm'
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 16000
});
mediaRecorder.ondataavailable = (e) => {
audioChunks.push(e.data);
const source = audioContext.createMediaStreamSource(audioStream);
scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
scriptProcessor.onaudioprocess = (e) => {
if (isRecording) {
recordedBuffers.push(e.inputBuffer.getChannelData(0).slice());
}
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
audioChunks = [];
await sendAudio(audioBlob);
};
source.connect(scriptProcessor);
scriptProcessor.connect(audioContext.destination);
return true;
} catch (e) {
@@ -371,13 +546,12 @@
// 开始录音
async function startRecording() {
if (!mediaRecorder) {
if (!audioContext) {
const success = await initAudio();
if (!success) return;
}
audioChunks = [];
mediaRecorder.start();
recordedBuffers = [];
isRecording = true;
recordBtn.classList.add('recording');
@@ -390,16 +564,29 @@
// 停止录音
function stopRecording() {
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
if (isRecording) {
isRecording = false;
// 合并所有缓冲区
const totalLength = recordedBuffers.reduce((acc, buf) => acc + buf.length, 0);
const mergedBuffer = new Float32Array(totalLength);
let offset = 0;
for (const buf of recordedBuffers) {
mergedBuffer.set(buf, offset);
offset += buf.length;
}
// 创建 WAV 文件
const wavBlob = createWavFile(mergedBuffer, 16000);
recordBtn.classList.remove('recording');
recordBtn.querySelector('.icon').textContent = '🎤';
recordBtn.querySelector('.text').textContent = '点击录音';
recordStatus.textContent = '处理中...';
recordStatus.classList.remove('recording');
waveform.style.display = 'none';
sendAudio(wavBlob);
}
}
@@ -408,8 +595,11 @@
try {
showLoading();
// 计算音频时长
const duration = Math.round(recordedBuffers.reduce((acc, buf) => acc + buf.length, 0) / 16000);
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
formData.append('audio', audioBlob, 'recording.wav');
if (conversationId) {
formData.append('conversation_id', conversationId);
}
@@ -427,8 +617,8 @@
const data = await resp.json();
conversationId = data.conversation_id;
// 显示消息
addMessage('user', '🎵 语音消息');
// 显示消息(带音频播放)
addMessage('user', audioBlob, duration);
addMessage('assistant', data.reply);
recordStatus.textContent = '点击按钮开始录音';
@@ -441,7 +631,7 @@
}
// 添加消息
function addMessage(role, content) {
function addMessage(role, content, audioDuration = null) {
// 移除提示
const hint = chatSection.querySelector('.hint');
if (hint) hint.remove();
@@ -452,16 +642,50 @@
const msg = document.createElement('div');
msg.className = `message ${role}`;
msg.innerHTML = `
<div class="role">${role === 'user' ? '我' : 'AI'}</div>
<div class="content">${content}</div>
`;
// 用户消息可能是音频
if (role === 'user' && content instanceof Blob) {
const audioUrl = URL.createObjectURL(content);
const durationText = audioDuration ? `${audioDuration}s` : '';
msg.innerHTML = `
<div class="role">我</div>
<div class="content audio-content">
<button class="play-btn" onclick="playAudio('${audioUrl}', this)">
<span class="play-icon">▶️</span>
<span class="duration">${durationText}</span>
</button>
</div>
`;
} else {
msg.innerHTML = `
<div class="role">${role === 'user' ? '我' : 'AI'}</div>
<div class="content">${content}</div>
`;
}
chatSection.appendChild(msg);
// 滚动到底部
chatSection.scrollTop = chatSection.scrollHeight;
}
// 播放音频
function playAudio(audioUrl, btn) {
const audio = new Audio(audioUrl);
const icon = btn.querySelector('.play-icon');
audio.onplay = () => {
icon.textContent = '🔊';
btn.classList.add('playing');
};
audio.onended = () => {
icon.textContent = '▶️';
btn.classList.remove('playing');
};
audio.play();
}
// 显示加载
function showLoading() {
const hint = chatSection.querySelector('.hint');
@@ -517,6 +741,17 @@
clearBtn.addEventListener('click', clearChat);
// 文字输入事件
sendTextBtn.addEventListener('click', () => {
sendText(textInput.value);
});
textInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendText(textInput.value);
}
});
// 初始化
checkStatus();
setInterval(checkStatus, 10000); // 每10秒检查状态
+1045
View File
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
"""
TTS 语音合成模块
支持多种 TTS 方案
"""
import os
import uuid
import logging
import asyncio
from abc import ABC, abstractmethod
from typing import Optional, Tuple
from datetime import datetime
# 配置
AUDIO_DIR = os.getenv("AUDIO_DIR", "audio_cache")
os.makedirs(AUDIO_DIR, exist_ok=True)
logger = logging.getLogger(__name__)
class TTSProvider(ABC):
"""TTS 提供者抽象类"""
@abstractmethod
async def synthesize(self, text: str) -> Tuple[str, str]:
"""
合成语音
返回: (音频文件路径, 音频URL路径)
"""
pass
@abstractmethod
def get_name(self) -> str:
"""获取提供者名称"""
pass
@abstractmethod
def is_available(self) -> bool:
"""检查是否可用"""
pass
class EdgeTTSProvider(TTSProvider):
"""Edge TTS 提供者(微软免费TTS"""
# 可用音色
VOICES = {
"zh-CN-XiaoxiaoNeural": "晓晓(女)",
"zh-CN-YunxiNeural": "云希(男)",
"zh-CN-YunyangNeural": "云扬(男)",
"zh-CN-XiaochenNeural": "晓晨(女)",
"zh-CN-XiaohanNeural": "晓涵(女)",
"zh-CN-XiaomengNeural": "晓梦(女)",
"zh-CN-XiaomoNeural": "晓墨(女)",
"zh-CN-XiaoruiNeural": "晓睿(女)",
"zh-CN-XiaoshuangNeural": "晓双(女)",
"zh-CN-XiaoxuanNeural": "晓萱(女)",
"zh-CN-XiaoyanNeural": "晓颜(女)",
"zh-CN-XiaoyouNeural": "晓悠(女)",
}
DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural"
def __init__(self, voice: Optional[str] = None):
self.voice = voice or self.DEFAULT_VOICE
self._available = None
async def synthesize(self, text: str) -> Tuple[str, str]:
"""使用 Edge TTS 合成语音"""
import edge_tts
# 生成唯一文件名
filename = f"{uuid.uuid4().hex}.mp3"
filepath = os.path.join(AUDIO_DIR, filename)
# 合成语音
communicate = edge_tts.Communicate(text, self.voice)
await communicate.save(filepath)
# 返回路径
audio_url = f"/audio/{filename}"
return filepath, audio_url
def get_name(self) -> str:
return "Edge TTS"
def get_voice_name(self) -> str:
"""获取当前音色名称"""
return self.VOICES.get(self.voice, self.voice)
def is_available(self) -> bool:
"""检查 Edge TTS 是否可用"""
if self._available is None:
try:
import edge_tts
self._available = True
except ImportError:
logger.warning("edge-tts not installed")
self._available = False
return self._available
def set_voice(self, voice: str):
"""设置音色"""
if voice in self.VOICES:
self.voice = voice
else:
logger.warning(f"Unknown voice: {voice}, using default")
class ChatTTSProvider(TTSProvider):
"""ChatTTS 提供者(本地部署)"""
# ChatTTS 服务地址
CHATTTS_URL = os.getenv("CHATTTS_URL", "http://192.168.2.5:12002")
def __init__(self):
self._available = None
async def synthesize(self, text: str) -> Tuple[str, str]:
"""使用 ChatTTS 合成语音"""
import aiohttp
async with aiohttp.ClientSession() as session:
form = aiohttp.FormData()
form.add_field('text', text)
async with session.post(
f"{self.CHATTTS_URL}/synthesize",
data=form,
timeout=aiohttp.ClientTimeout(total=60)
) as resp:
if resp.status != 200:
error = await resp.text()
raise Exception(f"ChatTTS error: {error}")
data = await resp.json()
# ChatTTS 返回的 URL 是 /audio/xxx.wav
# 改用本地代理路径(解决 HTTPS 页面访问 HTTP 问题)
original_url = data['audio_url']
# /audio/xxx.wav -> /chattts/audio/xxx.wav (通过本地代理)
filename = original_url.split('/')[-1]
audio_url = f"/chattts/audio/{filename}"
return None, audio_url
def get_name(self) -> str:
return "ChatTTS"
def is_available(self) -> bool:
"""检查 ChatTTS 是否可用"""
if self._available is None:
try:
import requests
resp = requests.get(f"{self.CHATTTS_URL}/health", timeout=5)
if resp.status_code == 200:
data = resp.json()
self._available = data.get("status") == "ok"
else:
self._available = False
except Exception as e:
logger.warning(f"ChatTTS check failed: {e}")
self._available = False
return self._available
def set_url(self, url: str):
"""设置服务地址"""
self.CHATTTS_URL = url
self._available = None # 重新检测
class NoTTSProvider(TTSProvider):
"""不使用 TTS"""
async def synthesize(self, text: str) -> Tuple[str, str]:
return None, None
def get_name(self) -> str:
return "无 TTS"
def is_available(self) -> bool:
return True
# TTS 管理器
class TTSManager:
"""TTS 方案管理"""
PROVIDERS = {
"edge": EdgeTTSProvider,
"chattts": ChatTTSProvider,
"none": NoTTSProvider,
}
def __init__(self, default_provider: str = "none"):
self.current_provider = default_provider
self._providers = {}
# 初始化 Edge TTS(如果可用)
edge_provider = EdgeTTSProvider()
if edge_provider.is_available():
self._providers["edge"] = edge_provider
# 初始化 ChatTTS(预留)
self._providers["chattts"] = ChatTTSProvider()
# 无 TTS
self._providers["none"] = NoTTSProvider()
def get_provider(self, provider_name: Optional[str] = None) -> TTSProvider:
"""获取 TTS 提供者"""
name = provider_name or self.current_provider
return self._providers.get(name, self._providers["none"])
def set_provider(self, provider_name: str):
"""设置当前 TTS 方案"""
if provider_name in self._providers:
self.current_provider = provider_name
else:
logger.warning(f"Unknown provider: {provider_name}")
def list_providers(self) -> list:
"""列出所有可用方案"""
return [
{
"name": name,
"display_name": provider.get_name(),
"available": provider.is_available()
}
for name, provider in self._providers.items()
]
def get_edge_voices(self) -> dict:
"""获取 Edge TTS 可用音色"""
return EdgeTTSProvider.VOICES
async def synthesize(self, text: str, provider_name: Optional[str] = None) -> Optional[str]:
"""
合成语音
返回音频URL
"""
provider = self.get_provider(provider_name)
if not provider.is_available():
logger.warning(f"Provider {provider.get_name()} not available")
return None
try:
_, audio_url = await provider.synthesize(text)
return audio_url
except Exception as e:
logger.error(f"TTS synthesis failed: {e}")
return None
# 全局 TTS 管理器
tts_manager = TTSManager()