Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c93b83e2cf | ||
|
|
0a51190482 | ||
|
|
7778522c96 | ||
|
|
2877ae996d | ||
|
|
dbafd4fb73 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
-1
@@ -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
|
||||||
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-20
@@ -134,8 +134,12 @@ class ChatTTSProvider(TTSProvider):
|
|||||||
raise Exception(f"ChatTTS error: {error}")
|
raise Exception(f"ChatTTS error: {error}")
|
||||||
|
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
# ChatTTS 返回的 URL 是相对路径,需要拼接
|
# ChatTTS 返回的 URL 是 /audio/xxx.wav
|
||||||
audio_url = f"{self.CHATTTS_URL}{data['audio_url']}"
|
# 改用本地代理路径(解决 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
|
return None, audio_url
|
||||||
|
|
||||||
def get_name(self) -> str:
|
def get_name(self) -> str:
|
||||||
@@ -145,24 +149,13 @@ class ChatTTSProvider(TTSProvider):
|
|||||||
"""检查 ChatTTS 是否可用"""
|
"""检查 ChatTTS 是否可用"""
|
||||||
if self._available is None:
|
if self._available is None:
|
||||||
try:
|
try:
|
||||||
import aiohttp
|
import requests
|
||||||
import asyncio
|
resp = requests.get(f"{self.CHATTTS_URL}/health", timeout=5)
|
||||||
|
if resp.status_code == 200:
|
||||||
async def check():
|
data = resp.json()
|
||||||
try:
|
self._available = data.get("status") == "ok"
|
||||||
async with aiohttp.ClientSession() as session:
|
else:
|
||||||
async with session.get(
|
self._available = False
|
||||||
f"{self.CHATTTS_URL}/health",
|
|
||||||
timeout=aiohttp.ClientTimeout(total=5)
|
|
||||||
) as resp:
|
|
||||||
if resp.status == 200:
|
|
||||||
data = await resp.json()
|
|
||||||
return data.get("status") == "ok"
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
self._available = asyncio.get_event_loop().run_until_complete(check())
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"ChatTTS check failed: {e}")
|
logger.warning(f"ChatTTS check failed: {e}")
|
||||||
self._available = False
|
self._available = False
|
||||||
|
|||||||
Reference in New Issue
Block a user