3 Commits
7 changed files with 110 additions and 17 deletions
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+28 -1
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)
+29 -1
View File
@@ -12,7 +12,7 @@ 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
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel
# 导入 TTS 服务
@@ -255,6 +255,34 @@ async def synthesize_tts(text: str = Form(...), provider: Optional[str] = Form(N
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__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=PORT)
+45 -11
View File
@@ -545,6 +545,25 @@
let currentVoice = 'zh-CN-XiaoxiaoNeural';
let autoPlay = true; // 自动播放开关
let volumeLevel = 1.5; // 音量倍率
let audioUnlocked = false; // 音频播放是否解锁
// 解锁音频播放(浏览器需要用户交互后才能自动播放)
function unlockAudio() {
if (!audioUnlocked) {
// 创建一个静音音频来解锁
const silentAudio = new Audio();
silentAudio.play().then(() => {
audioUnlocked = true;
console.log('音频播放已解锁');
}).catch(e => {
console.log('音频解锁失败:', e);
});
}
}
// 页面点击解锁音频
document.addEventListener('click', unlockAudio, { once: true });
document.addEventListener('touchstart', unlockAudio, { once: true });
// 元素
const statusDot = document.getElementById('statusDot');
@@ -879,6 +898,8 @@
const msg = document.createElement('div');
msg.className = `message ${role}`;
let audioBtnId = ''; // 在函数顶部声明
if (role === 'user' && content instanceof Blob) {
const audioUrl = URL.createObjectURL(content);
msg.innerHTML = `
@@ -892,7 +913,6 @@
`;
} else if (role === 'assistant') {
let audioHtml = '';
let audioBtnId = '';
if (audioData) {
audioBtnId = `audioBtn_${Date.now()}`;
audioHtml = `<button class="play-btn tts-play-btn" id="${audioBtnId}" onclick="playAudio('${audioData}', this)">
@@ -904,9 +924,15 @@
<div class="role">AI</div>
<div class="content">${content}${audioHtml}</div>
`;
} else {
msg.innerHTML = `<div class="role">我</div><div class="content">${content}</div>`;
}
// 自动播放(如果开启)
if (audioData && autoPlay) {
chatSection.appendChild(msg);
chatSection.scrollTop = chatSection.scrollHeight;
// 自动播放(在元素添加到DOM后)
if (role === 'assistant' && audioData && autoPlay && audioBtnId) {
setTimeout(() => {
const btn = document.getElementById(audioBtnId);
if (btn) {
@@ -914,12 +940,6 @@
}
}, 100);
}
} else {
msg.innerHTML = `<div class="role">我</div><div class="content">${content}</div>`;
}
chatSection.appendChild(msg);
chatSection.scrollTop = chatSection.scrollHeight;
}
// 播放音频
@@ -936,11 +956,25 @@
};
audio.onended = () => {
icon.textContent = url.startsWith('/audio') || url.startsWith('http') ? '🔊' : '▶️';
icon.textContent = url.startsWith('/audio') || url.startsWith('http') || url.startsWith('blob:') ? '🔊' : '▶️';
btn.classList.remove('playing');
};
audio.play();
audio.onerror = (e) => {
console.error('音频播放失败:', url, e);
icon.textContent = '❌';
btn.classList.remove('playing');
};
// 添加播放失败的catch
audio.play().catch(e => {
console.error('播放被阻止:', e);
icon.textContent = '🔇';
// 提示用户点击播放
if (e.name === 'NotAllowedError') {
showError('请先点击页面解锁音频播放');
}
});
}
// 显示加载
+6 -2
View File
@@ -134,8 +134,12 @@ class ChatTTSProvider(TTSProvider):
raise Exception(f"ChatTTS error: {error}")
data = await resp.json()
# ChatTTS 返回的 URL 是相对路径,需要拼接
audio_url = f"{self.CHATTTS_URL}{data['audio_url']}"
# 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: