#5310: push-to-talk hold gesture (#3700)

This commit is contained in:
nesquena-hermes committed 2026-07-01 23:26:15 +00:00
1 parent b1a02bdb72
commit 9fb2731848
8 files changed
+246 -40

No files matched your search

-4
View File
@@ -19367,10 +19367,7 @@ def _handle_chat_start(handler, body, diag=None):
if "model_provider" in body
else getattr(s, "model_provider", None)
)
<<<<<<< ours
_pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, requested_provider)
=======
>>>>>>> theirs
explicit_model_pick = bool(body.get("explicit_model_pick"))
moa_config = None
gateway_chat_enabled = webui_gateway_chat_enabled(get_config())
@@ -19383,7 +19380,6 @@ def _handle_chat_start(handler, body, diag=None):
moa_config = resolve_moa_config()
except RuntimeError as e:
return bad(handler, str(e), 503)
_pp_provider, _pp_default = _read_profile_model_config(s, requested_provider)
diag.stage("resolve_model_provider") if diag else None
model, model_provider, normalized_model = _resolve_compatible_session_model_state(
requested_model,
+127 -19
View File
@@ -645,6 +645,11 @@ function _micToastKeyForRecognitionError(error){
let _finalText='';
let _prefix='';
let _isRecording=false;
let _micHoldTimer=null;
let _micHoldActive=false;
let _micPointerDown=false;
let _micStartSeq=0;
const _micHoldThresholdMs=300;
function _setButtonTooltipAndKey(btn, key){
const text = t(key);
@@ -751,14 +756,16 @@ function _micToastKeyForRecognitionError(error){
}
}
function _stopTracks(){
if(mediaStream){
mediaStream.getTracks().forEach(track=>track.stop());
mediaStream=null;
function _stopTracks(stream=mediaStream){
if(stream){
stream.getTracks().forEach(track=>track.stop());
if(mediaStream===stream) mediaStream=null;
}
}
function _stopMic(){
_micStartSeq+=1;
_isRecording=false;
if(!window._micActive) return;
// Stop the backend that was ACTIVE WHEN RECORDING STARTED — not whatever
// _rawAudioMode says now. The user can toggle Settings → Sound mid-recording,
@@ -868,7 +875,31 @@ function _micToastKeyForRecognitionError(error){
_probeServerSttCapability();
btn.onclick=async()=>{
function _clearMicHoldTimer(){
if(_micHoldTimer){
clearTimeout(_micHoldTimer);
_micHoldTimer=null;
}
}
function _resetMicHoldState(){
_clearMicHoldTimer();
_micHoldActive=false;
_micPointerDown=false;
}
function _micButtonAvailable(){
if(!btn||btn.disabled) return false;
if(btn.style.display==='none') return false;
if(btn.classList.contains('composer-control-hidden')) return false;
if(btn.getAttribute('aria-hidden')==='true') return false;
if(window.getComputedStyle&&window.getComputedStyle(btn).display==='none') return false;
return true;
}
async function _startMicCapture(holdRequired=false){
if(!_micButtonAvailable()) return;
const startSeq=++_micStartSeq;
// Race-condition guard: ignore rapid double-clicks
if(_isRecording){
_stopMic();
@@ -900,26 +931,38 @@ function _micToastKeyForRecognitionError(error){
return;
}
try{
mediaStream=await navigator.mediaDevices.getUserMedia({audio:true});
const captureStream=await navigator.mediaDevices.getUserMedia({audio:true});
if(startSeq!==_micStartSeq||!_micButtonAvailable()||(holdRequired&&!_micHoldActive)){
_isRecording=false;
_stopTracks(captureStream);
return;
}
mediaStream=captureStream;
const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg'];
const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||'';
mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined);
const captureMode=_rawAudioMode?'media-raw':'media-transcribe';
const recorder=new MediaRecorder(captureStream,mimeType?{mimeType}:undefined);
audioChunks=[];
mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
mediaRecorder.onerror=()=>{
const captureChunks=audioChunks;
recorder.ondataavailable=e=>{if(e.data&&e.data.size)captureChunks.push(e.data);};
recorder.onerror=()=>{
const isCurrentCapture=mediaRecorder===recorder||mediaStream===captureStream;
_isRecording=false;
_setRecording(false);
if(mediaRecorder===recorder) mediaRecorder=null;
if(isCurrentCapture) _setRecording(false);
window._micPendingSend=false;
_stopTracks();
_stopTracks(captureStream);
showToast(t('mic_network'));
};
mediaRecorder.onstop=async()=>{
recorder.onstop=async()=>{
const isCurrentCapture=mediaRecorder===recorder||mediaStream===captureStream;
if(mediaRecorder===recorder) mediaRecorder=null;
_isRecording=false;
const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
_setRecording(false);
_stopTracks();
const blob=new Blob(captureChunks,{type:recorder.mimeType||mimeType||'audio/webm'});
if(isCurrentCapture) _setRecording(false);
_stopTracks(captureStream);
if(blob.size){
if(_activeCaptureMode==='media-raw'){
if(captureMode==='media-raw'){
await _sendRawAudio(blob);
}else{
await _transcribeBlob(blob);
@@ -930,16 +973,81 @@ function _micToastKeyForRecognitionError(error){
}
_applyDeferredServerSttFlip();
};
_activeCaptureMode=_rawAudioMode?'media-raw':'media-transcribe';
mediaRecorder.start();
_activeCaptureMode=captureMode;
mediaRecorder=recorder;
recorder.start();
_setRecording(true);
}catch(err){
if(startSeq!==_micStartSeq) return;
_isRecording=false;
window._micPendingSend=false;
_stopTracks();
showToast(t(_micToastKeyForRecognitionError('not-allowed')||'mic_denied'));
}
};
}
async function _toggleMicCapture(){
if(!_micButtonAvailable()) return;
if(window._micActive){
_stopMic();
return;
}
await _startMicCapture();
}
window._toggleMicCapture=_toggleMicCapture;
btn.addEventListener('pointerdown',e=>{
if(e.button!==0) return;
if(!_micButtonAvailable()) return;
_resetMicHoldState();
_micPointerDown=true;
_micHoldTimer=setTimeout(async()=>{
_micHoldTimer=null;
if(!_micPointerDown||window._micActive) return;
_micHoldActive=true;
await _startMicCapture(true);
},_micHoldThresholdMs);
});
btn.addEventListener('pointerup',async e=>{
if(e.button!==0||!_micPointerDown) return;
_clearMicHoldTimer();
if(_micHoldActive){
_micHoldActive=false;
_micPointerDown=false;
_stopMic();
return;
}
_micPointerDown=false;
await _toggleMicCapture();
});
btn.addEventListener('pointerleave',()=>{
_clearMicHoldTimer();
if(_micHoldActive){
_micHoldActive=false;
_micPointerDown=false;
_stopMic();
return;
}
_micPointerDown=false;
});
btn.addEventListener('pointercancel',()=>{
_clearMicHoldTimer();
if(_micHoldActive){
_micHoldActive=false;
_micPointerDown=false;
_stopMic();
return;
}
_micPointerDown=false;
});
btn.addEventListener('click',async e=>{
if(e.detail!==0) return;
await _toggleMicCapture();
});
// Wire up the settings checkbox
const rawAudioCheckbox = document.getElementById('settingsRawAudio');
+9 -1
View File
@@ -1644,7 +1644,15 @@ function cmdReasoning(args){
}
function cmdVoice(){
const mic=document.getElementById('btnMic');
if(mic&&mic.style.display!=='none'&&!mic.disabled){try{mic.click();return;}catch(_){}}
const micVisible=!!(
mic
&& mic.style.display!=='none'
&& !mic.disabled
&& !mic.classList.contains('composer-control-hidden')
&& mic.getAttribute('aria-hidden')!=='true'
&& (!window.getComputedStyle||window.getComputedStyle(mic).display!=='none')
);
if(micVisible){try{mic.click();return;}catch(_){}}
showToast(t('cmd_voice_use_mic'));
}
+9 -7
View File
@@ -177,13 +177,15 @@ def test_590_transcribing_status_shown_before_fetch():
def test_590_recording_stops_before_transcribe():
"""boot.js: _setRecording(false) must fire in onstop before _transcribeBlob."""
onstop_start = BOOT_JS.find("mediaRecorder.onstop")
assert onstop_start != -1, "mediaRecorder.onstop not found"
onstop_body = BOOT_JS[onstop_start:onstop_start + 400]
"""boot.js: _setRecording(false) must fire in onstop before the pinned send path."""
onstop_start = BOOT_JS.find("recorder.onstop")
assert onstop_start != -1, "recorder.onstop not found"
onstop_body = BOOT_JS[onstop_start:onstop_start + 700]
rec_pos = onstop_body.find("_setRecording(false)")
blob_pos = onstop_body.find("_transcribeBlob(")
assert rec_pos != -1 and blob_pos != -1
assert rec_pos < blob_pos, (
"_setRecording(false) must come before _transcribeBlob so mic icon clears immediately"
raw_pos = onstop_body.find("_sendRawAudio(")
send_pos = raw_pos if raw_pos != -1 and (blob_pos == -1 or raw_pos < blob_pos) else blob_pos
assert rec_pos != -1 and send_pos != -1
assert rec_pos < send_pos, (
"_setRecording(false) must come before the pinned send path so the mic icon clears immediately"
)
+92
View File
@@ -0,0 +1,92 @@
import re
from pathlib import Path
def _boot_src() -> str:
return Path("static/boot.js").read_text(encoding="utf-8")
def _commands_src() -> str:
return Path("static/commands.js").read_text(encoding="utf-8")
def test_start_mic_capture_extracted():
src = _boot_src()
assert re.search(r"async function _startMicCapture\([^)]*\)\{", src)
assert "_activeCaptureMode='speech'" in src
assert "const captureMode=_rawAudioMode?'media-raw':'media-transcribe';" in src
assert "_activeCaptureMode=captureMode;" in src
def test_toggle_mic_capture_exposed_on_window():
src = _boot_src()
assert "async function _toggleMicCapture()" in src
assert "window._toggleMicCapture=_toggleMicCapture;" in src
def test_toggle_helper_respects_hidden_mic_setting():
src = _boot_src()
assert "function _micButtonAvailable()" in src
assert "btn.classList.contains('composer-control-hidden')" in src
assert "btn.getAttribute('aria-hidden')==='true'" in src
def test_voice_command_respects_hidden_mic_setting_before_clicking():
src = _commands_src()
assert "function cmdVoice()" in src
assert "mic.classList.contains('composer-control-hidden')" in src
assert "mic.getAttribute('aria-hidden')!=='true'" in src
assert "showToast(t('cmd_voice_use_mic'));" in src
def test_old_onclick_handler_removed():
src = _boot_src()
assert "btn.onclick=async()=>{" not in src
def test_pointer_hold_wiring_present():
src = _boot_src()
assert "let _micHoldTimer=null;" in src
assert "let _micHoldActive=false;" in src
assert "let _micPointerDown=false;" in src
assert "let _micStartSeq=0;" in src
assert "const _micHoldThresholdMs=300;" in src
assert "btn.addEventListener('pointerdown'" in src
assert "btn.addEventListener('pointerup'" in src
assert (
"btn.addEventListener('pointerleave'" in src
or "btn.addEventListener('pointercancel'" in src
)
assert "_micHoldTimer=setTimeout(async()=>{" in src
assert "},_micHoldThresholdMs);" in src
def test_hold_release_routes_to_stop_mic():
src = _boot_src()
assert re.search(
r"btn\.addEventListener\('pointerup',async e=>\{.*?_stopMic\(\);.*?\}\);",
src,
re.DOTALL,
)
assert re.search(
r"btn\.addEventListener\('pointer(cancel|leave)',\(\)=>\{.*?_stopMic\(\);.*?\}\);",
src,
re.DOTALL,
)
assert "const startSeq=++_micStartSeq;" in src
assert "if(startSeq!==_micStartSeq||!_micButtonAvailable()||(holdRequired&&!_micHoldActive)){" in src
assert "_stopTracks(captureStream);" in src
assert "if(startSeq!==_micStartSeq) return;" in src
def test_reserved_ctrl_shift_d_shortcut_is_not_bound():
src = _boot_src()
assert "(e.metaKey||e.ctrlKey)&&e.shiftKey&&!e.altKey&&(e.key==='d'||e.key==='D')" not in src
assert "await window._toggleMicCapture();" not in src
def test_stop_mic_still_exposed_and_active_capture_mode_retained():
src = _boot_src()
assert "window._stopMic=_stopMic;" in src
assert "_activeCaptureMode" in src
assert "_stopMic();" in src
+2 -2
View File
@@ -70,8 +70,8 @@ def test_permission_style_speech_errors_use_insecure_origin_key_only_on_that_bra
def test_dictation_preflights_insecure_origin_before_speech_or_media_capture():
body = _slice_between(
BOOT_JS,
"btn.onclick=async()=>{",
"\n // Wire up the settings checkbox",
"async function _startMicCapture(holdRequired=false){",
"\n\n async function _toggleMicCapture(){",
)
insecure_idx = body.index("_micOriginNeedsSecureContext()")
speech_idx = body.index("recognition.start()")
+3 -2
View File
@@ -159,10 +159,11 @@ def test_stop_mic_uses_pinned_active_capture_mode_not_current_rawmode():
# _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
assert "if(captureMode==='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
assert "const captureMode=_rawAudioMode?'media-raw':'media-transcribe';" in _BOOT_JS
assert "_activeCaptureMode=captureMode;" in _BOOT_JS
def test_send_raw_audio_honors_explicit_pending_send():
+4 -5
View File
@@ -425,11 +425,10 @@ def test_boot_js_prefix_variable_declared():
def test_boot_js_prefix_captured_on_start():
"""_prefix must be set from ta.value when the user starts recording."""
js, _ = get_text("/static/boot.js")
# _prefix assignment must happen in the btn.onclick else branch (before recognition.start)
btn_onclick_idx = js.find("btn.onclick")
btn_onclick_end = js.find("};", btn_onclick_idx)
onclick_body = js[btn_onclick_idx:btn_onclick_end]
assert "_prefix=ta.value" in onclick_body or "_prefix = ta.value" in onclick_body
start_idx = js.find("async function _startMicCapture")
start_end = js.find("async function _toggleMicCapture", start_idx)
start_body = js[start_idx:start_end]
assert "_prefix=ta.value" in start_body or "_prefix = ta.value" in start_body
def test_boot_js_onresult_prepends_prefix():