fix(#3169): pin capture backend at mic start (_activeCaptureMode) — toggle-mid-record safety (Codex review #2)

Codex round-2: _stopMic and mediaRecorder.onstop read the CURRENT _rawAudioMode
to choose backend/dispatch, but the recording was started on the OLD mode — so
toggling Settings→Sound mid-recording could stop the wrong backend (orphaning
the other) or dispatch raw-vs-transcribe wrongly. Pin _activeCaptureMode
(speech | media-raw | media-transcribe) at start; _stopMic + onstop use it.
Adds front-end source-invariant regression tests.

Co-authored-by: lucasrc <lrclucas@gmail.com>
This commit is contained in:
nesquena-hermes
2026-05-31 04:54:56 +00:00
parent c74a019a8a
commit 0cceb3bebf
2 changed files with 45 additions and 2 deletions

View File

@@ -444,6 +444,10 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
// Raw audio mode preference: send audio file instead of transcribing
let _rawAudioMode = localStorage.getItem('hermes-raw-audio-mode') === 'true';
// Capture backend pinned at recording start ('speech' | 'media' | null) so
// _stopMic / onstop act on the backend that actually started, even if the
// raw-audio toggle changes mid-recording (#3169 Codex review).
let _activeCaptureMode = null;
const btn=$('btnMic');
const status=$('micStatus');
@@ -549,7 +553,11 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
function _stopMic(){
if(!window._micActive) return;
if(recognition && !_rawAudioMode){
// Stop the backend that was ACTIVE WHEN RECORDING STARTED — not whatever
// _rawAudioMode says now. The user can toggle Settings → Sound mid-recording,
// which would otherwise make us stop the wrong backend and orphan the other
// (#3169 Codex review). _activeCaptureMode is pinned at start.
if(recognition && _activeCaptureMode==='speech'){
recognition.stop();
return;
}
@@ -630,6 +638,7 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
_finalText='';
_prefix=ta.value;
if(recognition && !_forceMediaRecorder && !_rawAudioMode){
_activeCaptureMode='speech';
recognition.start();
_setRecording(true);
return;
@@ -659,7 +668,7 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
_setRecording(false);
_stopTracks();
if(blob.size){
if(_rawAudioMode){
if(_activeCaptureMode==='media-raw'){
await _sendRawAudio(blob);
}else{
await _transcribeBlob(blob);
@@ -669,6 +678,7 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
window._micPendingSend=false;
}
};
_activeCaptureMode=_rawAudioMode?'media-raw':'media-transcribe';
mediaRecorder.start();
_setRecording(true);
}catch(err){

View File

@@ -141,3 +141,36 @@ def test_raw_audio_upload_different_formats():
except KeyError:
pass
assert handler.status is not None, f"Failed for {filename} ({mime})"
# ── Front-end: raw-audio mic backend pinning (boot.js source invariants) ──────
# Regression guard for the #3169 Codex-review fix: _stopMic / onstop must act on
# the capture backend that was ACTIVE WHEN RECORDING STARTED (pinned in
# _activeCaptureMode), not whatever _rawAudioMode says now — otherwise toggling
# Settings → Sound mid-recording orphans the wrong backend. Also: an explicit
# Send click (_micPendingSend) must send even with text in the composer.
import pathlib as _pathlib
_BOOT_JS = (_pathlib.Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8")
def test_stop_mic_uses_pinned_active_capture_mode_not_current_rawmode():
assert "let _activeCaptureMode" in _BOOT_JS, "_activeCaptureMode must be declared"
# _stopMic decides backend from the pinned mode, not _rawAudioMode.
assert "_activeCaptureMode==='speech'" in _BOOT_JS
# onstop dispatches raw-vs-transcribe from the pinned mode too.
assert "_activeCaptureMode==='media-raw'" in _BOOT_JS
# the mode is pinned at both start branches.
assert "_activeCaptureMode='speech'" in _BOOT_JS
assert "_activeCaptureMode=_rawAudioMode?'media-raw':'media-transcribe'" in _BOOT_JS
def test_send_raw_audio_honors_explicit_pending_send():
# An explicit Send-button click (sets _micPendingSend) must send the raw
# audio even when the composer already has text.
assert "if(window._micPendingSend){" in _BOOT_JS
# and it lives inside _sendRawAudio (before the empty-composer fallback).
idx = _BOOT_JS.index("async function _sendRawAudio")
end = _BOOT_JS.index("function _commitTranscript", idx)
body = _BOOT_JS[idx:end]
assert "window._micPendingSend" in body and "send()" in body