Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c8d1e676c | ||
|
|
37242cdf82 | ||
|
|
d674c4d460 | ||
|
|
d0fc1f8cff | ||
|
|
c93b83e2cf | ||
|
|
0a51190482 | ||
|
|
7778522c96 | ||
|
|
2877ae996d | ||
|
|
dbafd4fb73 | ||
|
|
2208a1a7d4 |
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 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
|
||||
|
||||
# 导入后端服务
|
||||
from server import app as api_app
|
||||
|
||||
# 确保音频缓存目录存在
|
||||
os.makedirs("audio_cache", exist_ok=True)
|
||||
|
||||
# 主应用
|
||||
app = FastAPI(title="Voice Chat Web")
|
||||
|
||||
@@ -24,6 +28,35 @@ 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")
|
||||
|
||||
|
||||
# 挂载音频文件目录(Edge TTS 生成的 MP3)
|
||||
app.mount("/audio", StaticFiles(directory="audio_cache"), name="audio")
|
||||
|
||||
# 挂载 API
|
||||
app.mount("/api", api_app)
|
||||
|
||||
|
||||
+2
-1
@@ -2,4 +2,5 @@ fastapi==0.110.0
|
||||
uvicorn==0.27.1
|
||||
python-multipart==0.0.9
|
||||
aiohttp==3.9.3
|
||||
edge-tts==6.1.9
|
||||
edge-tts==6.1.9
|
||||
requests==2.31.0
|
||||
@@ -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)
|
||||
+181
-9
@@ -124,6 +124,96 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* TTS 控制选项 */
|
||||
.tts-controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eee;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auto-play-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .3s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #667eea;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.volume-control input[type="range"] {
|
||||
width: 80px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: #ddd;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.volume-control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #667eea;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
min-width: 35px;
|
||||
}
|
||||
|
||||
.voice-select {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -363,6 +453,9 @@
|
||||
<!-- TTS 设置 -->
|
||||
<div class="tts-section">
|
||||
<h3>🔊 TTS语音合成设置</h3>
|
||||
<div class="browser-tip" id="browserTip" style="display: none; background: #fff3cd; padding: 8px 12px; border-radius: 6px; margin-bottom: 10px; font-size: 13px; color: #856404;">
|
||||
⚠️ 当前浏览器可能限制音频播放,建议使用 Chrome/Firefox,或点击页面任意位置解锁播放
|
||||
</div>
|
||||
<div class="tts-options" id="ttsOptions">
|
||||
<div class="tts-option" data-provider="none">
|
||||
<div class="name">❌ 无 TTS</div>
|
||||
@@ -372,9 +465,9 @@
|
||||
<div class="name">🌐 Edge TTS</div>
|
||||
<div class="status" id="edgeStatus">检测中...</div>
|
||||
</div>
|
||||
<div class="tts-option disabled" data-provider="chattts">
|
||||
<div class="tts-option" data-provider="chattts">
|
||||
<div class="name">🤖 ChatTTS</div>
|
||||
<div class="status">暂未部署</div>
|
||||
<div class="status" id="chatttsStatus">检测中...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="voice-select" id="voiceSelect" style="display: none;">
|
||||
@@ -388,6 +481,20 @@
|
||||
<option value="zh-CN-XiaoyouNeural">晓悠(女)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tts-controls" id="ttsControls" style="display: none;">
|
||||
<div class="auto-play-switch">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="autoPlaySwitch" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span>自动播放</span>
|
||||
</div>
|
||||
<div class="volume-control">
|
||||
<span>🔊</span>
|
||||
<input type="range" id="volumeSlider" min="0.5" max="2" step="0.1" value="1.5">
|
||||
<span class="volume-value" id="volumeValue">150%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 录音 -->
|
||||
@@ -439,6 +546,18 @@
|
||||
let conversationId = null;
|
||||
let currentTTSProvider = 'none';
|
||||
let currentVoice = 'zh-CN-XiaoxiaoNeural';
|
||||
let autoPlay = true; // 自动播放开关
|
||||
let volumeLevel = 1.5; // 音量倍率
|
||||
let userInteracted = false; // 用户是否已交互
|
||||
|
||||
// 用户点击页面解锁音频播放能力
|
||||
document.addEventListener('click', () => {
|
||||
userInteracted = true;
|
||||
}, { once: true });
|
||||
|
||||
document.addEventListener('touchstart', () => {
|
||||
userInteracted = true;
|
||||
}, { once: true });
|
||||
|
||||
// 元素
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
@@ -453,16 +572,35 @@
|
||||
const ttsOptions = document.getElementById('ttsOptions');
|
||||
const voiceSelect = document.getElementById('voiceSelect');
|
||||
const voiceDropdown = document.getElementById('voiceDropdown');
|
||||
const ttsControls = document.getElementById('ttsControls');
|
||||
const autoPlaySwitch = document.getElementById('autoPlaySwitch');
|
||||
const volumeSlider = document.getElementById('volumeSlider');
|
||||
const volumeValue = document.getElementById('volumeValue');
|
||||
|
||||
// 初始化
|
||||
async function init() {
|
||||
await checkStatus();
|
||||
await loadTTSProviders();
|
||||
|
||||
// 检测特殊浏览器(小米浏览器等)
|
||||
checkBrowser();
|
||||
|
||||
// 定时检查状态
|
||||
setInterval(checkStatus, 10000);
|
||||
}
|
||||
|
||||
// 检测浏览器兼容性
|
||||
function checkBrowser() {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const browserTip = document.getElementById('browserTip');
|
||||
|
||||
// 小米浏览器、华为浏览器等国产浏览器UA特征
|
||||
if (ua.includes('miui') || ua.includes('xiaomi') || ua.includes('huawei') ||
|
||||
ua.includes('micromessenger') || ua.includes('quark') || ua.includes('ucbrowser')) {
|
||||
browserTip.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 检查服务状态
|
||||
async function checkStatus() {
|
||||
try {
|
||||
@@ -490,13 +628,20 @@
|
||||
|
||||
// 更新状态
|
||||
data.providers.forEach(p => {
|
||||
if (p.name === 'edge') {
|
||||
const statusEl = document.getElementById('edgeStatus');
|
||||
const statusElId = p.name === 'edge' ? 'edgeStatus' :
|
||||
p.name === 'chattts' ? 'chatttsStatus' : null;
|
||||
|
||||
if (statusElId) {
|
||||
const statusEl = document.getElementById(statusElId);
|
||||
statusEl.textContent = p.available ? '可用 ✓' : '不可用';
|
||||
|
||||
const optionEl = ttsOptions.querySelector('[data-provider="edge"]');
|
||||
if (p.available) {
|
||||
optionEl.classList.remove('disabled');
|
||||
const optionEl = ttsOptions.querySelector(`[data-provider="${p.name}"]`);
|
||||
if (optionEl) {
|
||||
if (p.available) {
|
||||
optionEl.classList.remove('disabled');
|
||||
} else {
|
||||
optionEl.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -524,6 +669,9 @@
|
||||
// 显示/隐藏音色选择
|
||||
voiceSelect.style.display = provider === 'edge' ? 'block' : 'none';
|
||||
|
||||
// 显示/隐藏控制选项(有TTS才显示)
|
||||
ttsControls.style.display = provider !== 'none' ? 'flex' : 'none';
|
||||
|
||||
// 保存设置
|
||||
saveTTSSettings();
|
||||
}
|
||||
@@ -759,6 +907,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 = `
|
||||
@@ -773,7 +923,8 @@
|
||||
} else if (role === 'assistant') {
|
||||
let audioHtml = '';
|
||||
if (audioData) {
|
||||
audioHtml = `<button class="play-btn tts-play-btn" onclick="playAudio('${audioData}', this)">
|
||||
audioBtnId = `audioBtn_${Date.now()}`;
|
||||
audioHtml = `<button class="play-btn tts-play-btn" id="${audioBtnId}" onclick="playAudio('${audioData}', this)">
|
||||
<span class="play-icon">🔊</span>
|
||||
<span>播放回复</span>
|
||||
</button>`;
|
||||
@@ -788,6 +939,16 @@
|
||||
|
||||
chatSection.appendChild(msg);
|
||||
chatSection.scrollTop = chatSection.scrollHeight;
|
||||
|
||||
// 自动播放(在元素添加到DOM后)
|
||||
if (role === 'assistant' && audioData && autoPlay && audioBtnId && userInteracted) {
|
||||
setTimeout(() => {
|
||||
const btn = document.getElementById(audioBtnId);
|
||||
if (btn) {
|
||||
playAudio(audioData, btn);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
@@ -801,7 +962,7 @@
|
||||
};
|
||||
|
||||
audio.onended = () => {
|
||||
icon.textContent = url.startsWith('/audio') ? '🔊' : '▶️';
|
||||
icon.textContent = url.startsWith('/audio') || url.startsWith('http') || url.startsWith('blob:') ? '🔊' : '▶️';
|
||||
btn.classList.remove('playing');
|
||||
};
|
||||
|
||||
@@ -855,6 +1016,17 @@
|
||||
saveTTSSettings();
|
||||
});
|
||||
|
||||
// 自动播放开关
|
||||
autoPlaySwitch.addEventListener('change', () => {
|
||||
autoPlay = autoPlaySwitch.checked;
|
||||
});
|
||||
|
||||
// 音量控制
|
||||
volumeSlider.addEventListener('input', () => {
|
||||
volumeLevel = parseFloat(volumeSlider.value);
|
||||
volumeValue.textContent = `${Math.round(volumeLevel * 100)}%`;
|
||||
});
|
||||
|
||||
recordBtn.addEventListener('click', () => {
|
||||
isRecording ? stopRecording() : startRecording();
|
||||
});
|
||||
|
||||
+44
-13
@@ -108,32 +108,63 @@ class EdgeTTSProvider(TTSProvider):
|
||||
|
||||
|
||||
class ChatTTSProvider(TTSProvider):
|
||||
"""ChatTTS 提供者(本地部署,预留接口)"""
|
||||
"""ChatTTS 提供者(本地部署)"""
|
||||
|
||||
# 预留配置
|
||||
CHATTTS_URL = os.getenv("CHATTTS_URL", "http://localhost:19020")
|
||||
# ChatTTS 服务地址
|
||||
CHATTTS_URL = os.getenv("CHATTTS_URL", "http://192.168.2.5:12002")
|
||||
|
||||
def __init__(self):
|
||||
self._available = False # 暂不可用
|
||||
self._available = None
|
||||
|
||||
async def synthesize(self, text: str) -> Tuple[str, str]:
|
||||
"""
|
||||
使用 ChatTTS 合成语音
|
||||
TODO: 后续实现
|
||||
"""
|
||||
raise NotImplementedError("ChatTTS 尚未实现,请先部署 ChatTTS 服务")
|
||||
"""使用 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 是否可用"""
|
||||
# TODO: 后续实现检测逻辑
|
||||
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_available(self, available: bool):
|
||||
"""设置可用状态(部署后调用)"""
|
||||
self._available = available
|
||||
def set_url(self, url: str):
|
||||
"""设置服务地址"""
|
||||
self.CHATTTS_URL = url
|
||||
self._available = None # 重新检测
|
||||
|
||||
|
||||
class NoTTSProvider(TTSProvider):
|
||||
|
||||
Reference in New Issue
Block a user