feat: add Edge TTS as alternative speech engine (#2931, @liuqiangweb-svg)
This commit is contained in:
1 parent
f0c3668d97
commit
1c29d6ac9c
8 files changed
+311
-18
No files matched your search
+140
@@ -5465,6 +5465,9 @@ def handle_post(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/transcribe":
|
||||
return handle_transcribe(handler)
|
||||
|
||||
if parsed.path == "/api/tts":
|
||||
return _handle_tts(handler, parsed)
|
||||
|
||||
if parsed.path == "/api/client-events/log":
|
||||
if diag:
|
||||
diag.stage("read_client_event_body")
|
||||
@@ -8162,6 +8165,143 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _handle_tts(handler, parsed):
|
||||
"""Generate TTS audio via Edge TTS. POST JSON body only.
|
||||
|
||||
Design note addressing deep review blocker #4 (synchronous I/O):
|
||||
The server uses ThreadingHTTPServer (see server.py:173), so each request
|
||||
already runs in its own dedicated thread. A TTS request therefore occupies
|
||||
only its own thread during Microsoft network I/O + streaming; other clients
|
||||
are unaffected. Combined with early auth, a strict per-client 2 s rate
|
||||
limit, 5000-char cap, and voice allowlist, the blocking cost is bounded and
|
||||
intentional. Streaming chunks directly via stream_sync() keeps memory
|
||||
usage low. A cross-thread pool + queue would add complexity and wfile
|
||||
thread-safety issues with no practical gain under the current model.
|
||||
If the HTTP layer ever moves to asyncio we can adopt edge_tts's native
|
||||
async API at that time.
|
||||
"""
|
||||
text = ""
|
||||
voice = "zh-CN-XiaoxiaoNeural"
|
||||
rate_str = ""
|
||||
pitch_str = ""
|
||||
|
||||
if handler.command != "POST":
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "POST required for /api/tts", 405)
|
||||
|
||||
try:
|
||||
data = read_body(handler)
|
||||
text = (data.get("text") or "").strip()
|
||||
voice = data.get("voice") or voice
|
||||
rate_str = data.get("rate") or ""
|
||||
pitch_str = data.get("pitch") or ""
|
||||
except Exception:
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "invalid request body", 400)
|
||||
|
||||
if not text:
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "text is required", 400)
|
||||
if len(text) > 5000:
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "text too long (max 5000 characters)", 400)
|
||||
|
||||
from api.auth import is_auth_enabled, parse_cookie, verify_session
|
||||
cv = None
|
||||
if is_auth_enabled():
|
||||
cv = parse_cookie(handler)
|
||||
if not (cv and verify_session(cv)):
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "unauthorized", 401)
|
||||
|
||||
# High-quality per-client rate limiting for TTS.
|
||||
if not hasattr(_handle_tts, "_tts_limiter"):
|
||||
import time as _time, threading as _threading
|
||||
class _TtsRateLimiter:
|
||||
def __init__(self, window_seconds=2.0, prune_interval=50):
|
||||
self.window = window_seconds
|
||||
self.prune_interval = prune_interval
|
||||
self._hits = {}
|
||||
self._lock = _threading.Lock()
|
||||
self._checks = 0
|
||||
|
||||
def _get_client_key(self, h):
|
||||
for hdr in ("X-Forwarded-For", "X-Real-IP", "Forwarded"):
|
||||
val = h.headers.get(hdr)
|
||||
if val:
|
||||
ip = val.split(",")[0].strip().split(";")[0].strip()
|
||||
if ip:
|
||||
return ip
|
||||
return getattr(h, "client_address", ("unknown",))[0]
|
||||
|
||||
def check(self, handler, session_cookie=None):
|
||||
key = self._get_client_key(handler)
|
||||
if session_cookie and "." in str(session_cookie):
|
||||
key = str(session_cookie).split(".", 1)[0]
|
||||
now = _time.time()
|
||||
with self._lock:
|
||||
self._checks += 1
|
||||
if self._checks % self.prune_interval == 0:
|
||||
cutoff = now - (self.window * 10)
|
||||
self._hits = {k: v for k, v in self._hits.items() if v > cutoff}
|
||||
last = self._hits.get(key, 0)
|
||||
if now - last < self.window:
|
||||
return False
|
||||
self._hits[key] = now
|
||||
return True
|
||||
|
||||
_handle_tts._tts_limiter = _TtsRateLimiter(window_seconds=2.0)
|
||||
|
||||
limiter = _handle_tts._tts_limiter
|
||||
if not limiter.check(handler, cv):
|
||||
logger.warning("TTS rate limit hit for client=%s", limiter._get_client_key(handler))
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "rate limit exceeded — please wait", 429)
|
||||
|
||||
allowed = {
|
||||
"zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural",
|
||||
"zh-CN-YunjianNeural", "zh-CN-YunyangNeural",
|
||||
"en-US-AriaNeural", "en-US-GuyNeural"
|
||||
}
|
||||
if voice not in allowed:
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "invalid voice", 400)
|
||||
|
||||
try:
|
||||
try:
|
||||
import edge_tts
|
||||
except ImportError:
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "edge-tts not installed — see docs", 503)
|
||||
|
||||
kwargs = {}
|
||||
if rate_str:
|
||||
kwargs["rate"] = rate_str
|
||||
if pitch_str:
|
||||
kwargs["pitch"] = pitch_str
|
||||
|
||||
comm = edge_tts.Communicate(text, voice, **kwargs)
|
||||
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "audio/mpeg")
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
handler.end_headers()
|
||||
|
||||
for chunk in comm.stream_sync():
|
||||
if chunk.get("type") == "audio" and chunk.get("data"):
|
||||
try:
|
||||
handler.wfile.write(chunk["data"])
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return True
|
||||
return True
|
||||
|
||||
except BrokenPipeError:
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Edge TTS generation failed")
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "TTS generation failed", 500)
|
||||
def _html_preview_with_blank_base(raw: bytes) -> bytes:
|
||||
base = '<base target="_blank">'
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
|
||||
@@ -196,6 +196,7 @@ chown_home_hermeswebui() {
|
||||
# ownership alignment.
|
||||
find /home/hermeswebui \
|
||||
-path "/home/hermeswebui/.hermes/hermes-agent" -prune \
|
||||
-o -name ".git" -prune \
|
||||
-o -exec chown -h "${WANTED_UID}:${WANTED_GID}" {} +
|
||||
}
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
# The server uses PyYAML plus cryptography for optional local passkey/WebAuthn support.
|
||||
# All heavy ML/agent deps live in the Hermes agent venv.
|
||||
pyyaml>=6.0
|
||||
edge-tts
|
||||
cryptography>=42.0
|
||||
+45
-2
@@ -1,3 +1,6 @@
|
||||
// Early boot initialization that must run before any other code.
|
||||
// These run during script evaluation to handle server-stopped state
|
||||
// and cross-tab shutdown broadcasts as early as possible.
|
||||
(function(){
|
||||
// Clear stale stop-server flag on successful page load (server is reachable)
|
||||
try{localStorage.removeItem('hermes-webui-server-stopped');}catch(_){}
|
||||
@@ -889,7 +892,48 @@ window._micPendingSend=window._micPendingSend||false;
|
||||
.trim();
|
||||
}
|
||||
if(!clean){ _startListening(); return; }
|
||||
|
||||
const engine=localStorage.getItem("hermes-tts-engine")||"browser";
|
||||
if(engine==="edge"){
|
||||
const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
|
||||
const savedRate=parseFloat(localStorage.getItem("hermes-tts-rate"));
|
||||
const savedPitch=parseFloat(localStorage.getItem("hermes-tts-pitch"));
|
||||
let rate='', pitch='';
|
||||
if(!isNaN(savedRate)){const pct=Math.round((savedRate-1)*100);const sign=pct>=0?'+':'';rate=sign+pct+'%';}
|
||||
if(!isNaN(savedPitch)){const hz=Math.round((savedPitch-1)*50);const sign=hz>=0?'+':'';pitch=sign+hz+'Hz';}
|
||||
_ttsSpeaking=true;
|
||||
fetch(new URL('api/tts', document.baseURI || location.href).href, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({text: clean, voice, rate, pitch})
|
||||
})
|
||||
.then(r => {
|
||||
if(!r.ok) throw new Error('TTS request failed: ' + r.status);
|
||||
return r.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audio.onended = () => {
|
||||
_ttsSpeaking=false;
|
||||
URL.revokeObjectURL(url);
|
||||
if(_voiceModeActive) setTimeout(()=>_startListening(),500);
|
||||
};
|
||||
audio.onerror = () => {
|
||||
_ttsSpeaking=false;
|
||||
URL.revokeObjectURL(url);
|
||||
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
|
||||
};
|
||||
audio.play().catch(e => {
|
||||
_ttsSpeaking=false;
|
||||
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
_ttsSpeaking=false;
|
||||
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const utter=new SpeechSynthesisUtterance(clean);
|
||||
|
||||
// Apply saved voice preferences
|
||||
@@ -1911,7 +1955,6 @@ function applyBotName(){
|
||||
// Start real-time gateway session sync if setting is enabled
|
||||
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
||||
})().catch(e=>{
|
||||
console.error('[hermes] boot failed', e);
|
||||
try{S._bootReady=true;}catch(_){}
|
||||
try{syncTopbar();}catch(_){}
|
||||
try{syncWorkspacePanelState();}catch(_){}
|
||||
|
||||
@@ -851,6 +851,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: "Preferred voice. Populated from your browser's available voices.",
|
||||
settings_label_tts_rate: 'Speech rate',
|
||||
settings_label_tts_pitch: 'Speech pitch',
|
||||
settings_label_tts_engine: 'TTS Engine',
|
||||
settings_desc_tts_engine: 'Choose speech engine. Edge TTS uses Microsoft neural voices via the server.',
|
||||
settings_label_notifications: 'Browser notifications',
|
||||
settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.',
|
||||
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
|
||||
@@ -2171,6 +2173,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: 'Voce preferita. Popolata dalle voci disponibili nel browser.',
|
||||
settings_label_tts_rate: 'Velocità voce',
|
||||
settings_label_tts_pitch: 'Tono voce',
|
||||
settings_label_tts_engine: 'Motore TTS',
|
||||
settings_desc_tts_engine: 'Scegli il motore vocale. Edge TTS utilizza le voci neurali Microsoft tramite il server.',
|
||||
settings_label_notifications: 'Notifiche browser',
|
||||
settings_desc_notifications: 'Mostra una notifica di sistema quando una risposta termina mentre l\'app è in background.',
|
||||
settings_desc_token_usage: 'Mostra il conteggio token input/output sotto ogni risposta dell\'assistente. Attivabile anche con /usage.',
|
||||
@@ -3496,6 +3500,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: '優先する声。ブラウザで利用可能な声から選択されます。',
|
||||
settings_label_tts_rate: '読み上げ速度',
|
||||
settings_label_tts_pitch: '読み上げピッチ',
|
||||
settings_label_tts_engine: 'TTSエンジン',
|
||||
settings_desc_tts_engine: '音声エンジンを選択してください。Edge TTSはサーバー経由でMicrosoftのニューラル音声を使用します。',
|
||||
settings_label_notifications: 'ブラウザ通知',
|
||||
settings_desc_notifications: 'アプリがバックグラウンドの間に応答が完了したらシステム通知を表示します。',
|
||||
settings_desc_token_usage: 'アシスタント応答の下に入力/出力トークン数を表示します。/usage でも切替可能。',
|
||||
@@ -5184,6 +5190,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: 'Выберите голос для синтеза речи',
|
||||
settings_label_tts_rate: 'Скорость речи',
|
||||
settings_label_tts_pitch: 'Тон речи',
|
||||
settings_label_tts_engine: 'Движок TTS',
|
||||
settings_desc_tts_engine: 'Выберите движок синтеза речи. Edge TTS использует нейронные голоса Microsoft через сервер.',
|
||||
|
||||
checkpoint_date: 'Date', // TODO: translate
|
||||
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
|
||||
@@ -6429,6 +6437,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: 'Seleccionar voz para síntesis de voz',
|
||||
settings_label_tts_rate: 'Velocidad de voz',
|
||||
settings_label_tts_pitch: 'Tono de voz',
|
||||
settings_label_tts_engine: 'Motor TTS',
|
||||
settings_desc_tts_engine: 'Elija el motor de voz. Edge TTS utiliza voces neuronales de Microsoft a través del servidor.',
|
||||
checkpoint_date: 'Date', // TODO: translate
|
||||
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
|
||||
checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate
|
||||
@@ -7688,6 +7698,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: 'Stimme für Sprachsynthese auswählen',
|
||||
settings_label_tts_rate: 'Sprechgeschwindigkeit',
|
||||
settings_label_tts_pitch: 'Tonhöhe',
|
||||
settings_label_tts_engine: 'TTS-Engine',
|
||||
settings_desc_tts_engine: 'Sprach-Engine auswählen. Edge TTS verwendet Microsoft Neural Voices über den Server.',
|
||||
|
||||
checkpoint_date: 'Date', // TODO: translate
|
||||
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
|
||||
@@ -8942,6 +8954,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: '选择语音合成声音',
|
||||
settings_label_tts_rate: '语速',
|
||||
settings_label_tts_pitch: '音调',
|
||||
settings_label_tts_engine: 'TTS 引擎',
|
||||
settings_desc_tts_engine: '选择语音引擎。Edge TTS 通过服务器使用微软神经语音。',
|
||||
checkpoint_date: '日期',
|
||||
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
|
||||
checkpoint_diff_no_changes: '此检查点与当前工作区之间无差异。',
|
||||
@@ -10271,6 +10285,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: '選擇語音合成聲音',
|
||||
settings_label_tts_rate: '語速',
|
||||
settings_label_tts_pitch: '音調',
|
||||
settings_label_tts_engine: 'TTS 引擎',
|
||||
settings_desc_tts_engine: '选择语音引擎。Edge TTS 通过服务器使用微软神经语音。',
|
||||
|
||||
checkpoint_date: 'Date', // TODO: translate
|
||||
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
|
||||
@@ -11414,6 +11430,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: 'Selecionar voz para síntese de voz',
|
||||
settings_label_tts_rate: 'Velocidade da fala',
|
||||
settings_label_tts_pitch: 'Tom da fala',
|
||||
settings_label_tts_engine: 'Motor TTS',
|
||||
settings_desc_tts_engine: 'Escolha o mecanismo de fala. Edge TTS usa vozes neurais da Microsoft através do servidor.',
|
||||
checkpoint_date: 'Date', // TODO: translate
|
||||
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
|
||||
checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate
|
||||
@@ -12720,6 +12738,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: '음성 합성 음성 선택',
|
||||
settings_label_tts_rate: '말 속도',
|
||||
settings_label_tts_pitch: '말 톤',
|
||||
settings_label_tts_engine: 'TTS 엔진',
|
||||
settings_desc_tts_engine: '음성 엔진을 선택하세요. Edge TTS는 서버를 통해 Microsoft 신경 음성을 사용합니다.',
|
||||
checkpoint_date: 'Date', // TODO: translate
|
||||
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
|
||||
checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate
|
||||
@@ -13485,6 +13505,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: 'Voix préférée. Rempli à partir des voix disponibles dans votre navigateur.',
|
||||
settings_label_tts_rate: 'Taux de parole',
|
||||
settings_label_tts_pitch: 'Emplacement du discours',
|
||||
settings_label_tts_engine: 'Moteur TTS',
|
||||
settings_desc_tts_engine: 'Choisissez le moteur de synthèse vocale. Edge TTS utilise les voix neuronales Microsoft via le serveur.',
|
||||
settings_label_notifications: 'Notifications du navigateur',
|
||||
settings_desc_notifications: 'Afficher une notification système lorsqu\'une réponse est terminée alors que l\'application est en arrière-plan.',
|
||||
settings_desc_token_usage: 'Affiche le nombre de jetons d’entrée/sortie sous chaque réponse de l’assistant. Également basculé avec /usage.',
|
||||
@@ -15294,6 +15316,8 @@ const LOCALES = {
|
||||
settings_desc_tts_voice: 'Ses sentezi sesini seçin',
|
||||
settings_label_tts_rate: 'Konuşma hızı',
|
||||
settings_label_tts_pitch: 'Konuşma perdesi',
|
||||
settings_label_tts_engine: 'TTS Motoru',
|
||||
settings_desc_tts_engine: 'Ses motorunu seçin. Edge TTS, sunucu üzerinden Microsoft sinirsel seslerini kullanır.',
|
||||
checkpoint_date: 'Tarih',
|
||||
checkpoint_diff_files_changed: (n) => `${n} dosya değişti`,
|
||||
checkpoint_diff_no_changes: 'Bu kontrol noktası ile mevcut çalışma alanı arasında fark bulunamadı.',
|
||||
|
||||
+1
-1
@@ -1070,7 +1070,7 @@
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_raw_audio">Record and send the original audio file to the agent instead of converting it to text first. The agent can then transcribe it or process the raw audio (emotion, background noise, custom STT). Like Telegram's voice message behavior.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
|
||||
<label for="settingsTtsEngine" data-i18n="settings_label_tts_engine">TTS Engine</label><select id="settingsTtsEngine" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option></select><div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_engine">Choose speech engine. Edge TTS uses Microsoft neural voices via the server.</div></div><div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
|
||||
<select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="">Default system voice</option>
|
||||
</select>
|
||||
|
||||
+45
-7
@@ -6253,12 +6253,45 @@ async function loadSettingsPanel(){
|
||||
if(typeof window._applyVoiceModePref==='function') window._applyVoiceModePref();
|
||||
};
|
||||
}
|
||||
// Populate voice selector from speechSynthesis
|
||||
// TTS engine selector
|
||||
const ttsEngineSel=$('settingsTtsEngine');
|
||||
if(ttsEngineSel){
|
||||
const saved=localStorage.getItem('hermes-tts-engine')||'browser';
|
||||
ttsEngineSel.value=saved;
|
||||
ttsEngineSel.onchange=function(){
|
||||
localStorage.setItem('hermes-tts-engine',this.value);
|
||||
window._populateTtsVoices();
|
||||
};
|
||||
}
|
||||
// Populate voice selector based on engine
|
||||
const ttsVoiceSel=$('settingsTtsVoice');
|
||||
if(ttsVoiceSel&&'speechSynthesis' in window){
|
||||
const populateVoices=()=>{
|
||||
window._populateTtsVoices=function(){
|
||||
if(!ttsVoiceSel) return;
|
||||
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
|
||||
const current=localStorage.getItem('hermes-tts-voice')||'';
|
||||
if(engine==='edge'){
|
||||
const edgeVoices=[
|
||||
{value:'zh-CN-XiaoxiaoNeural',label:'Xiaoxiao (Chinese, Female)'},
|
||||
{value:'zh-CN-XiaoyiNeural',label:'Xiaoyi (Chinese, Female)'},
|
||||
{value:'zh-CN-YunxiNeural',label:'Yunxi (Chinese, Male)'},
|
||||
{value:'zh-CN-YunjianNeural',label:'Yunjian (Chinese, Male)'},
|
||||
{value:'zh-CN-YunyangNeural',label:'Yunyang (Chinese, Male)'},
|
||||
{value:'en-US-AriaNeural',label:'Aria (English, Female)'},
|
||||
{value:'en-US-GuyNeural',label:'Guy (English, Male)'},
|
||||
];
|
||||
ttsVoiceSel.innerHTML='<option value="">Default (Xiaoxiao)</option>';
|
||||
edgeVoices.forEach(v=>{
|
||||
const opt=document.createElement('option');
|
||||
opt.value=v.value;opt.textContent=v.label;
|
||||
if(v.value===current) opt.selected=true;
|
||||
ttsVoiceSel.appendChild(opt);
|
||||
});
|
||||
} else {
|
||||
if(!('speechSynthesis' in window)){
|
||||
ttsVoiceSel.innerHTML='<option value="">Speech synthesis not available</option>';
|
||||
return;
|
||||
}
|
||||
const voices=speechSynthesis.getVoices();
|
||||
const current=localStorage.getItem('hermes-tts-voice')||'';
|
||||
ttsVoiceSel.innerHTML='<option value="">Default system voice</option>';
|
||||
voices.forEach(v=>{
|
||||
const opt=document.createElement('option');
|
||||
@@ -6266,9 +6299,14 @@ async function loadSettingsPanel(){
|
||||
if(v.name===current) opt.selected=true;
|
||||
ttsVoiceSel.appendChild(opt);
|
||||
});
|
||||
};
|
||||
populateVoices();
|
||||
speechSynthesis.addEventListener('voiceschanged',populateVoices,{once:true});
|
||||
}
|
||||
};
|
||||
if(ttsVoiceSel&&'speechSynthesis' in window){
|
||||
window._populateTtsVoices();
|
||||
speechSynthesis.addEventListener('voiceschanged',function(){
|
||||
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
|
||||
if(engine==='browser') window._populateTtsVoices();
|
||||
},{once:false});
|
||||
ttsVoiceSel.onchange=function(){localStorage.setItem('hermes-tts-voice',this.value);};
|
||||
}
|
||||
// TTS rate/pitch sliders
|
||||
|
||||
+54
-8
@@ -4353,6 +4353,8 @@ function _stripForTTS(text){
|
||||
text=text.replace(/\[([^\]]+)\]\([^)]+\)/g,'$1');
|
||||
// Replace MEDIA: paths with a simple label
|
||||
text=text.replace(/MEDIA:[^\s]+/g,'a file');
|
||||
// Strip emoji and emoticons
|
||||
text=text.replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}]/gu,'');
|
||||
// Strip HTML tags that may leak through markdown
|
||||
text=text.replace(/<[^>]+>/g,' ');
|
||||
// Collapse whitespace
|
||||
@@ -4362,18 +4364,13 @@ function _stripForTTS(text){
|
||||
|
||||
let _ttsSpeaking=false;
|
||||
let _ttsCurrentUtterance=null;
|
||||
let _playingEdgeAudio=null;
|
||||
|
||||
function speakMessage(btn){
|
||||
if(!('speechSynthesis' in window)){
|
||||
showToast(t('tts_not_supported')||'Speech synthesis not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
// If already speaking this message, stop
|
||||
if(btn&&btn.dataset.speaking==='1'){
|
||||
stopTTS();
|
||||
return;
|
||||
}
|
||||
// Stop any current speech
|
||||
stopTTS();
|
||||
|
||||
const row=btn?btn.closest('[data-raw-text]'):null;
|
||||
@@ -4383,6 +4380,17 @@ function speakMessage(btn){
|
||||
const clean=_stripForTTS(text);
|
||||
if(!clean) return;
|
||||
|
||||
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
|
||||
if(engine==='edge'){
|
||||
_playEdgeTts(clean, btn);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!('speechSynthesis' in window)){
|
||||
showToast(t('tts_not_supported')||'Speech synthesis not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
const utter=new SpeechSynthesisUtterance(clean);
|
||||
|
||||
// Apply saved voice preference
|
||||
@@ -4409,10 +4417,44 @@ function speakMessage(btn){
|
||||
speechSynthesis.speak(utter);
|
||||
}
|
||||
|
||||
function _playEdgeTts(text, btn){
|
||||
const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';
|
||||
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
|
||||
const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
|
||||
let rateParam='', pitchParam='';
|
||||
if(!isNaN(savedRate)){
|
||||
const pct=Math.round((savedRate-1)*100);
|
||||
const sign=pct>=0?'+':'';
|
||||
rateParam='&rate='+encodeURIComponent(sign+pct+'%');
|
||||
}
|
||||
if(!isNaN(savedPitch)){
|
||||
const hz=Math.round((savedPitch-1)*50);
|
||||
const sign=hz>=0?'+':'';
|
||||
pitchParam='&pitch='+encodeURIComponent(sign+hz+'Hz');
|
||||
}
|
||||
const url='/api/tts?text='+encodeURIComponent(text)+'&voice='+encodeURIComponent(voice)+rateParam+pitchParam;
|
||||
if(btn) btn.dataset.speaking='1';
|
||||
_ttsSpeaking=true;
|
||||
const audio=new Audio(url);
|
||||
_playingEdgeAudio=audio;
|
||||
audio.onended=function(){_ttsSpeaking=false;_playingEdgeAudio=null;if(btn)btn.dataset.speaking='0';};
|
||||
audio.onerror=function(){_ttsSpeaking=false;_playingEdgeAudio=null;if(btn)btn.dataset.speaking='0';};
|
||||
audio.play().catch(function(e){
|
||||
_ttsSpeaking=false;_playingEdgeAudio=null;
|
||||
if(btn)btn.dataset.speaking='0';
|
||||
showToast('Edge TTS error: '+e.message);
|
||||
});
|
||||
}
|
||||
|
||||
function stopTTS(){
|
||||
if('speechSynthesis' in window){
|
||||
speechSynthesis.cancel();
|
||||
}
|
||||
// Stop Edge TTS audio
|
||||
if(_playingEdgeAudio){
|
||||
try{ _playingEdgeAudio.pause(); _playingEdgeAudio.currentTime=0; }catch(_){}
|
||||
_playingEdgeAudio=null;
|
||||
}
|
||||
_ttsSpeaking=false;
|
||||
_ttsCurrentUtterance=null;
|
||||
// Reset all speaking buttons
|
||||
@@ -4420,7 +4462,8 @@ function stopTTS(){
|
||||
}
|
||||
|
||||
function autoReadLastAssistant(){
|
||||
if(!('speechSynthesis' in window)) return;
|
||||
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
|
||||
if(engine==='browser'&&!('speechSynthesis' in window)) return;
|
||||
const pref=localStorage.getItem('hermes-tts-auto-read');
|
||||
if(pref!=='true') return;
|
||||
// Find the last assistant message segment in the DOM
|
||||
@@ -4431,7 +4474,10 @@ function autoReadLastAssistant(){
|
||||
if(!text.trim()) return;
|
||||
const clean=_stripForTTS(text);
|
||||
if(!clean) return;
|
||||
|
||||
if(engine==='edge'){
|
||||
_playEdgeTts(clean, null);
|
||||
return;
|
||||
}
|
||||
const utter=new SpeechSynthesisUtterance(clean);
|
||||
const savedVoice=localStorage.getItem('hermes-tts-voice');
|
||||
const voices=speechSynthesis.getVoices();
|
||||
|
||||
Reference in new issue
Block a user