fix: robust mic toggle + Tailscale MediaRecorder fallback — v0.50.94 (PR #715)

Fixes and extends PR #683 (MatzAgent). recognition.start() is now a real call. _isRecording race guard added with correct reset in all paths. localStorage persistence of fallback flag. Closes #683.

Co-authored-by: MatzAgent <MatzAgent@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-18 23:28:14 -07:00
committed by GitHub
parent 66fbfbaa2b
commit e637965388
2 changed files with 31 additions and 3 deletions

View File

@@ -1,5 +1,10 @@
# Hermes Web UI -- Changelog # Hermes Web UI -- Changelog
## [v0.50.94] — 2026-04-19
### Fixed
- **Mic toggle is now race-safe and works over Tailscale** — rapid click/toggle no longer leaves recording in inconsistent state (`_isRecording` flag with proper reset in all paths). `recognition.start()` is now correctly called (was previously only present in a comment string, so SpeechRecognition never started and the Tailscale fallback never fired). Falls back to `MediaRecorder` when `speech.googleapis.com` is unreachable. Browser capability preference persisted in `localStorage` across reloads. (PR #683 by @MatzAgent)
## [v0.50.93] — 2026-04-19 ## [v0.50.93] — 2026-04-19
### Fixed ### Fixed

View File

@@ -185,18 +185,23 @@ $('btnAttach').onclick=()=>$('fileInput').click();
const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder); const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden
// Persist SR failure across reloads (e.g. Tailscale/network error)
const _micForceMediaRecorderKey='mic_force_mediarecorder';
let _forceMediaRecorder=!SpeechRecognition||localStorage.getItem(_micForceMediaRecorderKey)==='1';
const btn=$('btnMic'); const btn=$('btnMic');
const status=$('micStatus'); const status=$('micStatus');
const ta=$('msg'); const ta=$('msg');
const statusText=status?status.querySelector('.status-text'):null; const statusText=status?status.querySelector('.status-text'):null;
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
let recognition=SpeechRecognition?new SpeechRecognition():null; let recognition=(!_forceMediaRecorder&&SpeechRecognition)?new SpeechRecognition():null;
let mediaRecorder=null; let mediaRecorder=null;
let mediaStream=null; let mediaStream=null;
let audioChunks=[]; let audioChunks=[];
let _finalText=''; let _finalText='';
let _prefix=''; let _prefix='';
let _isRecording=false;
function _setRecording(on){ function _setRecording(on){
window._micActive=on; window._micActive=on;
@@ -261,7 +266,7 @@ $('btnAttach').onclick=()=>$('fileInput').click();
} }
window._stopMic=_stopMic; // expose for send-guard above window._stopMic=_stopMic; // expose for send-guard above
if(recognition){ if(recognition && !_forceMediaRecorder){
recognition.continuous=false; recognition.continuous=false;
recognition.interimResults=true; recognition.interimResults=true;
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US'; recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
@@ -298,6 +303,13 @@ $('btnAttach').onclick=()=>$('fileInput').click();
recognition.onerror=(event)=>{ recognition.onerror=(event)=>{
_setRecording(false); _setRecording(false);
window._micPendingSend=false; window._micPendingSend=false;
_isRecording=false;
if(event.error==='network'||event.error==='not-allowed'){
// Persist SR failure: next reload will skip SpeechRecognition
localStorage.setItem(_micForceMediaRecorderKey,'1');
_forceMediaRecorder=true;
recognition=null;
}
const msgs={ const msgs={
'not-allowed':t('mic_denied'), 'not-allowed':t('mic_denied'),
'no-speech':t('mic_no_speech'), 'no-speech':t('mic_no_speech'),
@@ -308,18 +320,26 @@ $('btnAttach').onclick=()=>$('fileInput').click();
} }
btn.onclick=async()=>{ btn.onclick=async()=>{
// Race-condition guard: ignore rapid double-clicks
if(_isRecording){
_stopMic();
_isRecording=false;
return;
}
if(window._micActive){ if(window._micActive){
_stopMic(); _stopMic();
return; return;
} }
_isRecording=true;
_finalText=''; _finalText='';
_prefix=ta.value; _prefix=ta.value;
if(recognition){ if(recognition && !_forceMediaRecorder){
recognition.start(); recognition.start();
_setRecording(true); _setRecording(true);
return; return;
} }
if(!_canRecordAudio){ if(!_canRecordAudio){
_isRecording=false;
showToast(t('mic_network')); showToast(t('mic_network'));
return; return;
} }
@@ -331,12 +351,14 @@ $('btnAttach').onclick=()=>$('fileInput').click();
audioChunks=[]; audioChunks=[];
mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);}; mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
mediaRecorder.onerror=()=>{ mediaRecorder.onerror=()=>{
_isRecording=false;
_setRecording(false); _setRecording(false);
window._micPendingSend=false; window._micPendingSend=false;
_stopTracks(); _stopTracks();
showToast(t('mic_network')); showToast(t('mic_network'));
}; };
mediaRecorder.onstop=async()=>{ mediaRecorder.onstop=async()=>{
_isRecording=false;
const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'}); const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
_setRecording(false); _setRecording(false);
_stopTracks(); _stopTracks();
@@ -348,6 +370,7 @@ $('btnAttach').onclick=()=>$('fileInput').click();
mediaRecorder.start(); mediaRecorder.start();
_setRecording(true); _setRecording(true);
}catch(err){ }catch(err){
_isRecording=false;
window._micPendingSend=false; window._micPendingSend=false;
_stopTracks(); _stopTracks();
showToast(t('mic_denied')); showToast(t('mic_denied'));