diff --git a/static/boot.js b/static/boot.js index 8fff26ed..2d50695a 100644 --- a/static/boot.js +++ b/static/boot.js @@ -853,8 +853,45 @@ window._micPendingSend=window._micPendingSend||false; // a different session's last assistant reply if the user navigated away // between send and stream completion. (Opus pre-release advisor.) let _voiceModeThinkingSid=null; + let _browserTtsKeepAlive=null; + let _browserTtsWatchdog=null; const SILENCE_MS=1800; // auto-send after 1.8s silence + function _clearBrowserTtsRecovery(){ + if(_browserTtsKeepAlive){ + clearInterval(_browserTtsKeepAlive); + _browserTtsKeepAlive=null; + } + if(_browserTtsWatchdog){ + clearTimeout(_browserTtsWatchdog); + _browserTtsWatchdog=null; + } + } + + function _armBrowserTtsRecovery(clean, rate){ + _clearBrowserTtsRecovery(); + const safeRate=(Number.isFinite(rate)&&rate>0)?rate:1; + // Chromium can drop utter.onend on later turns, so force a recovery path. + const watchdogMs=Math.max(4000,Math.round((String(clean||'').length/(12*safeRate))*1000)+10000); + _browserTtsWatchdog=setTimeout(()=>{ + if(!_voiceModeActive||_voiceModeState!=='speaking') return; + try{ speechSynthesis.cancel(); }catch(_){} + _clearBrowserTtsRecovery(); + _startListening(); + },watchdogMs); + _browserTtsKeepAlive=setInterval(()=>{ + if(!_voiceModeActive||_voiceModeState!=='speaking'){ + _clearBrowserTtsRecovery(); + return; + } + if(!speechSynthesis.speaking) return; + try{ + speechSynthesis.pause(); + speechSynthesis.resume(); + }catch(_){} + },10000); + } + function _setState(state){ _voiceModeState=state; indicator.className='voice-mode-indicator '+state; @@ -867,6 +904,7 @@ window._micPendingSend=window._micPendingSend||false; function _startListening(){ if(!_voiceModeActive) return; + _clearBrowserTtsRecovery(); _setState('listening'); _recognition=new SpeechRecognition(); @@ -1057,14 +1095,22 @@ window._micPendingSend=window._micPendingSend||false; if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch)); utter.onend=()=>{ + _clearBrowserTtsRecovery(); // After speaking, go back to listening if(_voiceModeActive) setTimeout(()=>_startListening(),500); }; utter.onerror=()=>{ + _clearBrowserTtsRecovery(); if(_voiceModeActive) setTimeout(()=>_startListening(),1000); }; - speechSynthesis.speak(utter); + _armBrowserTtsRecovery(clean, utter.rate); + try{ + speechSynthesis.speak(utter); + }catch(_){ + _clearBrowserTtsRecovery(); + if(_voiceModeActive) setTimeout(()=>_startListening(),1000); + } } // Hook into response completion — observe when the agent finishes @@ -1125,6 +1171,7 @@ window._micPendingSend=window._micPendingSend||false; _setButtonTooltip(modeBtn, t('voice_mode_toggle')); bar.style.display='none'; clearTimeout(_silenceTimer); + _clearBrowserTtsRecovery(); try{ if(_recognition) _recognition.abort(); }catch(_){} _recognition=null; if(typeof stopTTS==='function') stopTTS(); diff --git a/tests/test_issue3983_browser_tts_watchdog.py b/tests/test_issue3983_browser_tts_watchdog.py new file mode 100644 index 00000000..69e73387 --- /dev/null +++ b/tests/test_issue3983_browser_tts_watchdog.py @@ -0,0 +1,63 @@ +from pathlib import Path +import re + + +REPO = Path(__file__).resolve().parents[1] + + +def test_boot_js_declares_browser_tts_recovery_helpers(): + src = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + assert "let _browserTtsKeepAlive=null;" in src + assert "let _browserTtsWatchdog=null;" in src + assert "function _clearBrowserTtsRecovery()" in src + assert "function _armBrowserTtsRecovery(clean, rate)" in src + + +def test_browser_tts_watchdog_rearms_listening_if_onend_drops(): + src = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + arm_idx = src.find("function _armBrowserTtsRecovery(clean, rate){") + assert arm_idx != -1 + arm_body = src[arm_idx : arm_idx + 1400] + assert "_browserTtsWatchdog=setTimeout" in arm_body + assert "_voiceModeState!=='speaking'" in arm_body + assert "speechSynthesis.cancel()" in arm_body + assert "_startListening();" in arm_body + assert "_browserTtsKeepAlive=setInterval" in arm_body + assert "speechSynthesis.pause();" in arm_body + assert "speechSynthesis.resume();" in arm_body + + +def test_browser_tts_callbacks_and_deactivate_clear_recovery_handles(): + src = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + speak_idx = src.find("const utter=new SpeechSynthesisUtterance(clean);") + assert speak_idx != -1 + speak_body = src[speak_idx : speak_idx + 1200] + assert "utter.onend=()=>{" in speak_body + assert "utter.onerror=()=>{" in speak_body + assert speak_body.count("_clearBrowserTtsRecovery();") >= 2, ( + "Both browser TTS completion callbacks must clear watchdog/keep-alive handles." + ) + assert "_armBrowserTtsRecovery(clean, utter.rate);" in speak_body + + deactivate_match = re.search( + r"function _deactivate\(\)\{(.*?)\n \}", + src, + re.DOTALL, + ) + assert deactivate_match, "_deactivate() must exist" + deactivate_body = deactivate_match.group(1) + assert "_clearBrowserTtsRecovery();" in deactivate_body, ( + "_deactivate() must clear browser TTS watchdog/keep-alive handles." + ) + + +def test_edge_audio_branch_stays_separate(): + src = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + edge_idx = src.find("if(engine===\"edge\"){") + assert edge_idx != -1 + edge_body = src[edge_idx : edge_idx + 1300] + assert "const audio = new Audio(url);" in edge_body + assert "audio.onended = () => {" in edge_body + assert "_armBrowserTtsRecovery" not in edge_body, ( + "The browser speechSynthesis workaround must not be injected into the Edge audio branch." + )