Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c8d1e676c | ||
|
|
37242cdf82 | ||
|
|
d674c4d460 | ||
|
|
d0fc1f8cff | ||
|
|
c93b83e2cf | ||
|
|
0a51190482 | ||
|
|
7778522c96 | ||
|
|
2877ae996d | ||
|
|
dbafd4fb73 | ||
|
|
2208a1a7d4 | ||
|
|
e00b0218a0 | ||
|
|
bcb0fbb384 | ||
|
|
0dced68876 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,14 +4,18 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import aiohttp
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
# 导入后端服务
|
# 导入后端服务
|
||||||
from server import app as api_app
|
from server import app as api_app
|
||||||
|
|
||||||
|
# 确保音频缓存目录存在
|
||||||
|
os.makedirs("audio_cache", exist_ok=True)
|
||||||
|
|
||||||
# 主应用
|
# 主应用
|
||||||
app = FastAPI(title="Voice Chat Web")
|
app = FastAPI(title="Voice Chat Web")
|
||||||
|
|
||||||
@@ -24,6 +28,35 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# 挂载音频文件目录(Edge TTS 生成的 MP3)
|
||||||
|
app.mount("/audio", StaticFiles(directory="audio_cache"), name="audio")
|
||||||
|
|
||||||
# 挂载 API
|
# 挂载 API
|
||||||
app.mount("/api", api_app)
|
app.mount("/api", api_app)
|
||||||
|
|
||||||
@@ -33,10 +66,16 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index():
|
async def index():
|
||||||
"""主页"""
|
"""主页(原版)"""
|
||||||
return FileResponse("static/index.html")
|
return FileResponse("static/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/tts")
|
||||||
|
async def tts_page():
|
||||||
|
"""TTS版本页面"""
|
||||||
|
return FileResponse("static/tts.html")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
PORT = int(os.getenv("PORT", "19019"))
|
PORT = int(os.getenv("PORT", "19019"))
|
||||||
SSL_KEY = os.getenv("SSL_KEY", "key.pem")
|
SSL_KEY = os.getenv("SSL_KEY", "key.pem")
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
fastapi==0.110.0
|
fastapi==0.110.0
|
||||||
uvicorn==0.27.1
|
uvicorn==0.27.1
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
aiohttp==3.9.3
|
aiohttp==3.9.3
|
||||||
|
edge-tts==6.1.9
|
||||||
|
requests==2.31.0
|
||||||
@@ -11,8 +11,13 @@ from datetime import datetime
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Form
|
from fastapi import FastAPI, UploadFile, File, HTTPException, Form
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# 导入 TTS 服务
|
||||||
|
from tts_service import tts_manager, AUDIO_DIR
|
||||||
|
|
||||||
# 配置
|
# 配置
|
||||||
MODEL_SERVICE_URL = os.getenv("MODEL_SERVICE_URL", "http://localhost:19018")
|
MODEL_SERVICE_URL = os.getenv("MODEL_SERVICE_URL", "http://localhost:19018")
|
||||||
PORT = int(os.getenv("PORT", "19019"))
|
PORT = int(os.getenv("PORT", "19019"))
|
||||||
@@ -130,6 +135,47 @@ async def voice_chat(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@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}")
|
@app.delete("/conversation/{conversation_id}")
|
||||||
async def delete_conversation(conversation_id: str):
|
async def delete_conversation(conversation_id: str):
|
||||||
"""删除对话"""
|
"""删除对话"""
|
||||||
@@ -145,6 +191,98 @@ async def delete_conversation(conversation_id: str):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
+180
-7
@@ -127,6 +127,50 @@
|
|||||||
font-weight: bold;
|
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 {
|
.waveform {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -196,6 +240,40 @@
|
|||||||
line-height: 1.5;
|
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 {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -285,6 +363,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="chat-section" id="chatSection">
|
||||||
<div class="hint">开始你的第一次语音对话吧!</div>
|
<div class="hint">开始你的第一次语音对话吧!</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,6 +401,46 @@
|
|||||||
const clearBtn = document.getElementById('clearBtn');
|
const clearBtn = document.getElementById('clearBtn');
|
||||||
const statusDot = document.getElementById('statusDot');
|
const statusDot = document.getElementById('statusDot');
|
||||||
const statusText = document.getElementById('statusText');
|
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() {
|
async function checkStatus() {
|
||||||
@@ -470,6 +595,9 @@
|
|||||||
try {
|
try {
|
||||||
showLoading();
|
showLoading();
|
||||||
|
|
||||||
|
// 计算音频时长
|
||||||
|
const duration = Math.round(recordedBuffers.reduce((acc, buf) => acc + buf.length, 0) / 16000);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('audio', audioBlob, 'recording.wav');
|
formData.append('audio', audioBlob, 'recording.wav');
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
@@ -489,8 +617,8 @@
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
conversationId = data.conversation_id;
|
conversationId = data.conversation_id;
|
||||||
|
|
||||||
// 显示消息
|
// 显示消息(带音频播放)
|
||||||
addMessage('user', '🎵 语音消息');
|
addMessage('user', audioBlob, duration);
|
||||||
addMessage('assistant', data.reply);
|
addMessage('assistant', data.reply);
|
||||||
|
|
||||||
recordStatus.textContent = '点击按钮开始录音';
|
recordStatus.textContent = '点击按钮开始录音';
|
||||||
@@ -503,7 +631,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加消息
|
// 添加消息
|
||||||
function addMessage(role, content) {
|
function addMessage(role, content, audioDuration = null) {
|
||||||
// 移除提示
|
// 移除提示
|
||||||
const hint = chatSection.querySelector('.hint');
|
const hint = chatSection.querySelector('.hint');
|
||||||
if (hint) hint.remove();
|
if (hint) hint.remove();
|
||||||
@@ -514,16 +642,50 @@
|
|||||||
|
|
||||||
const msg = document.createElement('div');
|
const msg = document.createElement('div');
|
||||||
msg.className = `message ${role}`;
|
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.appendChild(msg);
|
||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
chatSection.scrollTop = chatSection.scrollHeight;
|
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() {
|
function showLoading() {
|
||||||
const hint = chatSection.querySelector('.hint');
|
const hint = chatSection.querySelector('.hint');
|
||||||
@@ -579,6 +741,17 @@
|
|||||||
|
|
||||||
clearBtn.addEventListener('click', clearChat);
|
clearBtn.addEventListener('click', clearChat);
|
||||||
|
|
||||||
|
// 文字输入事件
|
||||||
|
sendTextBtn.addEventListener('click', () => {
|
||||||
|
sendText(textInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
textInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
sendText(textInput.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
checkStatus();
|
checkStatus();
|
||||||
setInterval(checkStatus, 10000); // 每10秒检查状态
|
setInterval(checkStatus, 10000); // 每10秒检查状态
|
||||||
|
|||||||
+1045
File diff suppressed because it is too large
Load Diff
+254
@@ -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()
|
||||||
Reference in New Issue
Block a user