feat: add Edge TTS as alternative speech engine (#2931, @liuqiangweb-svg)

This commit is contained in:
nesquena-hermes committed 2026-06-02 01:21:39 +00:00
1 parent f0c3668d97
commit 1c29d6ac9c
8 files changed
+311 -18

No files matched your search

+140
View File
@@ -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")
+1
View File
@@ -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}" {} +
}
+1
View File
@@ -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
View File
@@ -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(_){}
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();