- Captions read message bodies only (the scraper was concatenating avatar, author and worklog chips); multi-segment interim turns are now speakable and drive clean speak-to-thinking-to-speak cycles when playback drains mid-turn. - Dynamic endpointing: complete-looking partials (3+ words or terminal punctuation) endpoint at the base window; the long hold remains only for one-two-word fragments. A stale-busy 10s settle wait on every post-error send is gone. - Both overlay captions are bounded, touch-scrollable regions with follow-tail; caps raised for long turns. - Error envelopes are never spoken or captioned; errored turns run resyncCapture (fresh STT session on the hot mic). - Workspace toggle now lives in the sidebar rail (floating button only below the rail breakpoint). - False 'session unavailable' toast root-caused: the router continuity guard shows it on a 409 that fired when a transient profile-listing failure failed closed into a fake cross-profile mismatch; the patcher now answers from the alias cache and never claims a default-vs-named mismatch while aliases are unconfirmed. - Barge-in sends carry a one-line cut-point marker with the last spoken sentence; visible-history truncation judged infeasible client-side. - Language switching works end-to-end: sticky per-session STT language hint (restarting an unused next session on switch), reply voice from script evidence, STT detection, then stopword heuristic; cues and WAV fallback share the turn language. - Legacy CI guards: node skip for the DOM probe, ffmpeg/codec skips for the container-fallback test. 272 voice-lane tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
149 lines
6.1 KiB
Python
149 lines
6.1 KiB
Python
"""Contracts for low-latency, interruptible Hermes hands-free playback."""
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
VOICE = ROOT / "dockerfiles/hermes-webui-atlas-voice.js"
|
|
WORKLET = ROOT / "dockerfiles/hermes-webui-atlas-voice-worklet.js"
|
|
|
|
|
|
def test_barge_in_keeps_aec_capture_and_requires_sustained_speech():
|
|
source = VOICE.read_text(encoding="utf-8")
|
|
|
|
assert "echoCancellation:true" in source
|
|
assert "noiseSuppression:true" in source
|
|
assert "autoGainControl:true" in source
|
|
assert "const BARGE_DUCK_FRAMES=2" in source
|
|
assert "const BARGE_TRIGGER_FRAMES=4" in source
|
|
assert "let speechArmAt=Date.now()+100" in source
|
|
assert "speechArmAt=now+(state==='thinking'?150:450)" in source
|
|
assert "speechArmAt=now+100" in source
|
|
assert "playbackActive&&!playbackWasActive" in source
|
|
assert "const threshold=Math.max(0.05,(noiseFloor*3)+0.008)" in source
|
|
assert "setPlaybackDucked(true)" in source
|
|
assert "setPlaybackDucked(false)" in source
|
|
assert "settings.echoCancellation!==false" in source
|
|
assert "const automaticBargeAllowed=!playbackActive||monitor.aecUsable" in source
|
|
|
|
|
|
def test_barge_in_cancels_owned_turn_and_reuses_pcm_lookback():
|
|
source = VOICE.read_text(encoding="utf-8")
|
|
|
|
assert "api/chat/cancel?stream_id=" in source
|
|
assert "controller.abort();},1800" in source
|
|
assert "const deadline=Date.now()+10000" in source
|
|
assert "stopResponseObserver();" in source
|
|
assert "cancelSpeechTurn();" in source
|
|
assert "stopPlayback();" in source
|
|
assert "bargeCancelPromise=cancelActiveModelTurn()" in source
|
|
assert "lookback:monitor.lookback" in source
|
|
assert "heardSpeech:true" in source
|
|
assert "reusedCapture.lookback.forEach" in source
|
|
assert "const bytes=new Uint8Array(16)" in source
|
|
assert "window.crypto.getRandomValues(bytes)" in source
|
|
assert (
|
|
"captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)"
|
|
in source
|
|
)
|
|
|
|
|
|
def test_streamed_chunks_share_one_audio_timeline_until_final_drain():
|
|
source = VOICE.read_text(encoding="utf-8")
|
|
worklet = WORKLET.read_text(encoding="utf-8")
|
|
|
|
assert "if(session.node)" in source
|
|
assert "return session;" in source
|
|
assert "session.node.port.postMessage({type:'push'" in source
|
|
assert "session.node.port.postMessage({type:'end'})" in source
|
|
assert "await drainPcmPlayback(session,turn.token)" in source
|
|
assert "node.connect(gain)" in source
|
|
assert "gain.connect(context.destination)" in source
|
|
assert "this.ended&&!this.queue.length" in worklet
|
|
assert "this.port.postMessage({type:'drained'})" in worklet
|
|
|
|
|
|
def test_barge_cancel_settles_before_new_turn_baseline_and_observer():
|
|
source = VOICE.read_text(encoding="utf-8")
|
|
region = source.split("async function sendTranscript", 1)[1].split(
|
|
"function audioExtension", 1
|
|
)[0]
|
|
|
|
assert region.index("await settleBargeCancellation(token)") < region.index(
|
|
"rememberAssistantBaseline()"
|
|
)
|
|
assert region.index("rememberAssistantBaseline()") < region.index("window.send()")
|
|
assert region.index("window.send()") < region.index("startResponseObserver(token)")
|
|
assert (
|
|
"suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'"
|
|
in source
|
|
)
|
|
|
|
|
|
def test_barge_handoff_never_uses_a_truncated_container_fallback():
|
|
source = VOICE.read_text(encoding="utf-8")
|
|
|
|
assert "monitor.handoff=true" in source
|
|
assert "if(!monitor.handoff) trimBargeLookback(monitor)" in source
|
|
assert "handoffMonitor:monitor" in source
|
|
assert "reusedCapture.lookback.forEach" in source
|
|
assert "requireStreamingLookback:true" in source
|
|
assert "allowContainerFallback===false" in source
|
|
assert "Please repeat your interruption" in source
|
|
|
|
|
|
def test_rolling_partials_are_feedback_only_and_wav_fallback_is_owned():
|
|
source = VOICE.read_text(encoding="utf-8")
|
|
partial = source.split("payload.type==='partial'", 1)[1].split(
|
|
"payload.type==='final'", 1
|
|
)[0]
|
|
fallback = source.split("async function fetchSpeech", 1)[1].split(
|
|
"function cuePoolOffset", 1
|
|
)[0]
|
|
|
|
assert "payload.stable_transcript" in partial
|
|
assert "const provisional=String(payload.transcript||'').trim()" in partial
|
|
assert "const visible=stable||provisional" in partial
|
|
assert (
|
|
"label.textContent='Listening · '+preview+(stable?'':' · provisional')"
|
|
in partial
|
|
)
|
|
assert "if(stable){" in partial
|
|
assert "scheduleVoicePreflight(turnId,revision,stable)" in partial
|
|
assert "composer.value" not in partial
|
|
assert "window.send" not in partial
|
|
assert "signal:controller.signal" in fallback
|
|
assert "token!==generation" in fallback
|
|
assert "session.turnId!==turnId" in fallback
|
|
assert "closePcmBeforeBlob" in source
|
|
|
|
|
|
def test_stt_client_queue_and_canonical_archive_are_strictly_bounded():
|
|
source = VOICE.read_text(encoding="utf-8")
|
|
|
|
assert "const STT_MAX_QUEUED_BYTES=1048576" in source
|
|
assert "const STT_MAX_ARCHIVE_BYTES=2880000" in source
|
|
assert "queuedBytes+bytes.byteLength>STT_MAX_QUEUED_BYTES" in source
|
|
assert "Streaming transcription backpressure limit exceeded" in source
|
|
assert "if(flushTimer||settled||!queue.length" in source
|
|
assert (
|
|
"flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)"
|
|
in source
|
|
)
|
|
assert "function clearQueue(){queue=[];queuedBytes=0;clearFlushTimer();}" in source
|
|
assert "takeFallbackBlob:function()" in source
|
|
assert "pcm16WavBlob(archive,16000)" in source
|
|
assert "if(pcmFallback)" in source
|
|
|
|
|
|
def test_playback_audibility_and_settle_short_circuit():
|
|
source = VOICE.read_text(encoding="utf-8")
|
|
|
|
# Barge detection keys off actually audible playback, so the persistent
|
|
# PCM worklet node between speech segments no longer counts as speaking.
|
|
assert "function playbackAudible()" in source
|
|
assert "const playbackActive=playbackAudible();" in source
|
|
assert "const playbackLive=playbackAudible();" in source
|
|
# A cancellation record with no in-flight stream can never gate the send.
|
|
assert "if(!cancellation.streamId){" in source
|