diff --git a/static/boot.js b/static/boot.js index 2d50695a..1fffefa8 100644 --- a/static/boot.js +++ b/static/boot.js @@ -855,6 +855,7 @@ window._micPendingSend=window._micPendingSend||false; let _voiceModeThinkingSid=null; let _browserTtsKeepAlive=null; let _browserTtsWatchdog=null; + let _browserTtsSuppressNextErrorRearm=false; const SILENCE_MS=1800; // auto-send after 1.8s silence function _clearBrowserTtsRecovery(){ @@ -870,11 +871,13 @@ window._micPendingSend=window._micPendingSend||false; function _armBrowserTtsRecovery(clean, rate){ _clearBrowserTtsRecovery(); + _browserTtsSuppressNextErrorRearm=false; 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; + _browserTtsSuppressNextErrorRearm=true; try{ speechSynthesis.cancel(); }catch(_){} _clearBrowserTtsRecovery(); _startListening(); @@ -1095,12 +1098,17 @@ window._micPendingSend=window._micPendingSend||false; if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch)); utter.onend=()=>{ + _browserTtsSuppressNextErrorRearm=false; _clearBrowserTtsRecovery(); // After speaking, go back to listening if(_voiceModeActive) setTimeout(()=>_startListening(),500); }; utter.onerror=()=>{ _clearBrowserTtsRecovery(); + if(_browserTtsSuppressNextErrorRearm){ + _browserTtsSuppressNextErrorRearm=false; + return; + } if(_voiceModeActive) setTimeout(()=>_startListening(),1000); }; @@ -1167,6 +1175,7 @@ window._micPendingSend=window._micPendingSend||false; _voiceModeActive=false; _voiceModeState='idle'; _voiceModeThinkingSid=null; + _browserTtsSuppressNextErrorRearm=false; modeBtn.classList.remove('active'); _setButtonTooltip(modeBtn, t('voice_mode_toggle')); bar.style.display='none'; diff --git a/tests/test_issue3983_browser_tts_watchdog.py b/tests/test_issue3983_browser_tts_watchdog.py index 69e73387..03e55bfd 100644 --- a/tests/test_issue3983_browser_tts_watchdog.py +++ b/tests/test_issue3983_browser_tts_watchdog.py @@ -9,6 +9,7 @@ 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 "let _browserTtsSuppressNextErrorRearm=false;" in src assert "function _clearBrowserTtsRecovery()" in src assert "function _armBrowserTtsRecovery(clean, rate)" in src @@ -20,6 +21,7 @@ def test_browser_tts_watchdog_rearms_listening_if_onend_drops(): arm_body = src[arm_idx : arm_idx + 1400] assert "_browserTtsWatchdog=setTimeout" in arm_body assert "_voiceModeState!=='speaking'" in arm_body + assert "_browserTtsSuppressNextErrorRearm=true;" in arm_body assert "speechSynthesis.cancel()" in arm_body assert "_startListening();" in arm_body assert "_browserTtsKeepAlive=setInterval" in arm_body @@ -37,6 +39,8 @@ def test_browser_tts_callbacks_and_deactivate_clear_recovery_handles(): assert speak_body.count("_clearBrowserTtsRecovery();") >= 2, ( "Both browser TTS completion callbacks must clear watchdog/keep-alive handles." ) + assert "_browserTtsSuppressNextErrorRearm=false;" in speak_body + assert "if(_browserTtsSuppressNextErrorRearm){" in speak_body assert "_armBrowserTtsRecovery(clean, utter.rate);" in speak_body deactivate_match = re.search( @@ -49,6 +53,7 @@ def test_browser_tts_callbacks_and_deactivate_clear_recovery_handles(): assert "_clearBrowserTtsRecovery();" in deactivate_body, ( "_deactivate() must clear browser TTS watchdog/keep-alive handles." ) + assert "_browserTtsSuppressNextErrorRearm=false;" in deactivate_body def test_edge_audio_branch_stays_separate():