fix(#3983): re-arm browser voice mode when speechSynthesis drops onend
This commit is contained in:
1 parent
8669b6b8b5
commit
f4a6544121
2 files changed
+111
-1
No files matched your search
+48
-1
@@ -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();
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
Reference in new issue
Block a user