6 Commits
11 changed files with 231 additions and 24 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.
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -2,4 +2,5 @@ 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 edge-tts==6.1.9
requests==2.31.0
+29 -1
View File
@@ -12,7 +12,7 @@ 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.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
# 导入 TTS 服务 # 导入 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") 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)
+156 -9
View File
@@ -124,6 +124,96 @@
color: #999; 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 { .voice-select {
margin-top: 10px; margin-top: 10px;
} }
@@ -372,9 +462,9 @@
<div class="name">🌐 Edge TTS</div> <div class="name">🌐 Edge TTS</div>
<div class="status" id="edgeStatus">检测中...</div> <div class="status" id="edgeStatus">检测中...</div>
</div> </div>
<div class="tts-option disabled" data-provider="chattts"> <div class="tts-option" data-provider="chattts">
<div class="name">🤖 ChatTTS</div> <div class="name">🤖 ChatTTS</div>
<div class="status">暂未部署</div> <div class="status" id="chatttsStatus">检测中...</div>
</div> </div>
</div> </div>
<div class="voice-select" id="voiceSelect" style="display: none;"> <div class="voice-select" id="voiceSelect" style="display: none;">
@@ -388,6 +478,20 @@
<option value="zh-CN-XiaoyouNeural">晓悠(女)</option> <option value="zh-CN-XiaoyouNeural">晓悠(女)</option>
</select> </select>
</div> </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> </div>
<!-- 录音 --> <!-- 录音 -->
@@ -439,6 +543,8 @@
let conversationId = null; let conversationId = null;
let currentTTSProvider = 'none'; let currentTTSProvider = 'none';
let currentVoice = 'zh-CN-XiaoxiaoNeural'; let currentVoice = 'zh-CN-XiaoxiaoNeural';
let autoPlay = true; // 自动播放开关
let volumeLevel = 1.5; // 音量倍率
// 元素 // 元素
const statusDot = document.getElementById('statusDot'); const statusDot = document.getElementById('statusDot');
@@ -453,6 +559,10 @@
const ttsOptions = document.getElementById('ttsOptions'); const ttsOptions = document.getElementById('ttsOptions');
const voiceSelect = document.getElementById('voiceSelect'); const voiceSelect = document.getElementById('voiceSelect');
const voiceDropdown = document.getElementById('voiceDropdown'); 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() { async function init() {
@@ -490,13 +600,20 @@
// 更新状态 // 更新状态
data.providers.forEach(p => { data.providers.forEach(p => {
if (p.name === 'edge') { const statusElId = p.name === 'edge' ? 'edgeStatus' :
const statusEl = document.getElementById('edgeStatus'); p.name === 'chattts' ? 'chatttsStatus' : null;
if (statusElId) {
const statusEl = document.getElementById(statusElId);
statusEl.textContent = p.available ? '可用 ✓' : '不可用'; statusEl.textContent = p.available ? '可用 ✓' : '不可用';
const optionEl = ttsOptions.querySelector('[data-provider="edge"]'); const optionEl = ttsOptions.querySelector(`[data-provider="${p.name}"]`);
if (p.available) { if (optionEl) {
optionEl.classList.remove('disabled'); if (p.available) {
optionEl.classList.remove('disabled');
} else {
optionEl.classList.add('disabled');
}
} }
} }
}); });
@@ -524,6 +641,9 @@
// 显示/隐藏音色选择 // 显示/隐藏音色选择
voiceSelect.style.display = provider === 'edge' ? 'block' : 'none'; voiceSelect.style.display = provider === 'edge' ? 'block' : 'none';
// 显示/隐藏控制选项(有TTS才显示)
ttsControls.style.display = provider !== 'none' ? 'flex' : 'none';
// 保存设置 // 保存设置
saveTTSSettings(); saveTTSSettings();
} }
@@ -759,6 +879,8 @@
const msg = document.createElement('div'); const msg = document.createElement('div');
msg.className = `message ${role}`; msg.className = `message ${role}`;
let audioBtnId = ''; // 在函数顶部声明
if (role === 'user' && content instanceof Blob) { if (role === 'user' && content instanceof Blob) {
const audioUrl = URL.createObjectURL(content); const audioUrl = URL.createObjectURL(content);
msg.innerHTML = ` msg.innerHTML = `
@@ -773,7 +895,8 @@
} else if (role === 'assistant') { } else if (role === 'assistant') {
let audioHtml = ''; let audioHtml = '';
if (audioData) { 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 class="play-icon">🔊</span>
<span>播放回复</span> <span>播放回复</span>
</button>`; </button>`;
@@ -788,6 +911,16 @@
chatSection.appendChild(msg); chatSection.appendChild(msg);
chatSection.scrollTop = chatSection.scrollHeight; chatSection.scrollTop = chatSection.scrollHeight;
// 自动播放(在元素添加到DOM后)
if (role === 'assistant' && audioData && autoPlay && audioBtnId) {
setTimeout(() => {
const btn = document.getElementById(audioBtnId);
if (btn) {
playAudio(audioData, btn);
}
}, 100);
}
} }
// 播放音频 // 播放音频
@@ -795,13 +928,16 @@
const audio = new Audio(url); const audio = new Audio(url);
const icon = btn.querySelector('.play-icon'); const icon = btn.querySelector('.play-icon');
// 应用音量倍率
audio.volume = Math.min(volumeLevel, 2); // 最大不超过2
audio.onplay = () => { audio.onplay = () => {
icon.textContent = '🔊'; icon.textContent = '🔊';
btn.classList.add('playing'); btn.classList.add('playing');
}; };
audio.onended = () => { audio.onended = () => {
icon.textContent = url.startsWith('/audio') ? '🔊' : '▶️'; icon.textContent = url.startsWith('/audio') || url.startsWith('http') ? '🔊' : '▶️';
btn.classList.remove('playing'); btn.classList.remove('playing');
}; };
@@ -855,6 +991,17 @@
saveTTSSettings(); saveTTSSettings();
}); });
// 自动播放开关
autoPlaySwitch.addEventListener('change', () => {
autoPlay = autoPlaySwitch.checked;
});
// 音量控制
volumeSlider.addEventListener('input', () => {
volumeLevel = parseFloat(volumeSlider.value);
volumeValue.textContent = `${Math.round(volumeLevel * 100)}%`;
});
recordBtn.addEventListener('click', () => { recordBtn.addEventListener('click', () => {
isRecording ? stopRecording() : startRecording(); isRecording ? stopRecording() : startRecording();
}); });
+44 -13
View File
@@ -108,32 +108,63 @@ class EdgeTTSProvider(TTSProvider):
class ChatTTSProvider(TTSProvider): class ChatTTSProvider(TTSProvider):
"""ChatTTS 提供者(本地部署,预留接口""" """ChatTTS 提供者(本地部署)"""
# 预留配置 # ChatTTS 服务地址
CHATTTS_URL = os.getenv("CHATTTS_URL", "http://localhost:19020") CHATTTS_URL = os.getenv("CHATTTS_URL", "http://192.168.2.5:12002")
def __init__(self): def __init__(self):
self._available = False # 暂不可用 self._available = None
async def synthesize(self, text: str) -> Tuple[str, str]: async def synthesize(self, text: str) -> Tuple[str, str]:
""" """使用 ChatTTS 合成语音"""
使用 ChatTTS 合成语音 import aiohttp
TODO: 后续实现
""" async with aiohttp.ClientSession() as session:
raise NotImplementedError("ChatTTS 尚未实现,请先部署 ChatTTS 服务") 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: def get_name(self) -> str:
return "ChatTTS" return "ChatTTS"
def is_available(self) -> bool: def is_available(self) -> bool:
"""检查 ChatTTS 是否可用""" """检查 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 return self._available
def set_available(self, available: bool): def set_url(self, url: str):
"""设置可用状态(部署后调用)""" """设置服务地址"""
self._available = available self.CHATTTS_URL = url
self._available = None # 重新检测
class NoTTSProvider(TTSProvider): class NoTTSProvider(TTSProvider):