"""Continuous-capture contracts: no first-word clipping, stitching, overlay. The scenarios drive the real ``atlas-voice.js`` against a stub streaming STT WebSocket whose VAD, speculative EOS-freeze, resume and commit semantics mirror ``hermes-jetson-stt-server.py``. Words are encoded as distinct PCM amplitudes, so the transcript of a commit is exactly the words whose audio survived endpointing — the live "only the first word was sent" regression is directly observable in the composer text the probe records. """ from __future__ import annotations import json import shutil import subprocess from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] VOICE_SCRIPT = ROOT / "dockerfiles" / "hermes-webui-atlas-voice.js" PROBE = ROOT / "testing" / "probes" / "hermes_voice_capture_probe.js" @pytest.fixture(scope="module") def probe_results() -> dict: node = shutil.which("node") if not node: pytest.skip("node is required to drive the capture continuity contract") completed = subprocess.run( [node, str(PROBE), str(VOICE_SCRIPT)], check=False, capture_output=True, text=True, timeout=180, ) assert completed.returncode == 0, completed.stderr return json.loads(completed.stdout) def test_multiword_utterance_with_interword_pause_is_complete(probe_results): scenario = probe_results["multiword_utterance_with_interword_pause"] assert scenario["sends"] == ["alpha bravo"] assert scenario["state"] == "thinking" def test_thinking_pause_after_first_word_does_not_split(probe_results): """A >1100ms pause right after the first word stays inside one utterance.""" scenario = probe_results["thinking_pause_after_first_word_does_not_split"] assert scenario["sends"] == ["alpha bravo"] # The pause froze a first-word EOS snapshot on the server; resumed speech # must have cleared it before the commit. events = scenario["serverEvents"][0] assert "resume-clear" in events or "recover-by-rms" in events def test_barge_in_stitches_regardless_of_partial_assistant_output(probe_results): scenario = probe_results["second_utterance_during_response_is_complete"] assert scenario["firstSends"] == ["alpha"] # Assistant text was already visible (and being spoken) when the user # talked over the response: the interrupted thought and the follow-up form # one stitched message, and the cut marker names the sentence that was # playing so the model knows where its reply stopped being heard. assert scenario["sends"] == [ "alpha", "alpha bravo charlie\n[voice interruption: you were cut off after " '"Partial answer already visible."]', ] def test_normal_completion_clears_stitch_and_mic_stays_hot(probe_results): scenario = probe_results["back_to_back_utterances_stay_hot"] assert scenario["sendsAfterFirst"] == ["alpha"] assert scenario["sends"] == ["alpha", "bravo"] # The whole session runs on one getUserMedia lease. assert scenario["micAcquisitions"] == 1 def test_conversation_overlay_lifecycle(probe_results): scenario = probe_results["conversation_overlay_lifecycle"] assert scenario["overlayPresent"] is True assert scenario["role"] == "dialog" assert scenario["ariaModal"] == "true" assert scenario["captionsLive"] == "polite" assert scenario["listeningState"] == "listening" assert scenario["thinkingState"] in {"thinking", "speaking"} assert scenario["userCaption"] == "alpha" assert scenario["assistantCaption"] == "A visible answer." # Mute drives the real capture track and is fully reversible. assert scenario["mutedPressed"] == "true" assert scenario["mutedTracks"] == [False] assert scenario["unmutedTracks"] == [True] # Both exit paths remove the overlay completely and end hands-free mode. assert scenario["removedOnExit"] is True assert scenario["inactiveAfterExit"] is True assert scenario["removedOnEscape"] is True def test_overlay_source_contract(): source = VOICE_SCRIPT.read_text(encoding="utf-8") css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text( encoding="utf-8" ) # Lazily created, fully removed, storage-free, single-capture overlay. assert "function openConversationOverlay()" in source assert "function removeConversationOverlay()" in source assert source.count("navigator.mediaDevices.getUserMedia(") == 1 overlay_region = source.split("Conversation mode overlay", 1)[1].split( "function clearErrorTimer", 1 )[0] assert "localStorage" not in overlay_region assert "track.enabled=!conversation.muted" in overlay_region assert "'aria-label':'Voice conversation'" in overlay_region assert "'aria-live':'polite'" in overlay_region assert "event.key==='Escape'" in overlay_region assert "event.key==='Tab'" in overlay_region # Orb styling: breathing, level-driven, playback-driven, reduced-motion. assert ".voice-conversation" in css assert "--conversation-level" in css assert "voice-conversation-breathe" in css assert ".voice-conversation.is-playing .voice-conversation-orb-halo" in css reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1] assert ".voice-conversation *" in reduced def test_endpointing_and_streaming_hardening_source_contract(): source = VOICE_SCRIPT.read_text(encoding="utf-8") # Young utterances hold their endpoint past a thinking pause — unless the # streaming partial already reads as a plausibly complete utterance # (>=3 words or terminal punctuation), which endpoints at the base window. assert "const VAD_EARLY_SILENCE_MS=1800" in source assert "const VAD_COMMITTED_SPEECH_MS=1200" in source assert "latestPartial:function(){return lastPartialText;}" in source assert "const partialComplete=partialWords>=3||(partialWords>0" in source assert ( "const endpointSilenceMs=(speechMs=SERVER_EOS_SILENCE_MS" in source assert "if(settled||committed||!speculative) return;" not in source.split( "resume:function()", 1 )[1].split("speculate:function()", 1)[0] # No speculative Whisper pass on a one-word fragment. assert "const SPECULATE_MIN_SPEECH_MS=700" in source assert "speechMs>=SPECULATE_MIN_SPEECH_MS" in source # Echo residue can no longer ratchet the onset threshold above speech. assert "Math.min(rms,speechThreshold)*0.06" in source def test_stitch_gate_ignores_partial_assistant_output(): source = VOICE_SCRIPT.read_text(encoding="utf-8") region = source.split("function recordPendingStitch()", 1)[1].split( "async function sendTranscript", 1 )[0] assert "currentAssistantText()" not in region assert "pendingStitch={text:sent.text,at:Date.now(),cut:cut}" in region # Normal completion still clears the stitch context. assert ( "if(isFinal){\n // The completion callback marks a normally " "completed response" in source ) def test_three_word_partial_endpoints_at_base_silence(probe_results): scenario = probe_results["three_word_partial_endpoints_at_base_silence"] assert scenario["sends"] == ["alpha bravo charlie"] assert scenario["state"] == "thinking" def test_two_word_young_utterance_keeps_the_hold(probe_results): scenario = probe_results["two_word_young_utterance_keeps_the_hold"] assert scenario["sendsEarly"] == [] assert scenario["sends"] == ["alpha bravo"] def test_errored_turn_is_never_spoken_and_capture_resyncs(probe_results): scenario = probe_results["errored_turn_is_not_spoken_and_capture_resyncs"] assert scenario["stateAfterError"] == "listening" assert scenario["labelAfterError"] == "Let’s try that again — listening" assert scenario["ttsDuringError"] == 0 assert scenario["freshSessions"] >= 1 # The follow-up utterance is sent alone: no stitch with the errored turn. assert scenario["sends"] == ["alpha", "bravo"] def test_barge_cut_marker_is_one_bounded_line(probe_results): scenario = probe_results["barge_cut_marker_records_spoken_tail"] assert scenario["ttsTexts"][0] == "The first point is ready." assert scenario["sends"][1] == ( "alpha bravo\n[voice interruption: you were cut off after " '"The first point is ready."]' ) marker = scenario["sends"][1].split("\n", 1)[1] assert "\n" not in marker def test_sticky_language_biases_next_stt_session(probe_results): scenario = probe_results["sticky_language_biases_next_stt_session"] assert scenario["startLanguages"][0] == "auto" assert "en" in scenario["startLanguages"] def test_cut_marker_error_resync_and_speaking_cycles_source_contract(): source = VOICE_SCRIPT.read_text(encoding="utf-8") # Cut marker: exactly one appended line, tail bounded to 120 characters. assert "function voiceCutMarker(cut)" in source assert "tail.length>120?tail.slice(tail.length-120):tail" in source assert '\\n[voice interruption: you were cut off after "' in source # Error envelopes are detected structurally and trigger a capture resync. assert "function handleAssistantResponseError(token)" in source assert "function resyncCapture(token,statusLabel)" in source assert "Let’s try that again — listening" in source assert "segment.dataset.error==='1'" in source assert ".provider-error-details" in source # A cancellation with no in-flight stream never gates the send. assert "if(!cancellation.streamId){" in source # Speaking ⇄ thinking cycles inside one interim-message turn. assert "function scheduleSpeakingIdleFallback(turn,session)" in source assert "if(state==='speaking') setState('thinking')" in source assert "function playbackAudible()" in source # Body-only scraping: never the avatar letter, author name or status chip. assert "querySelectorAll('.msg-body')" in source assert "function readSegmentBody(segment)" in source assert "function readAssistantTurn(turn)" in source def test_caption_regions_are_bounded_scrollable_and_follow_tail(): source = VOICE_SCRIPT.read_text(encoding="utf-8") css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text( encoding="utf-8" ) assert "function updateCaptionRegion(element,text,limit)" in source assert "function attachCaptionScroll(element)" in source assert "element.dataset.follow=gap<=24?'1':'0'" in source for token in ( "overflow-y: auto", "overscroll-behavior: contain", "-webkit-overflow-scrolling: touch", "touch-action: pan-y", "max-height: 22vh", "max-height: 38vh", "mask-image", ): assert token in css def test_reply_language_stickiness_source_contract(): source = VOICE_SCRIPT.read_text(encoding="utf-8") assert "let sessionLanguage=''" in source # A user-forced conversation-mode language (FIX 3) overrides the sticky # auto-detected hint for the streaming STT session. assert "language:forcedLanguage||sessionLanguage||'auto'" in source assert "function strongReplyLanguage(text)" in source assert "function detectReplyLanguage(text)" in source assert "scheduleThinkingCues(token,language||sessionLanguage,thinkingTurnId)" in source # The sticky language resets with each hands-free session. assert source.count("sessionLanguage='';") >= 2 def test_conversation_language_override_forces_stt_and_voice(probe_results): """FIX 3: picking Russian in the overlay forces BOTH the streaming STT hint and the reply TTS voice for the session (overriding auto-detection), and Auto releases the override.""" scenario = probe_results["language_override_forces_stt_and_voice"] assert scenario["overlayPresent"] is True assert scenario["ruItemPresent"] is True # Selection state is reflected accessibly (menuitemradio aria-checked). assert scenario["forcedChecked"] == "true" assert scenario["autoChecked"] == "false" assert scenario["btnForced"] is True # Every STT session opened after the override carries the forced hint, and an # ENGLISH reply is still spoken by the Russian voice — auto-detection is # fully overridden. assert scenario["startLanguages"][1:] == ["ru", "ru", "ru"] assert scenario["ttsLanguages"] == ["ru", "ru"] # Auto releases the override. assert scenario["releasedChecked"] == "true" assert scenario["btnForcedAfterAuto"] is False def test_conversation_language_selector_source_and_style_contract(): """FIX 3: the corner language control is present, accessible, session-only (no localStorage), and lists Auto + the server voice-map languages.""" source = VOICE_SCRIPT.read_text(encoding="utf-8") css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8") assert "let forcedLanguage=''" in source assert "const CONVERSATION_LANGUAGES=[" in source # Auto + en/es/ru mirror hermes-jetson-tts-server.py LANGUAGE_VOICE_MAP. for code in ("{code:'',label:'Auto'", "code:'en'", "code:'es'", "code:'ru'"): assert code in source assert "function selectConversationLanguage(code)" in source assert "aria-haspopup" in source and "'aria-expanded':'false'" in source assert "role:'menuitemradio'" in source # Forced language overrides both STT (start hint) and the reply voice. assert "language:forcedLanguage||sessionLanguage||'auto'" in source assert "const resolved=forcedLanguage||strongReplyLanguage(text)" in source # Session-only: the forced language never touches localStorage. assert "localStorage" not in source.split("CONVERSATION_LANGUAGES", 1)[1].split("function openConversationOverlay", 1)[0] # Escape peels the menu before exiting; the language button joins the trap. assert "if(conversation.langMenu&&!conversation.langMenu.hidden){closeLanguageMenu(true);return;}" in source assert "[conversation.langBtn,conversation.muteBtn,conversation.exitBtn]" in source for token in (".voice-conversation-lang-btn", ".voice-conversation-lang-menu", "menuitemradio"): pass # menu roles live in JS; assert CSS hooks below for token in (".voice-conversation-lang", ".voice-conversation-lang-btn", ".voice-conversation-lang-menu", ".voice-conversation-lang-item"): assert token in css def test_conversation_orb_hermes_mark_source_and_style_contract(): """FIX 2: the Hermes caduceus mark is embedded in the orb as a static, low-opacity watermark that respects reduced motion and never animates.""" source = VOICE_SCRIPT.read_text(encoding="utf-8") css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8") assert "const HERMES_MARK_SVG=" in source assert 'fill-rule="evenodd"' in source # the real favicon caduceus path assert "'voice-conversation-orb-mark'" in source assert "orbMark.innerHTML=HERMES_MARK_SVG" in source assert ".voice-conversation-orb-mark" in css # Monochrome via currentColor at low opacity; carries no animation of its own. assert "color: rgba(233, 244, 255, 0.9)" in css mark_rule = css.split(".voice-conversation-orb-mark {", 1)[1].split("}", 1)[0] assert "animation" not in mark_rule assert "opacity: 0.16" in mark_rule def test_conversation_thinking_working_affordance_contract(): """FIX 4: a delayed 'working…' affordance during silent Thinking, CSS-driven and reduced-motion aware, with no fabricated spoken acknowledgement.""" source = VOICE_SCRIPT.read_text(encoding="utf-8") css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8") assert "const AWAITING_AFFORDANCE_MS=2500" in source assert "function updateAwaitingAffordance(next)" in source assert "conversation.root.classList.add('is-awaiting')" in source # The affordance disarms the instant any reply text arrives. assert "if(text&&String(text).trim()) clearAwaitingAffordance();" in source assert ".voice-conversation.is-awaiting" in css # Reduced motion suppresses every overlay animation, including this one. rm_block = css.split("@media (prefers-reduced-motion: reduce)", 1)[1] assert "animation: none !important" in rm_block