2026-08-21 06:43:18 +00:00
// Natural turn-taking for chat.bstein.dev using the private Jetsons.
2026-08-10 00:42:37 -03:00
( function ( ) {
'use strict' ;
const modeBtn = document . getElementById ( 'btnVoiceMode' ) ;
const bar = document . getElementById ( 'voiceModeBar' ) ;
const indicator = document . getElementById ( 'voiceModeIndicator' ) ;
const label = document . getElementById ( 'voiceModeLabel' ) ;
const composer = document . getElementById ( 'msg' ) ;
if ( ! modeBtn || ! bar || ! indicator || ! label || ! composer || ! navigator . mediaDevices || ! window . MediaRecorder ) return ;
let ready = false ;
let active = false ;
let state = 'idle' ;
let generation = 0 ;
2026-08-23 18:29:02 -03:00
let turnSequence = 0 ;
2026-08-10 00:42:37 -03:00
let recorder = null ;
let stream = null ;
let audioContext = null ;
2026-08-23 18:29:02 -03:00
let captureNode = null ;
2026-08-10 00:42:37 -03:00
let vadTimer = null ;
2026-08-23 18:29:02 -03:00
let responsePollTimer = null ;
2026-08-10 00:42:37 -03:00
let currentAudio = null ;
2026-08-23 18:29:02 -03:00
let playbackSession = null ;
2026-08-23 22:13:52 -03:00
let bargeMonitor = null ;
let bargeCancelPromise = null ;
let suppressAutoRead = false ;
let thinkingCue = null ;
2026-08-23 18:29:02 -03:00
let streamingStt = null ;
let captureTurnId = '' ;
let thinkingTurnId = '' ;
2026-08-10 00:42:37 -03:00
let thinkingSession = null ;
2026-08-23 18:29:02 -03:00
let assistantBaseline = null ;
let speechTurn = null ;
2026-08-20 23:13:51 +00:00
let errorTimer = null ;
let visualInputLevel = 0 ;
2026-08-23 22:13:52 -03:00
let streamingCapability = { tts : null , stt : null , preflight : null } ;
let voicePreflight = null ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// Unified spoken-output sink. Every spoken sound — the reply's streaming PCM,
// the thinking-cue PCM, and the WAV blob fallback — plays through this ONE
// shared AudioContext (and, for the blob element, the same chosen sinkId), so
// the reply and its fillers can never land on different hardware outputs
// (the earpiece-vs-loudspeaker split). The context is created lazily on first
// playback, persists across cues/segments/turns, and is closed only when the
// hands-free session ends. selectedOutputSinkId is the user-chosen audio
// output device (session-only; '' means the system default).
let sharedPlaybackContext = null ;
let sharedPlaybackWorklet = null ;
let selectedOutputSinkId = '' ;
let outputDevices = [ ] ;
2026-08-24 20:24:31 -03:00
// Whether the user has explicitly picked an output device this session. Until
// they do, the first device enumeration auto-defaults the shared sink to the
// loudspeaker (never the OS "communications"/earpiece route the browser picks
// while the mic is open); an explicit choice is never overridden afterwards.
let outputSinkUserChosen = false ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// Continuous-capture state. The microphone stream and its AudioContext stay
// hot for the whole hands-free session; each utterance is one "capture turn"
// guarded by captureGeneration so a response-side barge (which bumps
// `generation`) never interrupts the running microphone pipeline.
let captureGeneration = 0 ;
let captureActive = false ;
let captureGraph = null ;
// Barge-in stitching: transcript whose model response was cancelled before
// any visible assistant output, plus the transcript most recently sent.
let pendingStitch = null ;
let lastSentTranscript = null ;
2026-08-23 22:13:52 -03:00
const voiceTabNonce = ( function ( ) {
try {
const bytes = new Uint8Array ( 16 ) ;
window . crypto . getRandomValues ( bytes ) ;
return Array . from ( bytes , function ( value ) { return value . toString ( 16 ) . padStart ( 2 , '0' ) ; } ) . join ( '' ) ;
} catch ( _ ) { return '' ; }
} ) ( ) ;
2026-08-20 23:13:51 +00:00
const reducedMotion = window . matchMedia ? window . matchMedia ( '(prefers-reduced-motion: reduce)' ) : { matches : false } ;
2026-08-24 20:24:31 -03:00
// FIX 1: the conversation orb's centred watermark is the Hermes CHARACTER
// glyph — the 512px character art, feathered to a circle and luminance-weighted
// to a warm near-white, inlined as a data URI and painted by
2026-08-24 20:36:51 -03:00
// .voice-conversation-orb-mark in atlas-voice.css cropped into the orb by a feathered circle mask (no box; normal blend)
2026-08-24 20:24:31 -03:00
// so the face features glow over the dark orb. A low-opacity, non-animating
// (reduced-motion safe) layer that scales with the orb and reads across every
// state tint. No separate static asset is served for it.
2026-08-20 23:13:51 +00:00
const ERROR _VISIBLE _MS = 3200 ;
2026-08-23 18:29:02 -03:00
const STREAMING _CAPABILITY _URL = '/api/voice/streaming/capability' ;
const TTS _STREAM _URL = '/api/tts/stream' ;
const STT _STREAM _PATH = '/api/transcribe/stream' ;
2026-08-23 22:13:52 -03:00
const VOICE _PREFLIGHT _URL = '/api/voice/route-preflight' ;
const VOICE _PREFLIGHT _DEBOUNCE _MS = 200 ;
const VOICE _PREFLIGHT _DEADLINE _MS = 1100 ;
const VOICE _PREFLIGHT _TIERS = [ 'fast' , 'balanced' , 'deep' , 'maximum' ] ;
2026-08-23 18:29:02 -03:00
const WORKLET _URL = 'static/atlas-voice-worklet.js' ;
2026-08-23 22:13:52 -03:00
const STT _MAX _QUEUED _BYTES = 1048576 ;
const STT _MAX _ARCHIVE _BYTES = 2880000 ;
const BARGE _LOOKBACK _MS = 900 ;
const BARGE _DUCK _FRAMES = 2 ;
const BARGE _TRIGGER _FRAMES = 4 ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// A response cancelled by barge-in leaves the user mid-thought: the next
// finalized utterance inside this window is sent as one stitched message.
const STITCH _WINDOW _MS = 20000 ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
// Endpointing. A short utterance ("The…") is usually a sentence still being
// formed, so while less than VAD_COMMITTED_SPEECH_MS of voiced audio has
// been collected the endpoint silence window stretches to
// VAD_EARLY_SILENCE_MS: a thinking pause right after the first word can no
// longer commit a one-word fragment of the sentence.
const VAD _EARLY _SILENCE _MS = 1800 ;
const VAD _COMMITTED _SPEECH _MS = 1200 ;
// The private STT server freezes a speculative end-of-speech snapshot after
// 650ms of server-side silence even when this client never sent speculate.
// Speech resuming after a gap that long always sends an explicit resume so
// a frozen first-word snapshot can never survive into the commit.
const SERVER _EOS _SILENCE _MS = 650 ;
// Never speculate on the first inter-word gap of a young utterance: a
// wasted Whisper pass over one word occupies the Jetson for seconds and
// stalls the real commit that follows (observed live as multi-second
// commit pending waits).
const SPECULATE _MIN _SPEECH _MS = 700 ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
const TTS _SPEED _DEFAULT = 1.15 ;
const TTS _SPEED _MIN = 0.5 ;
const TTS _SPEED _MAX = 2 ;
2026-08-23 22:13:52 -03:00
const THINKING _CUE _FIRST _MS = 1900 ;
const THINKING _CUE _INTERVAL _MS = 6500 ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// Natural spoken fillers first (Umm/Hmm/One sec and their es/ru equivalents),
// then the longer reassurances — one short filler per genuine >~1.9s thinking
// gap (THINKING_CUE_FIRST_MS), the answer always preempting via
// cancelThinkingCues, rotated deterministically per turn so they never repeat
// back-to-back, spoken through the SAME unified sink as the reply.
2026-08-23 22:13:52 -03:00
const THINKING _CUE _POOLS = {
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
en : [ { id : 'umm' , text : 'Umm.' } , { id : 'hmm' , text : 'Hmm.' } , { id : 'one_sec' , text : 'One sec.' } , { id : 'thinking' , text : "I'm thinking." } , { id : 'let_me_think' , text : 'Let me think.' } , { id : 'still_working' , text : 'Still working on that.' } , { id : 'one_more_moment' , text : 'One more moment.' } ] ,
ru : [ { id : 'hmm' , text : 'Хм.' } , { id : 'sec' , text : 'Секунду.' } , { id : 'minute' , text : 'Минутку.' } , { id : 'thinking' , text : 'Я думаю.' } , { id : 'let_me_think' , text : 'Дайте подумать.' } , { id : 'still_working' , text : 'Я всё ещё думаю над этим.' } , { id : 'one_more_moment' , text : 'Ещё мгновение.' } ] ,
es : [ { id : 'mmm' , text : 'Mmm.' } , { id : 'a_ver' , text : 'A ver.' } , { id : 'un_momento' , text : 'Un momento.' } , { id : 'thinking' , text : 'Estoy pensando.' } , { id : 'let_me_think' , text : 'Déjame pensar.' } , { id : 'still_working' , text : 'Sigo pensando en eso.' } , { id : 'one_more_moment' , text : 'Un momento más.' } ] ,
2026-08-23 22:13:52 -03:00
} ;
2026-08-20 23:13:51 +00:00
const STATE _LABELS = {
listening : 'Listening' ,
transcribing : 'Transcribing…' ,
thinking : 'Thinking…' ,
speaking : 'Speaking' ,
error : 'Voice unavailable' ,
idle : '' ,
} ;
2026-08-21 11:52:49 +00:00
// The only language signal this file trusts is the one the private Whisper
// service returned for the audio of the turn currently being answered. It is
// bound to that turn's generation token and consumed exactly once.
let sttLanguage = '' ;
let sttLanguageToken = - 1 ;
2026-08-24 13:58:35 -03:00
// Sticky per-hands-free-session language: set by the private Whisper
// detection of the user's own speech (or a strong reply-text signal on a
// detection-less turn), consumed as the NEXT streaming STT session's bias
// and as the thinking-cue locale. Cleared on activate/deactivate.
let sessionLanguage = '' ;
2026-08-24 15:46:09 -03:00
// Forced conversation-mode language (FIX 3): when non-empty it overrides both
// the streaming STT language hint and the reply-TTS voice, superseding
// auto-detection until the user picks Auto again. Session-scoped; no storage.
let forcedLanguage = '' ;
// Bounded retry budget for the completion (isFinal) pump: the STREAM_DONE
// callback can beat the settle re-render that stamps the answer onto
// data-raw-text, so a transient empty read must never tear a live reply down.
let finalizeAttempts = 0 ;
hermes(voice): auto-retry transient provider errors; prime STT acronyms
Diagnosed from a live voice session (e1b9fccb90ef): three turns failed with
raw '**Error:** HTTP 502 ... hermes-{claude,codex}-broker' because the agent
pod hosting the model brokers rolled mid-conversation. The voice client
correctly refused to speak the error envelope, but it then dropped the user's
utterance and forced them to repeat it three times.
Voice: on a TRANSIENT provider error (5xx/502/'error sending request'/timeout),
conversation mode now re-runs the errored turn in place through the app's own
regenerate action (which truncates the errored turn — no duplicate user
message) and stays in Thinking so its cues cover the reconnect gap. Bounded to
MAX_TRANSIENT_RETRIES (2); a non-transient error or an exhausted budget still
drops cleanly to 'let's try that again — listening'. The raw error is never
spoken. New probe scenarios cover retry-then-recover and the bounded-then-drop
path; source contract updated.
STT: the same session mis-transcribed 'CUI' as 'cue'. Prime the default
initial_prompt with the domain acronyms the user uses (CUI, FOUO, DoD, NIST,
CMMC, FIPS, RMF, POA&M, ATO, SBU) so they bias to uppercase forms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 22:10:13 -03:00
// Bounded auto-retry for a TRANSIENT provider error (a broker 5xx/502 or
// "error sending request" reply — e.g. a model broker that blipped while its
// pod rolled mid-conversation). Instead of silently dropping the user's
// utterance and forcing them to repeat it, re-run the last user turn through
// the app's own regenerate path (which truncates the errored turn, so no
// duplicate user message), capped so a persistent failure still surfaces.
// Reset on any real answer and on each new user utterance.
let transientRetryCount = 0 ;
const MAX _TRANSIENT _RETRIES = 2 ;
const RECONNECT _CAPTION = { en : 'Reconnecting…' , ru : 'Переподключение…' , es : 'Reconectando…' } ;
const TRANSIENT _ERROR _RE = /\b(429|50[0-9])\b|error sending request|bad gateway|gateway timeout|timed? ?out|timeout|temporarily|overloaded|unavailable|connection (refused|reset|error)|reset by peer|upstream/i ;
2026-08-21 11:52:49 +00:00
const TTS _LANGUAGES = [ 'en' , 'ru' , 'es' ] ;
2026-08-24 15:46:09 -03:00
// FIX 3: conversation-mode language chooser. Auto (default) plus the languages
// the private voice map supports — kept in lockstep with
// dockerfiles/hermes-jetson-tts-server.py LANGUAGE_VOICE_MAP (en → amy,
// es → claude, ru → irina). Selecting one FORCES both the reply TTS voice and
// the streaming STT language hint for the rest of the hands-free session (via
// forcedLanguage), overriding auto-detection until Auto is chosen again.
const CONVERSATION _LANGUAGES = [
{ code : '' , label : 'Auto' , sublabel : 'Detect' } ,
{ code : 'en' , label : 'English' , sublabel : 'English' } ,
{ code : 'es' , label : 'Español' , sublabel : 'Spanish' } ,
{ code : 'ru' , label : 'Русский' , sublabel : 'Russian' } ,
] ;
const GLOBE _ICON _SVG = '<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false"><circle cx="10" cy="10" r="7.25"/><path d="M2.9 10h14.2M10 2.75c1.9 2 2.9 4.6 2.9 7.25S11.9 15.25 10 17.25C8.1 15.25 7.1 12.65 7.1 10S8.1 4.75 10 2.75z"/></svg>' ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
const SPEAKER _ICON _SVG = '<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 7.5h2.5L10.5 4v12L6.5 12.5H4z"/><path d="M13.2 7.4a3.5 3.5 0 0 1 0 5.2M15.4 5.3a6.5 6.5 0 0 1 0 9.4"/></svg>' ;
2026-08-10 00:42:37 -03:00
const originalAutoRead = window . autoReadLastAssistant ;
const originalApplyPreference = window . _applyVoiceModePref ;
2026-08-21 11:52:49 +00:00
function normalizeSttLanguage ( value ) {
if ( typeof value !== 'string' ) return '' ;
const code = value . trim ( ) . toLowerCase ( ) ;
return TTS _LANGUAGES . indexOf ( code ) >= 0 ? code : '' ;
}
function clearSttLanguage ( ) {
sttLanguage = '' ;
sttLanguageToken = - 1 ;
}
2026-08-23 18:29:02 -03:00
function rememberSttLanguage ( language , token ) {
2026-08-21 11:52:49 +00:00
sttLanguage = language || '' ;
sttLanguageToken = sttLanguage ? token : - 1 ;
}
function takeSttLanguage ( token ) {
const language = sttLanguageToken === token ? sttLanguage : '' ;
clearSttLanguage ( ) ;
return language ;
}
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
// Inverted punctuation is exclusive to Spanish and decisive on its own.
const SPANISH _UNIQUE = /[¡¿]/ ;
// Accented vowels and ñ also occur in English loanwords and European place
// names (Zürich, Málaga, Genève, jalapeño), so they signal Spanish only
// alongside real Spanish stopword density — never on their own.
const SPANISH _ACCENTS = /[áéíóúüñ]/gi ;
2026-08-24 13:58:35 -03:00
const SPANISH _STOPWORDS = /\b(?:el|la|los|las|un|una|es|está|qué|para|por|con|pero|como|más|sí|gracias|hola|puedo|también|muy|este|esta|todo|bien)\b/g ;
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
const CYRILLIC _CHARS = /[Ѐ-ӿ]/g ;
const WORD _LETTERS = /[A-Za-zÀ-ÿЀ-ӿ]/g ;
2026-08-24 13:58:35 -03:00
function strongReplyLanguage ( text ) {
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
// Decisive script/orthography evidence in the REPLY text only. This is
// ranked ABOVE the trusted STT detection, so it must never fire on
// incidental foreign glyphs: a lone accented European place name, or a
// single stray Cyrillic letter inside otherwise-English prose, keeps the
// English (Amy) voice. Russian needs Cyrillic-letter density; Spanish needs
// inverted punctuation, or accent/ñ density corroborated by Spanish
// stopword density. (Root cause of the "English reply about Europe spoken in
// a foreign voice" bug: the old rule returned 'es' for a single accented
// char and 'ru' for a single Cyrillic char, overriding a correct 'en' STT.)
2026-08-24 13:58:35 -03:00
const sample = String ( text || '' ) . slice ( 0 , 400 ) ;
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
const letters = ( sample . match ( WORD _LETTERS ) || [ ] ) . length ;
const cyrillic = ( sample . match ( CYRILLIC _CHARS ) || [ ] ) . length ;
if ( cyrillic >= 4 && letters > 0 && cyrillic / letters >= 0.5 ) return 'ru' ;
if ( SPANISH _UNIQUE . test ( sample ) ) return 'es' ;
const accents = ( sample . match ( SPANISH _ACCENTS ) || [ ] ) . length ;
if ( accents >= 2 ) {
const lower = sample . toLowerCase ( ) ;
const words = lower . split ( /\s+/ ) . filter ( Boolean ) ;
const stops = ( lower . match ( SPANISH _STOPWORDS ) || [ ] ) . length ;
if ( words . length >= 4 && stops >= 2 && stops / words . length >= 0.12 ) return 'es' ;
}
2026-08-24 13:58:35 -03:00
return '' ;
}
function detectReplyLanguage ( text ) {
// Lightweight reply-language heuristic for turns without a trusted STT
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
// detection: decisive script/orthography evidence first, then accent-free
// Spanish stopword density (an accent-free Spanish sentence still routes to
// the Spanish voice). Returns '' for English/unknown, which the private TTS
// service resolves to its own English default voice.
2026-08-24 13:58:35 -03:00
const strong = strongReplyLanguage ( text ) ;
if ( strong ) return strong ;
const sample = String ( text || '' ) . slice ( 0 , 400 ) . toLowerCase ( ) ;
const words = sample . split ( /\s+/ ) . filter ( Boolean ) ;
if ( words . length >= 4 ) {
const matches = ( sample . match ( SPANISH _STOPWORDS ) || [ ] ) . length ;
if ( matches >= 2 && matches / words . length >= 0.12 ) return 'es' ;
}
return '' ;
}
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
function resolveReplyLanguage ( text , sttLanguage , forced ) {
// The single source of truth for which Piper voice reads a reply. A user
// force wins outright; otherwise decisive reply-text evidence corrects a
// wrong or missing STT detection; otherwise the turn's trusted STT-detected
// language is spoken; otherwise the accent-free stopword heuristic. Plain
// English prose — including a reply full of European proper nouns — carries
// no decisive evidence and matches no STT hint of its own, so it can never be
// flipped off the English voice by a previous turn's sticky value: only a
// real force or real reply-text evidence moves it.
if ( forced ) return forced ;
const strong = strongReplyLanguage ( text ) ;
if ( strong ) return strong ;
const stt = normalizeSttLanguage ( sttLanguage ) ;
if ( stt ) return stt ;
return detectReplyLanguage ( text ) || '' ;
}
2026-08-10 00:42:37 -03:00
function toast ( message ) {
if ( typeof window . showToast === 'function' ) window . showToast ( message , 3000 ) ;
}
2026-08-23 18:29:02 -03:00
function setState ( next , customLabel ) {
2026-08-10 00:42:37 -03:00
state = next ;
indicator . className = 'voice-mode-indicator ' + next ;
2026-08-20 23:13:51 +00:00
bar . dataset . voiceState = next ;
bar . setAttribute ( 'aria-busy' , next === 'transcribing' || next === 'thinking' ? 'true' : 'false' ) ;
label . textContent = customLabel || STATE _LABELS [ next ] || '' ;
bar . style . display = ( active && next !== 'idle' ) || next === 'error' ? '' : 'none' ;
resetInputLevel ( ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
syncConversationOverlay ( next , customLabel ) ;
2026-08-20 23:13:51 +00:00
}
function resetInputLevel ( ) {
visualInputLevel = 0 ;
indicator . style . setProperty ( '--voice-ripple-scale' , '1.035' ) ;
indicator . style . setProperty ( '--voice-ripple-opacity' , '0.3' ) ;
}
function updateInputLevel ( rms ) {
if ( reducedMotion . matches || state !== 'listening' ) return ;
const target = Math . max ( 0 , Math . min ( 1 , ( rms - 0.01 ) / 0.18 ) ) ;
visualInputLevel = ( visualInputLevel * 0.72 ) + ( target * 0.28 ) ;
indicator . style . setProperty ( '--voice-ripple-scale' , ( 1.035 + ( visualInputLevel * 0.16 ) ) . toFixed ( 3 ) ) ;
indicator . style . setProperty ( '--voice-ripple-opacity' , ( 0.26 + ( visualInputLevel * 0.48 ) ) . toFixed ( 3 ) ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
if ( conversation ) conversation . root . style . setProperty ( '--conversation-level' , visualInputLevel . toFixed ( 3 ) ) ;
}
// ── Conversation mode overlay ─────────────────────────────────────────
// Hands-free is a full-screen conversation: a breathing orb that follows
// microphone energy while listening and TTS playback while speaking, the
// live state caption, streaming user/assistant captions, and mute/exit
// controls. The overlay is created lazily on activation, removed completely
// on deactivate, keeps no storage, and is silently skipped in environments
// without a usable DOM (headless contract probes).
let conversation = null ;
2026-08-24 15:46:09 -03:00
// FIX 4: when the model has been thinking for a beat with no reply text yet,
// the overlay shows a subtle animated "working…" affordance so the dead time
// reads as alive rather than frozen. Purely a client progress hint — it never
// fabricates spoken acknowledgements (that is the model's job).
let awaitingTimer = null ;
const AWAITING _AFFORDANCE _MS = 2500 ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
function conversationUsable ( ) {
return ! ! ( typeof document !== 'undefined' && document && document . body && typeof document . createElement === 'function' ) ;
}
2026-08-24 20:24:31 -03:00
function conversationWarn ( message , error ) {
try { if ( window . console && window . console . warn ) window . console . warn ( '[atlas-voice] ' + message , error ) ; } catch ( _ ) { }
}
2026-08-24 15:46:09 -03:00
function clearAwaitingAffordance ( ) {
if ( awaitingTimer ) { window . clearTimeout ( awaitingTimer ) ; awaitingTimer = null ; }
if ( conversation && conversation . root . classList ) conversation . root . classList . remove ( 'is-awaiting' ) ;
}
function updateAwaitingAffordance ( next ) {
// Arm the affordance only while Thinking and only after the grace window;
// any other state, or the first scrap of reply text, disarms it.
clearAwaitingAffordance ( ) ;
if ( ! conversation || next !== 'thinking' ) return ;
awaitingTimer = window . setTimeout ( function ( ) {
awaitingTimer = null ;
if ( conversation && conversation . root . dataset . voiceState === 'thinking' && conversation . root . classList ) {
conversation . root . classList . add ( 'is-awaiting' ) ;
}
} , AWAITING _AFFORDANCE _MS ) ;
}
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
function conversationNode ( tag , className , attributes ) {
const node = document . createElement ( tag ) ;
if ( className ) node . className = className ;
if ( attributes ) Object . keys ( attributes ) . forEach ( function ( name ) { node . setAttribute ( name , attributes [ name ] ) ; } ) ;
return node ;
}
function syncConversationOverlay ( next , customLabel ) {
if ( ! conversation ) return ;
conversation . root . dataset . voiceState = next ;
conversation . stateEl . textContent = customLabel || STATE _LABELS [ next ] || '' ;
2026-08-24 15:46:09 -03:00
updateAwaitingAffordance ( next ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
}
2026-08-24 13:58:35 -03:00
function updateCaptionRegion ( element , text , limit ) {
// Captions are bounded scrollable regions: keep following the streaming
// tail unless the user scrolled up inside this region (data-follow='0',
// maintained by the scroll listener installed at overlay build time).
const value = String ( text || '' ) . slice ( - limit ) ;
element . textContent = value ;
if ( ! value && element . dataset ) element . dataset . follow = '1' ;
if ( ( ! element . dataset || element . dataset . follow !== '0' ) && typeof element . scrollHeight === 'number' ) {
try { element . scrollTop = element . scrollHeight ; } catch ( _ ) { }
}
}
function attachCaptionScroll ( element ) {
if ( ! element . addEventListener ) return ;
element . addEventListener ( 'scroll' , function ( ) {
const gap = ( element . scrollHeight || 0 ) - ( element . scrollTop || 0 ) - ( element . clientHeight || 0 ) ;
if ( element . dataset ) element . dataset . follow = gap <= 24 ? '1' : '0' ;
} ) ;
}
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
function setConversationUserCaption ( text ) {
2026-08-24 13:58:35 -03:00
if ( conversation ) updateCaptionRegion ( conversation . userCaption , text , 4000 ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
}
function setConversationAssistantCaption ( text ) {
2026-08-24 15:46:09 -03:00
if ( ! conversation ) return ;
// The first scrap of reply text means the model is no longer silently
// thinking: disarm the "working…" affordance immediately (FIX 4).
if ( text && String ( text ) . trim ( ) ) clearAwaitingAffordance ( ) ;
updateCaptionRegion ( conversation . assistantCaption , text , 9000 ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
}
function setConversationPlaying ( playing ) {
if ( ! conversation ) return ;
if ( playing ) conversation . root . classList . add ( 'is-playing' ) ;
else conversation . root . classList . remove ( 'is-playing' ) ;
}
function applyConversationMute ( ) {
// True microphone mute on the session's single retained getUserMedia
// stream: disabled tracks deliver silence to the recorder, the VAD and
// the streaming STT worklet alike. No second capture is ever opened.
if ( ! conversation ) return ;
try {
const tracks = stream && stream . getAudioTracks ? stream . getAudioTracks ( ) : [ ] ;
tracks . forEach ( function ( track ) { track . enabled = ! conversation . muted ; } ) ;
} catch ( _ ) { }
}
function setConversationMuted ( muted ) {
if ( ! conversation ) return ;
conversation . muted = muted ;
applyConversationMute ( ) ;
try {
conversation . muteBtn . setAttribute ( 'aria-pressed' , muted ? 'true' : 'false' ) ;
conversation . muteBtn . textContent = muted ? 'Unmute microphone' : 'Mute microphone' ;
if ( muted ) conversation . root . classList . add ( 'is-muted' ) ;
else conversation . root . classList . remove ( 'is-muted' ) ;
} catch ( _ ) { }
}
function conversationKeydown ( event ) {
if ( ! conversation ) return ;
if ( event . key === 'Escape' ) {
if ( event . preventDefault ) event . preventDefault ( ) ;
2026-08-24 15:46:09 -03:00
// Escape peels one layer at a time: an open language menu closes first
// (focus returns to its button), and only a second Escape exits the mode.
if ( conversation . langMenu && ! conversation . langMenu . hidden ) { closeLanguageMenu ( true ) ; return ; }
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
if ( conversation . outMenu && ! conversation . outMenu . hidden ) { closeOutputMenu ( true ) ; return ; }
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
deactivate ( true ) ;
return ;
}
if ( event . key === 'Tab' ) {
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// Minimal focus trap across the overlay controls (language, output, mute,
// exit); the output button only joins the trap once it is actually shown.
const outVisible = conversation . outWrap && conversation . outWrap . style && conversation . outWrap . style . display !== 'none' ;
const stops = [ conversation . langBtn , outVisible ? conversation . outBtn : null , conversation . muteBtn , conversation . exitBtn ] . filter ( Boolean ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
const current = stops . indexOf ( document . activeElement ) ;
const index = current < 0 ? ( event . shiftKey ? 0 : stops . length - 1 ) : current ;
const next = stops [ ( index + ( event . shiftKey ? stops . length - 1 : 1 ) ) % stops . length ] ;
if ( event . preventDefault ) event . preventDefault ( ) ;
if ( next && next . focus ) next . focus ( ) ;
}
}
2026-08-24 15:46:09 -03:00
function closeLanguageMenu ( focusButton ) {
if ( ! conversation || ! conversation . langMenu ) return ;
conversation . langMenu . hidden = true ;
conversation . langMenu . setAttribute ( 'hidden' , '' ) ;
if ( conversation . langBtn ) {
conversation . langBtn . setAttribute ( 'aria-expanded' , 'false' ) ;
if ( focusButton && conversation . langBtn . focus ) conversation . langBtn . focus ( ) ;
}
}
function openLanguageMenu ( ) {
if ( ! conversation || ! conversation . langMenu ) return ;
conversation . langMenu . hidden = false ;
conversation . langMenu . removeAttribute ( 'hidden' ) ;
if ( conversation . langBtn ) conversation . langBtn . setAttribute ( 'aria-expanded' , 'true' ) ;
// Focus the currently selected option so keyboard users land on it.
const items = conversation . langItems || [ ] ;
const active = items . filter ( function ( item ) { return item . getAttribute ( 'aria-checked' ) === 'true' ; } ) [ 0 ] || items [ 0 ] ;
if ( active && active . focus ) active . focus ( ) ;
}
function toggleLanguageMenu ( ) {
if ( ! conversation || ! conversation . langMenu ) return ;
if ( conversation . langMenu . hidden ) openLanguageMenu ( ) ; else closeLanguageMenu ( true ) ;
}
function reflectLanguageSelection ( ) {
if ( ! conversation || ! conversation . langItems ) return ;
conversation . langItems . forEach ( function ( item ) {
const selected = ( item . getAttribute ( 'data-lang' ) || '' ) === forcedLanguage ;
item . setAttribute ( 'aria-checked' , selected ? 'true' : 'false' ) ;
} ) ;
if ( conversation . langBtn ) {
const current = CONVERSATION _LANGUAGES . filter ( function ( entry ) { return entry . code === forcedLanguage ; } ) [ 0 ] ;
const label = current ? current . label : 'Auto' ;
conversation . langBtn . setAttribute ( 'aria-label' , 'Conversation language: ' + label ) ;
conversation . langBtn . setAttribute ( 'title' , 'Language — ' + label ) ;
if ( conversation . langBtn . classList ) {
if ( forcedLanguage ) conversation . langBtn . classList . add ( 'is-forced' ) ;
else conversation . langBtn . classList . remove ( 'is-forced' ) ;
}
}
}
function selectConversationLanguage ( code ) {
// Force (or, for '', release back to auto-detection) the session language.
forcedLanguage = code || '' ;
reflectLanguageSelection ( ) ;
// Re-bias the open streaming STT session immediately when it is safe — i.e.
// it has heard nothing yet — so the forced hint applies to the very next
// utterance instead of only the one after it. Mirrors the auto-switch path.
if ( active && captureActive && streamingStt && typeof streamingStt . latestPartial === 'function' && ! streamingStt . latestPartial ( ) ) {
startListening ( generation , { preserveDisplay : true } ) ;
}
}
2026-08-24 20:24:31 -03:00
// Never throws: on any failure it returns an inert, hidden control object whose
// .wrap is still a safe, appendable element, so the overlay can attach without
// this enhancement. langBtn/langMenu stay null and every menu helper no-ops.
function inertControl ( className ) {
let wrap ;
try { wrap = conversationNode ( 'div' , className ) ; wrap . style . display = 'none' ; }
catch ( _ ) { wrap = null ; }
return { wrap : wrap , btn : null , menu : null , items : [ ] } ;
}
2026-08-24 15:46:09 -03:00
function buildLanguageControl ( ) {
2026-08-24 20:24:31 -03:00
try {
const wrap = conversationNode ( 'div' , 'voice-conversation-lang' ) ;
const btn = conversationNode ( 'button' , 'voice-conversation-lang-btn' , {
type : 'button' , 'aria-haspopup' : 'menu' , 'aria-expanded' : 'false' ,
'aria-label' : 'Conversation language: Auto' , title : 'Language — Auto' ,
2026-08-24 15:46:09 -03:00
} ) ;
2026-08-24 20:24:31 -03:00
btn . innerHTML = GLOBE _ICON _SVG ;
const menu = conversationNode ( 'div' , 'voice-conversation-lang-menu' , { role : 'menu' , 'aria-label' : 'Conversation language' , hidden : '' } ) ;
menu . hidden = true ;
const items = CONVERSATION _LANGUAGES . map ( function ( entry ) {
const item = conversationNode ( 'button' , 'voice-conversation-lang-item' , {
type : 'button' , role : 'menuitemradio' , 'data-lang' : entry . code ,
'aria-checked' : entry . code === forcedLanguage ? 'true' : 'false' ,
} ) ;
item . textContent = entry . label ;
item . addEventListener ( 'click' , function ( ) {
selectConversationLanguage ( entry . code ) ;
closeLanguageMenu ( true ) ;
} ) ;
menu . appendChild ( item ) ;
return item ;
2026-08-24 15:46:09 -03:00
} ) ;
2026-08-24 20:24:31 -03:00
btn . addEventListener ( 'click' , function ( event ) {
if ( event && event . stopPropagation ) event . stopPropagation ( ) ;
toggleLanguageMenu ( ) ;
} ) ;
wrap . appendChild ( btn ) ;
wrap . appendChild ( menu ) ;
return { wrap : wrap , btn : btn , menu : menu , items : items } ;
} catch ( error ) {
conversationWarn ( 'language control unavailable' , error ) ;
return inertControl ( 'voice-conversation-lang' ) ;
}
2026-08-24 15:46:09 -03:00
}
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// ── Output-device selector ────────────────────────────────────────────
// A tidy corner control, styled like the language chooser, to pick which audio
// output device the voice plays through. Feature-detected (enumerateDevices +
// setSinkId); the whole control stays hidden when routing is unsupported or no
// output devices are enumerable. Session-only — it never touches storage.
function closeOutputMenu ( focusButton ) {
if ( ! conversation || ! conversation . outMenu ) return ;
conversation . outMenu . hidden = true ;
conversation . outMenu . setAttribute ( 'hidden' , '' ) ;
if ( conversation . outBtn ) {
conversation . outBtn . setAttribute ( 'aria-expanded' , 'false' ) ;
if ( focusButton && conversation . outBtn . focus ) conversation . outBtn . focus ( ) ;
}
}
function openOutputMenu ( ) {
if ( ! conversation || ! conversation . outMenu ) return ;
conversation . outMenu . hidden = false ;
conversation . outMenu . removeAttribute ( 'hidden' ) ;
if ( conversation . outBtn ) conversation . outBtn . setAttribute ( 'aria-expanded' , 'true' ) ;
const items = conversation . outItems || [ ] ;
const active = items . filter ( function ( item ) { return item . getAttribute ( 'aria-checked' ) === 'true' ; } ) [ 0 ] || items [ 0 ] ;
if ( active && active . focus ) active . focus ( ) ;
}
function toggleOutputMenu ( ) {
if ( ! conversation || ! conversation . outMenu ) return ;
if ( conversation . outMenu . hidden ) openOutputMenu ( ) ; else closeOutputMenu ( true ) ;
}
function outputDeviceLabel ( device , index ) {
const label = device && device . label ? String ( device . label ) : '' ;
if ( label ) return label ;
return 'Output ' + ( index + 1 ) ;
}
function reflectOutputSelection ( ) {
if ( ! conversation || ! conversation . outItems ) return ;
conversation . outItems . forEach ( function ( item ) {
const selected = ( item . getAttribute ( 'data-device' ) || '' ) === selectedOutputSinkId ;
item . setAttribute ( 'aria-checked' , selected ? 'true' : 'false' ) ;
} ) ;
if ( conversation . outBtn ) {
const chosen = outputDevices . filter ( function ( device ) { return device . deviceId === selectedOutputSinkId ; } ) [ 0 ] ;
const label = selectedOutputSinkId && chosen ? outputDeviceLabel ( chosen , 0 ) : 'System default' ;
conversation . outBtn . setAttribute ( 'aria-label' , 'Audio output: ' + label ) ;
conversation . outBtn . setAttribute ( 'title' , 'Output — ' + label ) ;
if ( conversation . outBtn . classList ) {
if ( selectedOutputSinkId ) conversation . outBtn . classList . add ( 'is-forced' ) ;
else conversation . outBtn . classList . remove ( 'is-forced' ) ;
}
}
}
function selectOutputDevice ( deviceId ) {
2026-08-24 20:24:31 -03:00
// An explicit user choice: it is honoured for the rest of the session and is
// never overridden by the auto-default-to-loudspeaker on later refreshes.
outputSinkUserChosen = true ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
selectedOutputSinkId = deviceId || '' ;
reflectOutputSelection ( ) ;
applyOutputSink ( ) ;
}
2026-08-24 20:24:31 -03:00
function pickLoudspeakerSink ( devices ) {
// FIX 2(a): choose the actual LOUDSPEAKER so hands-free playback does not
// follow the OS "communication" route — while getUserMedia holds the mic
// open the browser/OS puts audio in communication mode and the system
// default endpoint becomes the EARPIECE. We therefore bias to an explicit
// speaker deviceId rather than leaving the sink on the system/communications
// default. Never target the "communications" pseudo-endpoint (that IS the
// earpiece route). Prefer a labelled speaker; else the first concrete,
// non-earpiece output; else '' (fall back to the system default).
const outs = ( devices || [ ] ) . filter ( function ( device ) {
return device && device . kind === 'audiooutput' && device . deviceId && device . deviceId !== 'communications' ;
} ) ;
const speaker = /(speaker|speakerphone|loud)/i ;
const earpiece = /(earpiece|receiver|handset|headset|headphone|earbud|bluetooth|communication)/i ;
const named = outs . filter ( function ( device ) {
const label = device . label || '' ; return speaker . test ( label ) && ! earpiece . test ( label ) ;
} ) [ 0 ] ;
if ( named ) return named . deviceId ;
const concrete = outs . filter ( function ( device ) {
return device . deviceId !== 'default' && ! earpiece . test ( device . label || '' ) ;
} ) [ 0 ] ;
return concrete ? concrete . deviceId : '' ;
}
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
async function refreshOutputDevices ( ) {
if ( ! conversation || ! conversation . outMenu || ! conversation . outWrap ) return ;
if ( ! outputRoutingSupported ( ) ) { conversation . outWrap . style . display = 'none' ; return ; }
let devices = [ ] ;
try { devices = await navigator . mediaDevices . enumerateDevices ( ) ; } catch ( _ ) { devices = [ ] ; }
// Real, routable outputs only: the empty-deviceId "default" device the
// browser reports is represented by our own "System default" entry.
outputDevices = ( devices || [ ] ) . filter ( function ( device ) { return device && device . kind === 'audiooutput' && device . deviceId ; } ) ;
if ( ! conversation || ! conversation . outMenu ) return ;
2026-08-24 20:24:31 -03:00
// Auto-default the shared sink to the loudspeaker on first enumeration so
// hands-free speech never plays out the earpiece. Only when the user has not
// made an explicit choice and nothing is selected yet — a user choice wins.
if ( ! outputSinkUserChosen && ! selectedOutputSinkId ) {
const speaker = pickLoudspeakerSink ( devices ) ;
if ( speaker ) { selectedOutputSinkId = speaker ; applyOutputSink ( ) ; }
}
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// Rebuild the menu: a "System default" entry plus every routable output.
conversation . outMenu . children = [ ] ;
const entries = [ { deviceId : '' , label : 'System default' } ] . concat (
outputDevices . map ( function ( device , index ) { return { deviceId : device . deviceId , label : outputDeviceLabel ( device , index ) } ; } )
) ;
conversation . outItems = entries . map ( function ( entry ) {
const item = conversationNode ( 'button' , 'voice-conversation-out-item' , {
type : 'button' , role : 'menuitemradio' , 'data-device' : entry . deviceId ,
'aria-checked' : entry . deviceId === selectedOutputSinkId ? 'true' : 'false' ,
} ) ;
item . textContent = entry . label ;
item . addEventListener ( 'click' , function ( ) {
selectOutputDevice ( entry . deviceId ) ;
closeOutputMenu ( true ) ;
} ) ;
conversation . outMenu . appendChild ( item ) ;
return item ;
} ) ;
// Only the default entry means nothing is really selectable — hide it.
conversation . outWrap . style . display = outputDevices . length ? '' : 'none' ;
reflectOutputSelection ( ) ;
}
2026-08-24 20:24:31 -03:00
// Never throws (see buildLanguageControl): device access is done later, and
// asynchronously, in refreshOutputDevices — buildOutputControl only assembles
// the inert corner button, hidden until routable outputs are confirmed.
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
function buildOutputControl ( ) {
2026-08-24 20:24:31 -03:00
try {
const wrap = conversationNode ( 'div' , 'voice-conversation-out' ) ;
// Hidden until refreshOutputDevices confirms real, routable outputs exist.
wrap . style . display = 'none' ;
const btn = conversationNode ( 'button' , 'voice-conversation-out-btn' , {
type : 'button' , 'aria-haspopup' : 'menu' , 'aria-expanded' : 'false' ,
'aria-label' : 'Audio output: System default' , title : 'Output — System default' ,
} ) ;
btn . innerHTML = SPEAKER _ICON _SVG ;
const menu = conversationNode ( 'div' , 'voice-conversation-out-menu' , { role : 'menu' , 'aria-label' : 'Audio output device' , hidden : '' } ) ;
menu . hidden = true ;
btn . addEventListener ( 'click' , function ( event ) {
if ( event && event . stopPropagation ) event . stopPropagation ( ) ;
toggleOutputMenu ( ) ;
} ) ;
wrap . appendChild ( btn ) ;
wrap . appendChild ( menu ) ;
return { wrap : wrap , btn : btn , menu : menu } ;
} catch ( error ) {
conversationWarn ( 'output control unavailable' , error ) ;
return inertControl ( 'voice-conversation-out' ) ;
}
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
}
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
function openConversationOverlay ( ) {
if ( conversation || ! conversationUsable ( ) ) return ;
2026-08-24 20:24:31 -03:00
let root = null ;
// ── Phase 1: the ESSENTIAL overlay (orb + captions + mute/exit). This is the
// full-screen visualization the user relies on; it MUST attach whenever
// conversation mode activates. The corner language/output selectors are
// enhancements added in phase 2 — a failure there can never keep this from
// rendering (which would drop us back to the inline voice bar). Only a
// failure to build this core is fatal, and is the sole reason for fallback.
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
try {
2026-08-24 20:24:31 -03:00
root = conversationNode ( 'div' , 'voice-conversation' , {
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
role : 'dialog' , 'aria-modal' : 'true' , 'aria-label' : 'Voice conversation' , tabindex : '-1' ,
} ) ;
const orb = conversationNode ( 'div' , 'voice-conversation-orb' , { 'aria-hidden' : 'true' } ) ;
orb . appendChild ( conversationNode ( 'span' , 'voice-conversation-orb-halo' ) ) ;
orb . appendChild ( conversationNode ( 'span' , 'voice-conversation-orb-core' ) ) ;
orb . appendChild ( conversationNode ( 'span' , 'voice-conversation-orb-ring' ) ) ;
2026-08-24 20:24:31 -03:00
// FIX 1: the Hermes character glyph watermark, centred in the orb beneath
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
// the energy layers. It is a presentation-only span; atlas-voice.css paints
2026-08-24 20:24:31 -03:00
// the inlined, circle-feathered character data URI into it (screen blend).
2026-08-24 15:46:09 -03:00
const orbMark = conversationNode ( 'span' , 'voice-conversation-orb-mark' , { 'aria-hidden' : 'true' } ) ;
orb . appendChild ( orbMark ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
const stateEl = conversationNode ( 'div' , 'voice-conversation-state' , { 'aria-hidden' : 'true' } ) ;
const captions = conversationNode ( 'div' , 'voice-conversation-captions' , { 'aria-live' : 'polite' } ) ;
2026-08-24 13:58:35 -03:00
// Each caption is its own bounded, independently scrollable region
// (keyboard focusable, touch scrollable); streaming keeps following the
// tail until the user scrolls up inside that region.
const userCaption = conversationNode ( 'p' , 'voice-conversation-caption-user' , { tabindex : '0' } ) ;
const assistantCaption = conversationNode ( 'p' , 'voice-conversation-caption-assistant' , { tabindex : '0' } ) ;
attachCaptionScroll ( userCaption ) ;
attachCaptionScroll ( assistantCaption ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
captions . appendChild ( userCaption ) ;
captions . appendChild ( assistantCaption ) ;
const controls = conversationNode ( 'div' , 'voice-conversation-controls' ) ;
const muteBtn = conversationNode ( 'button' , 'voice-conversation-mute' , { type : 'button' , 'aria-pressed' : 'false' } ) ;
muteBtn . textContent = 'Mute microphone' ;
const exitBtn = conversationNode ( 'button' , 'voice-conversation-exit' , { type : 'button' } ) ;
exitBtn . textContent = 'Exit voice mode' ;
controls . appendChild ( muteBtn ) ;
controls . appendChild ( exitBtn ) ;
root . appendChild ( orb ) ;
root . appendChild ( stateEl ) ;
root . appendChild ( captions ) ;
root . appendChild ( controls ) ;
muteBtn . addEventListener ( 'click' , function ( ) { if ( conversation ) setConversationMuted ( ! conversation . muted ) ; } ) ;
exitBtn . addEventListener ( 'click' , function ( ) { deactivate ( true ) ; } ) ;
root . addEventListener ( 'keydown' , conversationKeydown ) ;
2026-08-24 20:24:31 -03:00
// A tap anywhere outside a menu closes it (never deactivates).
2026-08-24 15:46:09 -03:00
root . addEventListener ( 'click' , function ( event ) {
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
if ( ! conversation ) return ;
2026-08-24 15:46:09 -03:00
const target = event && event . target ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
const closest = target && typeof target . closest === 'function' ? target . closest . bind ( target ) : null ;
if ( conversation . langMenu && ! conversation . langMenu . hidden && ! ( closest && closest ( '.voice-conversation-lang' ) ) ) closeLanguageMenu ( false ) ;
if ( conversation . outMenu && ! conversation . outMenu . hidden && ! ( closest && closest ( '.voice-conversation-out' ) ) ) closeOutputMenu ( false ) ;
2026-08-24 15:46:09 -03:00
} ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
document . body . appendChild ( root ) ;
2026-08-24 20:24:31 -03:00
// Selector fields start null; phase 2 fills them in if their build succeeds.
conversation = { root : root , orb : orb , stateEl : stateEl , userCaption : userCaption , assistantCaption : assistantCaption , muteBtn : muteBtn , exitBtn : exitBtn , langBtn : null , langMenu : null , langItems : [ ] , outWrap : null , outBtn : null , outMenu : null , outItems : [ ] , muted : false } ;
} catch ( error ) {
// The essential overlay could not even be built/attached: fall back to the
// compact voice bar as a last resort, leaving nothing half-attached.
conversationWarn ( 'conversation overlay unavailable' , error ) ;
if ( root && root . parentNode ) { try { root . parentNode . removeChild ( root ) ; } catch ( _e ) { } }
conversation = null ;
return ;
}
// ── Phase 2: OPTIONAL corner selectors. Each is built and wired under its own
// guard so a synchronous failure (e.g. a mobile browser where mediaDevices
// access throws) omits just that one control — the overlay stays up. Device
// enumeration itself is async (refreshOutputDevices), run after attachment.
try {
const lang = buildLanguageControl ( ) ;
if ( lang . wrap ) root . appendChild ( lang . wrap ) ;
conversation . langBtn = lang . btn ; conversation . langMenu = lang . menu ; conversation . langItems = lang . items || [ ] ;
2026-08-24 15:46:09 -03:00
reflectLanguageSelection ( ) ;
2026-08-24 20:24:31 -03:00
} catch ( error ) { conversationWarn ( 'language selector omitted' , error ) ; }
try {
const out = buildOutputControl ( ) ;
if ( out . wrap ) root . appendChild ( out . wrap ) ;
conversation . outWrap = out . wrap ; conversation . outBtn = out . btn ; conversation . outMenu = out . menu ;
// Async device enumeration; tolerate rejection without touching the overlay.
refreshOutputDevices ( ) . catch ( function ( error ) { conversationWarn ( 'output devices unavailable' , error ) ; } ) ;
} catch ( error ) { conversationWarn ( 'output selector omitted' , error ) ; }
try {
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
syncConversationOverlay ( state ) ;
if ( root . focus ) root . focus ( ) ;
2026-08-24 20:24:31 -03:00
} catch ( error ) { conversationWarn ( 'overlay finalize warning' , error ) ; }
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
}
function removeConversationOverlay ( ) {
2026-08-24 15:46:09 -03:00
clearAwaitingAffordance ( ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
const overlay = conversation ;
conversation = null ;
if ( ! overlay ) return ;
try {
if ( overlay . root . removeEventListener ) overlay . root . removeEventListener ( 'keydown' , conversationKeydown ) ;
if ( overlay . root . parentNode ) overlay . root . parentNode . removeChild ( overlay . root ) ;
} catch ( _ ) { }
// The session's tracks are always left enabled on the way out; a real
// deactivation stops them entirely via releaseMicrophone().
try {
const tracks = stream && stream . getAudioTracks ? stream . getAudioTracks ( ) : [ ] ;
tracks . forEach ( function ( track ) { track . enabled = true ; } ) ;
} catch ( _ ) { }
2026-08-20 23:13:51 +00:00
}
function clearErrorTimer ( ) {
if ( errorTimer ) { window . clearTimeout ( errorTimer ) ; errorTimer = null ; }
}
2026-08-23 18:29:02 -03:00
function errorMessage ( error , fallback ) {
2026-08-20 23:13:51 +00:00
return String ( ( error && error . message ) || fallback || 'Voice unavailable' ) . trim ( ) ;
}
2026-08-23 22:13:52 -03:00
function createAbortController ( ) {
const Controller = window . AbortController || ( typeof AbortController !== 'undefined' ? AbortController : null ) ;
return Controller ? new Controller ( ) : { signal : undefined , abort : function ( ) { } } ;
}
function cancelledError ( message ) {
const error = new Error ( message || 'Voice turn cancelled' ) ;
error . name = 'AbortError' ;
return error ;
}
2026-08-23 18:29:02 -03:00
function cancelSpeechTurn ( ) {
if ( ! speechTurn ) return ;
2026-08-24 13:58:35 -03:00
cancelSpeakingIdleFallback ( speechTurn ) ;
2026-08-23 18:29:02 -03:00
speechTurn . cancelled = true ;
speechTurn . final = true ;
while ( speechTurn . waiters . length ) speechTurn . waiters . shift ( ) ( null ) ;
speechTurn = null ;
}
function cancelStreamingStt ( ) {
if ( ! streamingStt ) return ;
streamingStt . cancel ( ) ;
streamingStt = null ;
}
2026-08-23 22:13:52 -03:00
function cancelVoicePreflight ( turnId ) {
const preflight = voicePreflight ;
if ( ! preflight || ( turnId && preflight . turnId !== turnId ) ) return ;
voicePreflight = null ;
if ( preflight . timer ) { window . clearTimeout ( preflight . timer ) ; preflight . timer = null ; }
if ( preflight . deadline ) { window . clearTimeout ( preflight . deadline ) ; preflight . deadline = null ; }
if ( preflight . controller ) { preflight . controller . abort ( ) ; preflight . controller = null ; }
}
function scheduleVoicePreflight ( turnId , revision , transcript ) {
const capability = streamingCapability . preflight ;
const stable = String ( transcript || '' ) . replace ( /\s+/g , ' ' ) . trim ( ) ;
if ( ! capability || ! voiceTabNonce || ! active || captureTurnId !== turnId || state !== 'listening' ) return ;
if ( ! Number . isInteger ( revision ) || revision < 1 || stable . length < 12 || stable . length > 512 ) return ;
if ( voicePreflight && voicePreflight . turnId === turnId && voicePreflight . transcript === stable ) return ;
cancelVoicePreflight ( ) ;
const preflight = { turnId : turnId , revision : revision , transcript : stable , timer : null , deadline : null , controller : null } ;
voicePreflight = preflight ;
preflight . timer = window . setTimeout ( async function ( ) {
preflight . timer = null ;
if ( voicePreflight !== preflight || ! active || captureTurnId !== turnId || state !== 'listening' ) return ;
const controller = createAbortController ( ) ;
preflight . controller = controller ;
preflight . deadline = window . setTimeout ( function ( ) { controller . abort ( ) ; } , VOICE _PREFLIGHT _DEADLINE _MS ) ;
try {
const response = await fetch ( VOICE _PREFLIGHT _URL , {
method : 'POST' ,
credentials : 'same-origin' ,
cache : 'no-store' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { turn _id : turnId , revision : revision , transcript : stable } ) ,
signal : controller . signal ,
} ) ;
if ( ! response . ok ) return ;
const advisory = await response . json ( ) ;
const tier = advisory && advisory . tier ;
if (
voicePreflight !== preflight || ! active || captureTurnId !== turnId || state !== 'listening' ||
advisory . turn _id !== turnId || advisory . revision !== revision || advisory . advisory !== true ||
VOICE _PREFLIGHT _TIERS . indexOf ( tier ) < 0 || advisory . target !== 'atlas/auto/' + tier
) return ;
// This is intentionally feedback-only. The completed transcript still
// takes the normal Switchyard path and remains the sole routing input.
const preview = stable . length > 60 ? stable . slice ( 0 , 57 ) + '…' : stable ;
label . textContent = 'Listening · ' + preview + ' · ' + tier ;
} catch ( _ ) {
// A partial can be superseded at any time; advisory failure is silent.
} finally {
if ( preflight . deadline ) { window . clearTimeout ( preflight . deadline ) ; preflight . deadline = null ; }
preflight . controller = null ;
}
} , VOICE _PREFLIGHT _DEBOUNCE _MS ) ;
}
function cancelThinkingCues ( ) {
const cue = thinkingCue ;
thinkingCue = null ;
if ( ! cue ) return ;
cue . cancelled = true ;
if ( cue . timer ) { window . clearTimeout ( cue . timer ) ; cue . timer = null ; }
if ( cue . controller ) { cue . controller . abort ( ) ; cue . controller = null ; }
if ( cue . audioWake ) { const wake = cue . audioWake ; cue . audioWake = null ; wake ( ) ; }
if ( cue . node ) { try { cue . node . port . postMessage ( { type : 'cancel' } ) ; cue . node . disconnect ( ) ; } catch ( _ ) { } cue . node = null ; }
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// The cue shares the ONE playback context; drop the reference but never
// close the shared sink here.
if ( cue . context ) { cue . context = null ; }
2026-08-23 22:13:52 -03:00
cue . gain = null ;
indicator . classList . remove ( 'is-playing' ) ;
indicator . classList . remove ( 'is-ducked' ) ;
}
function clearBargeCancellation ( ) {
const cancellation = bargeCancelPromise ;
bargeCancelPromise = null ;
if ( cancellation && cancellation . controller ) cancellation . controller . abort ( ) ;
}
function microphoneConstraints ( ) {
const constraints = { echoCancellation : true , noiseSuppression : true , autoGainControl : true } ;
const supported = navigator . mediaDevices . getSupportedConstraints ? navigator . mediaDevices . getSupportedConstraints ( ) : { } ;
if ( supported . voiceIsolation ) constraints . voiceIsolation = true ;
return constraints ;
}
function acquireMicrophone ( ) {
return navigator . mediaDevices . getUserMedia ( { audio : microphoneConstraints ( ) } ) ;
}
function captureAecIsUsable ( capture ) {
try {
const tracks = capture && capture . getAudioTracks ? capture . getAudioTracks ( ) : [ ] ;
const settings = tracks . length && tracks [ 0 ] . getSettings ? tracks [ 0 ] . getSettings ( ) : { } ;
return settings . echoCancellation !== false ;
} catch ( _ ) {
return true ;
}
}
window . _atlasCaptureAecIsUsable = captureAecIsUsable ;
function setPlaybackDucked ( ducked ) {
const session = playbackSession ;
if ( session && session . gain && session . context ) {
const gain = ducked ? 0.16 : 1 ;
try { session . gain . gain . setTargetAtTime ( gain , session . context . currentTime , 0.018 ) ; } catch ( _ ) { session . gain . gain . value = gain ; }
}
if ( currentAudio ) currentAudio . volume = ducked ? 0.16 : 1 ;
const cue = thinkingCue ;
if ( cue && cue . gain && cue . context ) {
const gain = ducked ? 0.16 : 1 ;
try { cue . gain . gain . setTargetAtTime ( gain , cue . context . currentTime , 0.018 ) ; } catch ( _ ) { cue . gain . gain . value = gain ; }
}
if ( ducked ) indicator . classList . add ( 'is-ducked' ) ; else indicator . classList . remove ( 'is-ducked' ) ;
}
2026-08-24 13:58:35 -03:00
function playbackAudible ( ) {
// True only while audio is actually sounding. A streaming PCM session
// keeps its worklet node alive across sentence chunks and between the
// speech segments of one turn; the buffered frames the worklet reports
// decide whether it is audible right now.
if ( currentAudio ) return true ;
if ( thinkingCue && thinkingCue . node ) return true ;
const session = playbackSession ;
if ( session && session . node ) return session . bufferedFrames > 0 && ! session . playbackEnded ;
return indicator . classList . contains ( 'is-playing' ) ;
}
2026-08-23 22:13:52 -03:00
function disposeBargeResources ( monitor , keepCapture ) {
if ( ! monitor ) return ;
monitor . cancelled = true ;
if ( monitor . timer ) { window . clearInterval ( monitor . timer ) ; monitor . timer = null ; }
try { if ( monitor . node ) monitor . node . disconnect ( ) ; } catch ( _ ) { }
try { if ( monitor . source ) monitor . source . disconnect ( ) ; } catch ( _ ) { }
try { if ( monitor . silentGain ) monitor . silentGain . disconnect ( ) ; } catch ( _ ) { }
if ( ! keepCapture ) {
if ( monitor . stream ) monitor . stream . getTracks ( ) . forEach ( function ( track ) { track . stop ( ) ; } ) ;
if ( monitor . context ) { try { monitor . context . close ( ) ; } catch ( _ ) { } }
}
if ( ! keepCapture ) setPlaybackDucked ( false ) ;
}
function stopBargeMonitor ( ) {
const monitor = bargeMonitor ;
bargeMonitor = null ;
disposeBargeResources ( monitor , false ) ;
}
2026-08-20 23:13:51 +00:00
function showUnavailable ( message ) {
generation += 1 ;
active = false ;
thinkingSession = null ;
2026-08-23 18:29:02 -03:00
thinkingTurnId = '' ;
2026-08-23 22:13:52 -03:00
suppressAutoRead = false ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
pendingStitch = null ;
lastSentTranscript = null ;
2026-08-23 22:13:52 -03:00
clearBargeCancellation ( ) ;
2026-08-23 18:29:02 -03:00
stopResponseObserver ( ) ;
2026-08-23 22:13:52 -03:00
cancelThinkingCues ( ) ;
2026-08-23 18:29:02 -03:00
cancelSpeechTurn ( ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
releaseMicrophone ( ) ;
2026-08-20 23:13:51 +00:00
stopPlayback ( ) ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
closeSharedPlayback ( ) ;
selectedOutputSinkId = '' ;
2026-08-24 20:24:31 -03:00
outputSinkUserChosen = false ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
outputDevices = [ ] ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
removeConversationOverlay ( ) ;
2026-08-20 23:13:51 +00:00
modeBtn . classList . remove ( 'active' ) ;
setState ( 'error' , message ) ;
clearErrorTimer ( ) ;
errorTimer = window . setTimeout ( function ( ) {
errorTimer = null ;
if ( ! active && state === 'error' && indicator . classList . contains ( 'error' ) ) setState ( 'idle' ) ;
} , ERROR _VISIBLE _MS ) ;
2026-08-10 00:42:37 -03:00
}
function stopCapture ( ) {
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// Tear down the per-utterance capture pipeline (recorder, VAD, streaming
// STT, WebAudio graph) but deliberately KEEP the microphone stream and its
// AudioContext, so the next capture turn starts instantly and speech
// during transcribing/thinking is never lost. Only releaseMicrophone()
// actually ends the session's single getUserMedia lease.
captureGeneration += 1 ;
captureActive = false ;
2026-08-23 22:13:52 -03:00
cancelVoicePreflight ( captureTurnId ) ;
stopBargeMonitor ( ) ;
2026-08-10 00:42:37 -03:00
if ( vadTimer ) { clearInterval ( vadTimer ) ; vadTimer = null ; }
if ( recorder && recorder . state !== 'inactive' ) {
try { recorder . stop ( ) ; } catch ( _ ) { }
}
recorder = null ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( captureNode ) { try { captureNode . disconnect ( ) ; } catch ( _ ) { } }
2026-08-23 18:29:02 -03:00
captureNode = null ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( captureGraph ) {
// The AudioContext persists across turns, so every per-turn node must be
// detached here or the shared graph would grow with each utterance.
try { if ( captureGraph . mediaSource ) captureGraph . mediaSource . disconnect ( ) ; } catch ( _ ) { }
try { if ( captureGraph . highpass ) captureGraph . highpass . disconnect ( ) ; } catch ( _ ) { }
try { if ( captureGraph . analyser ) captureGraph . analyser . disconnect ( ) ; } catch ( _ ) { }
try { if ( captureGraph . silentGain ) captureGraph . silentGain . disconnect ( ) ; } catch ( _ ) { }
captureGraph = null ;
}
2026-08-23 18:29:02 -03:00
cancelStreamingStt ( ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
}
function releaseMicrophone ( ) {
// Full microphone teardown. Used only by deactivate(), showUnavailable()
// and fatal capture errors; every other path retains the hot microphone.
stopCapture ( ) ;
2026-08-10 00:42:37 -03:00
if ( stream ) { stream . getTracks ( ) . forEach ( function ( track ) { track . stop ( ) ; } ) ; stream = null ; }
if ( audioContext ) { try { audioContext . close ( ) ; } catch ( _ ) { } audioContext = null ; }
}
function stopPlayback ( ) {
2026-08-23 18:29:02 -03:00
if ( currentAudio ) {
try { currentAudio . pause ( ) ; currentAudio . currentTime = 0 ; } catch ( _ ) { }
currentAudio = null ;
}
if ( playbackSession ) {
playbackSession . cancelled = true ;
if ( playbackSession . blobWake ) { playbackSession . blobWake ( ) ; playbackSession . blobWake = null ; }
playbackSession . controllers . forEach ( function ( controller ) { controller . abort ( ) ; } ) ;
if ( playbackSession . lowWaterWake ) {
playbackSession . lowWaterWake ( ) ;
playbackSession . lowWaterWake = null ;
}
if ( playbackSession . drainWake ) { playbackSession . drainWake ( ) ; playbackSession . drainWake = null ; }
if ( playbackSession . node ) {
try { playbackSession . node . port . postMessage ( { type : 'cancel' } ) ; playbackSession . node . disconnect ( ) ; } catch ( _ ) { }
}
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// The context is the shared spoken-output sink; it is closed only when
// hands-free mode ends (closeSharedPlayback), never per playback session.
playbackSession . context = null ;
2026-08-23 18:29:02 -03:00
playbackSession = null ;
}
2026-08-20 23:13:51 +00:00
indicator . classList . remove ( 'is-playing' ) ;
2026-08-23 22:13:52 -03:00
indicator . classList . remove ( 'is-ducked' ) ;
2026-08-10 00:42:37 -03:00
}
2026-08-23 18:29:02 -03:00
function stopResponseObserver ( ) {
if ( responsePollTimer ) { clearInterval ( responsePollTimer ) ; responsePollTimer = null ; }
}
2026-08-10 00:42:37 -03:00
function deactivate ( showMessage ) {
generation += 1 ;
active = false ;
thinkingSession = null ;
2026-08-23 18:29:02 -03:00
thinkingTurnId = '' ;
2026-08-20 23:13:51 +00:00
clearErrorTimer ( ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
removeConversationOverlay ( ) ;
2026-08-21 11:52:49 +00:00
clearSttLanguage ( ) ;
2026-08-24 13:58:35 -03:00
sessionLanguage = '' ;
2026-08-24 15:46:09 -03:00
forcedLanguage = '' ;
finalizeAttempts = 0 ;
2026-08-23 22:13:52 -03:00
suppressAutoRead = false ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
pendingStitch = null ;
lastSentTranscript = null ;
2026-08-23 22:13:52 -03:00
clearBargeCancellation ( ) ;
2026-08-23 18:29:02 -03:00
stopResponseObserver ( ) ;
2026-08-23 22:13:52 -03:00
cancelThinkingCues ( ) ;
2026-08-23 18:29:02 -03:00
cancelSpeechTurn ( ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
releaseMicrophone ( ) ;
2026-08-10 00:42:37 -03:00
stopPlayback ( ) ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
closeSharedPlayback ( ) ;
selectedOutputSinkId = '' ;
2026-08-24 20:24:31 -03:00
outputSinkUserChosen = false ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
outputDevices = [ ] ;
2026-08-10 00:42:37 -03:00
modeBtn . classList . remove ( 'active' ) ;
2026-08-20 23:13:51 +00:00
setState ( 'idle' ) ;
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
if ( showMessage ) toast ( 'Conversation mode off' ) ;
2026-08-10 00:42:37 -03:00
}
2026-08-23 18:29:02 -03:00
function restartSoon ( token , delay ) {
2026-08-10 00:42:37 -03:00
window . setTimeout ( function ( ) {
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( ! active || token !== generation ) return ;
// Continuous capture: while the microphone pipeline is already running,
// finishing a response only needs the display returned to Listening.
// Rebuilding capture here would drop speech already being collected.
if ( captureActive ) { setState ( 'listening' ) ; return ; }
startListening ( token ) ;
2026-08-10 00:42:37 -03:00
} , delay || 500 ) ;
}
2026-08-24 13:58:35 -03:00
function resyncCapture ( token , statusLabel ) {
// Full capture-turn resync: cancel any streaming STT session, drop the
// lookback/pending buffers and start a fresh capture turn on the hot
// microphone. Used after an errored turn and by barge-in edge cases
// where the previous stream state is no longer trustworthy.
if ( ! active || token !== generation ) return ;
stopCapture ( ) ;
startListening ( token ) ;
setState ( 'listening' , statusLabel ) ;
}
hermes(voice): auto-retry transient provider errors; prime STT acronyms
Diagnosed from a live voice session (e1b9fccb90ef): three turns failed with
raw '**Error:** HTTP 502 ... hermes-{claude,codex}-broker' because the agent
pod hosting the model brokers rolled mid-conversation. The voice client
correctly refused to speak the error envelope, but it then dropped the user's
utterance and forced them to repeat it three times.
Voice: on a TRANSIENT provider error (5xx/502/'error sending request'/timeout),
conversation mode now re-runs the errored turn in place through the app's own
regenerate action (which truncates the errored turn — no duplicate user
message) and stays in Thinking so its cues cover the reconnect gap. Bounded to
MAX_TRANSIENT_RETRIES (2); a non-transient error or an exhausted budget still
drops cleanly to 'let's try that again — listening'. The raw error is never
spoken. New probe scenarios cover retry-then-recover and the bounded-then-drop
path; source contract updated.
STT: the same session mis-transcribed 'CUI' as 'cue'. Prime the default
initial_prompt with the domain acronyms the user uses (CUI, FOUO, DoD, NIST,
CMMC, FIPS, RMF, POA&M, ATO, SBU) so they bias to uppercase forms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 22:10:13 -03:00
function errorTurnText ( turn ) {
// The human-visible error text of an errored assistant turn: the provider
// details block the renderer appends for a provider failure, else the body.
if ( ! turn || typeof turn . querySelector !== 'function' ) return '' ;
let txt = '' ;
try {
const details = turn . querySelector ( '.provider-error-details' ) ;
if ( details && typeof details . textContent === 'string' ) txt = details . textContent ;
if ( ! txt ) {
const body = turn . querySelector ( '.msg-body' ) ;
if ( body && typeof body . textContent === 'string' ) txt = body . textContent ;
}
} catch ( _ ) { }
return txt || '' ;
}
function errorTurnIsTransient ( turn ) {
// A broker/provider blip (5xx/502, "error sending request", timeout,
// overloaded) is recoverable by re-running the same turn; a content or
// policy error is not, and must never be retried into an identical failure.
return TRANSIENT _ERROR _RE . test ( errorTurnText ( turn ) ) ;
}
function findRegenerateButton ( turn ) {
// The app renders a single "regenerate" action on the last assistant turn
// (onclick="regenerateResponse(this)"): clicking it truncates the errored
// turn and re-runs the last user message with no duplicate user turn. Match
// the action buttons by class and filter by the onclick target so this stays
// correct without relying on a descendant/substring attribute selector.
if ( ! turn || typeof turn . querySelectorAll !== 'function' ) return null ;
let buttons ;
try { buttons = turn . querySelectorAll ( '.msg-action-btn' ) ; } catch ( _ ) { return null ; }
if ( ! buttons ) return null ;
for ( let i = 0 ; i < buttons . length ; i += 1 ) {
const button = buttons [ i ] ;
const onclick = ( button && typeof button . getAttribute === 'function' ) ? ( button . getAttribute ( 'onclick' ) || '' ) : '' ;
if ( onclick . indexOf ( 'regenerateResponse' ) >= 0 ) return button ;
}
return null ;
}
function retryTransientResponse ( token , turn ) {
// Re-run the errored user turn through the app's own regenerate path and
// keep the conversation in Thinking — its cues cover the reconnect gap — so
// the retried answer streams straight into TTS. Bounded by
// MAX_TRANSIENT_RETRIES; a busy session or a missing regenerate control
// declines the retry so the caller drops cleanly. Returns true iff launched.
if ( typeof S === 'undefined' || ! S || ! S . session || S . busy ) return false ;
if ( transientRetryCount >= MAX _TRANSIENT _RETRIES ) return false ;
const button = findRegenerateButton ( turn ) ;
if ( ! button ) return false ;
transientRetryCount += 1 ;
// Tear down only the response-side speech/observer state; do NOT resync
// capture (that would drop the turn and re-open the microphone).
thinkingSession = null ;
thinkingTurnId = '' ;
finalizeAttempts = 0 ;
stopResponseObserver ( ) ;
cancelThinkingCues ( ) ;
cancelSpeechTurn ( ) ;
stopPlayback ( ) ;
pendingStitch = null ;
const localized = normalizeSttLanguage ( sessionLanguage ) || 'en' ;
setConversationAssistantCaption ( RECONNECT _CAPTION [ localized ] || RECONNECT _CAPTION . en ) ;
setState ( 'thinking' ) ;
// Reset the answer baseline to the errored turn so the regenerated answer
// (which replaces it) is detected as fresh output, then arm the observer
// and the thinking cues before triggering the app's regenerate.
rememberAssistantBaseline ( ) ;
thinkingTurnId = String ( token ) + '-retry-' + String ( transientRetryCount ) ;
startResponseObserver ( token ) ;
scheduleThinkingCues ( token , sessionLanguage , thinkingTurnId ) ;
try { button . click ( ) ; } catch ( _ ) { return false ; }
return true ;
}
2026-08-24 13:58:35 -03:00
function handleAssistantResponseError ( token ) {
// An error/system envelope (cancellation notice, provider failure) is a
// transcript artifact, not a reply: never feed it to TTS or the reply
hermes(voice): auto-retry transient provider errors; prime STT acronyms
Diagnosed from a live voice session (e1b9fccb90ef): three turns failed with
raw '**Error:** HTTP 502 ... hermes-{claude,codex}-broker' because the agent
pod hosting the model brokers rolled mid-conversation. The voice client
correctly refused to speak the error envelope, but it then dropped the user's
utterance and forced them to repeat it three times.
Voice: on a TRANSIENT provider error (5xx/502/'error sending request'/timeout),
conversation mode now re-runs the errored turn in place through the app's own
regenerate action (which truncates the errored turn — no duplicate user
message) and stays in Thinking so its cues cover the reconnect gap. Bounded to
MAX_TRANSIENT_RETRIES (2); a non-transient error or an exhausted budget still
drops cleanly to 'let's try that again — listening'. The raw error is never
spoken. New probe scenarios cover retry-then-recover and the bounded-then-drop
path; source contract updated.
STT: the same session mis-transcribed 'CUI' as 'cue'. Prime the default
initial_prompt with the domain acronyms the user uses (CUI, FOUO, DoD, NIST,
CMMC, FIPS, RMF, POA&M, ATO, SBU) so they bias to uppercase forms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 22:10:13 -03:00
// caption. A TRANSIENT provider blip (a broker 5xx/502) is auto-retried in
// place — up to MAX_TRANSIENT_RETRIES — so a momentary backend hiccup does
// not silently discard the user's utterance and force them to repeat it. A
// non-transient error, or an exhausted retry budget, shows a brief non-
// spoken state and resynchronizes capture for the next utterance.
const rows = assistantRows ( ) ;
const turn = rows . length ? assistantTurnOf ( rows [ rows . length - 1 ] ) : null ;
if ( turn && errorTurnIsTransient ( turn ) && retryTransientResponse ( token , turn ) ) return ;
transientRetryCount = 0 ;
2026-08-24 13:58:35 -03:00
thinkingSession = null ;
thinkingTurnId = '' ;
2026-08-24 15:46:09 -03:00
finalizeAttempts = 0 ;
2026-08-24 13:58:35 -03:00
clearSttLanguage ( ) ;
stopResponseObserver ( ) ;
cancelThinkingCues ( ) ;
cancelSpeechTurn ( ) ;
stopPlayback ( ) ;
pendingStitch = null ;
lastSentTranscript = null ;
clearBargeCancellation ( ) ;
setConversationAssistantCaption ( '' ) ;
2026-08-24 16:18:48 -03:00
resyncCapture ( token , 'Let’ s try that again — listening' ) ;
2026-08-24 13:58:35 -03:00
}
2026-08-23 18:29:02 -03:00
function assistantRows ( ) {
return document . querySelectorAll ( '.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]' ) ;
}
2026-08-24 13:58:35 -03:00
function assistantTurnOf ( row ) {
// Resolve a matched node to its whole turn container so multi-segment
// turns (interim messages around tool calls) are read as one response.
if ( row && typeof row . closest === 'function' ) {
const turn = row . closest ( '.msg-row[data-role="assistant"]' ) ;
if ( turn ) return turn ;
}
return row ;
}
function segmentIsHidden ( segment ) {
if ( ! segment ) return true ;
if ( segment . hidden === true ) return true ;
return ! ! ( typeof segment . getAttribute === 'function' && segment . getAttribute ( 'aria-hidden' ) === 'true' ) ;
}
function segmentIsError ( segment ) {
// Error/system envelopes: the renderer stamps data-error="1" on segments
// matching its error patterns, and provider errors and cancellation
// notices carry a .provider-error-details block inside the body.
if ( ! segment ) return false ;
if ( segment . dataset && segment . dataset . error === '1' ) return true ;
return ! ! ( typeof segment . querySelector === 'function' && segment . querySelector ( '.provider-error-details' ) ) ;
}
2026-08-24 15:46:09 -03:00
// Ground-truth DOM contract (verified against the live build-24 bundle,
// ui.js renderMessages / messages.js ensureAssistantRow):
//
// <div class="msg-row assistant-turn" data-role="assistant">
// <div class="msg-role assistant">
// <div class="role-icon assistant">H</div> ← avatar letter
// <span class="msg-role-name">Hermes</span> ← author name
// </div>
// <div class="assistant-turn-blocks">
// <div class="tool-worklog-group">… "Processed 13s" …</div> ← worklog chip
// <div class="assistant-segment assistant-segment-worklog-source"
// hidden aria-hidden="true" data-raw-text="…">…</div> ← folded interim
// <div class="assistant-segment" data-raw-text="ANSWER">
// <div class="thinking-card">…</div> ← reasoning (optional)
// <div class="msg-body">ANSWER</div> ← the spoken answer
// </div>
// </div>
// </div>
//
// The settled renderer stamps the CLEAN answer text (no avatar, no author, no
// "Processed Ns" chip, no reasoning) onto every answer segment's data-raw-text
// — the exact field the app's own autoReadLastAssistant() reads (ui.js:~8891).
// Reasoning uses .thinking-card-body, tool activity uses .tool-call-group-body
// and the worklog "Processed Ns" chip lives in the worklog group beside the
// segments — so .msg-body is answer-only, and the concatenated data-raw-text of
// the visible answer segments is the whole reply. The prior round's fallback to
// turn.textContent scraped the avatar "H" + "Hermes" + "Processed 13s" whenever
// a settle-frame race left no readable answer segment; that fallback is gone.
const NON _ANSWER _SEGMENT _CLASSES = [ 'assistant-segment-worklog-source' , 'assistant-segment-anchor' ] ;
// A .msg-body is the spoken answer ONLY when it is not nested inside reasoning,
// tool, worklog, run-status, error or role chrome. The live renderer uses
// distinct classes for each, but this guard keeps chrome out of the caption and
// TTS even if a future build nests a body differently.
const CHROME _CONTAINER _SELECTOR = '.thinking-card,.agent-activity-thinking,.tool-call-group,.tool-worklog-group,.tool-group,.tool-card,.wl-reason,.process-wakeup-card,.provider-error-details,.msg-role,.assistant-run-status,.assistant-segment-worklog-source' ;
function segmentHasClass ( segment , name ) {
if ( ! segment ) return false ;
if ( segment . classList && typeof segment . classList . contains === 'function' ) return segment . classList . contains ( name ) ;
return typeof segment . className === 'string' && segment . className . split ( /\s+/ ) . indexOf ( name ) >= 0 ;
}
function segmentIsAnswer ( segment ) {
// A visible answer segment: not folded into the worklog, not an anchor
// scaffold, not hidden, not an error/system envelope.
if ( ! segment ) return false ;
if ( segmentIsHidden ( segment ) ) return false ;
if ( segmentIsError ( segment ) ) return false ;
for ( let i = 0 ; i < NON _ANSWER _SEGMENT _CLASSES . length ; i += 1 ) {
if ( segmentHasClass ( segment , NON _ANSWER _SEGMENT _CLASSES [ i ] ) ) return false ;
}
return true ;
}
function answerBodiesText ( segment ) {
// Concatenate ONLY answer .msg-body text, excluding any body that sits inside
// reasoning / tool / worklog / status chrome.
if ( ! segment || typeof segment . querySelectorAll !== 'function' ) return '' ;
const bodies = segment . querySelectorAll ( '.msg-body' ) ;
if ( ! bodies || ! bodies . length ) return '' ;
const parts = [ ] ;
Array . prototype . forEach . call ( bodies , function ( body ) {
if ( body . classList && typeof body . classList . contains === 'function' && body . classList . contains ( 'process-wakeup-body' ) ) return ;
if ( typeof body . closest === 'function' && body . closest ( CHROME _CONTAINER _SELECTOR ) ) return ;
const value = typeof body . textContent === 'string' ? body . textContent : '' ;
if ( value ) parts . push ( value ) ;
} ) ;
return parts . join ( '\n' ) ;
}
2026-08-24 13:58:35 -03:00
function readSegmentBody ( segment ) {
if ( ! segment ) return '' ;
2026-08-24 15:46:09 -03:00
// Prefer the renderer-stamped clean answer text; while a turn is still
// streaming the live segment has no data-raw-text yet, so read its answer
// .msg-body directly. Never fall through to the segment's whole textContent.
if ( segment . dataset && typeof segment . dataset . rawText === 'string' && segment . dataset . rawText !== '' ) return segment . dataset . rawText ;
return answerBodiesText ( segment ) ;
2026-08-24 13:58:35 -03:00
}
function readAssistantTurn ( turn ) {
2026-08-24 15:46:09 -03:00
// {text, error}: the concatenated human-visible ANSWER text of every visible
// answer segment of the turn — never the avatar letter, the "Hermes" author
// name, a "Processed Ns" worklog chip, reasoning or tool chrome — plus whether
// any segment is an error/system envelope. Returns '' (NEVER the row's
// textContent) when the turn has no readable answer segment, so a tool-only or
// not-yet-answered settle frame can never leak chrome into the caption or the
// one-ahead TTS synthesizer.
2026-08-24 13:58:35 -03:00
if ( ! turn ) return { text : '' , error : false } ;
let error = false ;
const parts = [ ] ;
const segments = ( typeof turn . querySelectorAll === 'function' ) ? turn . querySelectorAll ( '.assistant-segment' ) : null ;
if ( segments && segments . length ) {
Array . prototype . forEach . call ( segments , function ( segment ) {
if ( segmentIsError ( segment ) ) { error = true ; return ; }
2026-08-24 15:46:09 -03:00
if ( ! segmentIsAnswer ( segment ) ) return ;
2026-08-24 13:58:35 -03:00
const value = readSegmentBody ( segment ) ;
if ( value && value . trim ( ) ) parts . push ( value . trim ( ) ) ;
} ) ;
} else if ( segmentIsError ( turn ) ) {
error = true ;
2026-08-24 15:46:09 -03:00
} else if ( segmentIsAnswer ( turn ) ) {
// The matched node was itself a lone answer segment (a leaf's
// querySelectorAll returns nothing): read its own stamped answer text.
2026-08-24 13:58:35 -03:00
const value = readSegmentBody ( turn ) ;
if ( value && value . trim ( ) ) parts . push ( value . trim ( ) ) ;
}
2026-08-24 16:18:48 -03:00
// A stray error-stamped segment (a recovered tool error, or a
// cancellation notice from an earlier interim) must not discard a real
// answer: only surface the error state when the turn produced no
// spoken answer at all.
return { text : cleanForSpeech ( parts . join ( '\n\n' ) ) , error : error && parts . length === 0 } ;
2026-08-23 18:29:02 -03:00
}
function rememberAssistantBaseline ( ) {
const rows = assistantRows ( ) ;
2026-08-24 13:58:35 -03:00
const turn = rows . length ? assistantTurnOf ( rows [ rows . length - 1 ] ) : null ;
assistantBaseline = { row : turn , text : readAssistantTurn ( turn ) . text , count : rows . length } ;
2026-08-23 18:29:02 -03:00
}
2026-08-23 22:13:52 -03:00
async function settleBargeCancellation ( token ) {
const cancellation = bargeCancelPromise ;
if ( ! cancellation ) return true ;
2026-08-24 13:58:35 -03:00
if ( ! cancellation . streamId ) {
// Nothing was actually in flight to cancel: a stale busy flag left by
// an errored turn must never hold the send in a dead settle wait.
if ( bargeCancelPromise === cancellation ) bargeCancelPromise = null ;
return true ;
}
2026-08-23 22:13:52 -03:00
try { await cancellation . promise ; } catch ( _ ) { }
const deadline = Date . now ( ) + 10000 ;
while ( active && token === generation && Date . now ( ) < deadline ) {
const sessionId = ( typeof S !== 'undefined' && S . session ) ? S . session . session _id : '' ;
const activeStreamId = typeof S !== 'undefined' ? String ( S . activeStreamId || '' ) : '' ;
const busy = typeof S !== 'undefined' && ! ! S . busy ;
if ( sessionId !== cancellation . sessionId ) break ;
if ( ! busy && ( ! cancellation . streamId || activeStreamId !== cancellation . streamId ) ) {
if ( bargeCancelPromise === cancellation ) bargeCancelPromise = null ;
return true ;
}
await new Promise ( function ( resolve ) { window . setTimeout ( resolve , 50 ) ; } ) ;
}
if ( bargeCancelPromise === cancellation ) bargeCancelPromise = null ;
return false ;
}
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
function recordPendingStitch ( ) {
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
// Called at the moment a model turn is cancelled by user speech. The
// interrupted send left the user mid-thought regardless of how much of an
// answer had already streamed — responses start streaming almost
// immediately, so gating on visible output made stitching almost never
// fire. The next finalized utterance inside STITCH_WINDOW_MS is therefore
// always sent together with the interrupted transcript as one message.
// Normal completion, deactivation and window expiry still clear it.
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
const sent = lastSentTranscript ;
lastSentTranscript = null ;
if ( ! sent || ! sent . text ) return ;
2026-08-24 13:58:35 -03:00
// The playback queue knows exactly where the user stopped listening: the
// chunk being spoken (or the last fully spoken one) becomes the cut
// marker appended to the stitched follow-up message.
const turn = speechTurn ;
const cut = turn ? String ( turn . speakingChunk || turn . lastSpokenChunk || '' ) : '' ;
pendingStitch = { text : sent . text , at : Date . now ( ) , cut : cut } ;
}
function voiceCutMarker ( cut ) {
// Exactly one compact machine-readable line, appended to a stitched
// interruption, telling the model where its spoken reply was cut off so
// it can resume naturally instead of re-answering from the top.
const tail = String ( cut || '' ) . replace ( /\s+/g , ' ' ) . replace ( /"/g , "'" ) . trim ( ) ;
if ( ! tail ) return '' ;
const bounded = tail . length > 120 ? tail . slice ( tail . length - 120 ) : tail ;
return '\n[voice interruption: you were cut off after "' + bounded + '"]' ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
}
async function sendTranscript ( transcript , token , language , turnId ) {
2026-08-10 00:42:37 -03:00
if ( ! active || token !== generation ) return ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
cancelVoicePreflight ( turnId || captureTurnId ) ;
2026-08-10 00:42:37 -03:00
const text = String ( transcript || '' ) . trim ( ) ;
2026-08-21 11:52:49 +00:00
if ( ! text ) { clearSttLanguage ( ) ; restartSoon ( token , 350 ) ; return ; }
hermes(voice): auto-retry transient provider errors; prime STT acronyms
Diagnosed from a live voice session (e1b9fccb90ef): three turns failed with
raw '**Error:** HTTP 502 ... hermes-{claude,codex}-broker' because the agent
pod hosting the model brokers rolled mid-conversation. The voice client
correctly refused to speak the error envelope, but it then dropped the user's
utterance and forced them to repeat it three times.
Voice: on a TRANSIENT provider error (5xx/502/'error sending request'/timeout),
conversation mode now re-runs the errored turn in place through the app's own
regenerate action (which truncates the errored turn — no duplicate user
message) and stays in Thinking so its cues cover the reconnect gap. Bounded to
MAX_TRANSIENT_RETRIES (2); a non-transient error or an exhausted budget still
drops cleanly to 'let's try that again — listening'. The raw error is never
spoken. New probe scenarios cover retry-then-recover and the bounded-then-drop
path; source contract updated.
STT: the same session mis-transcribed 'CUI' as 'cue'. Prime the default
initial_prompt with the domain acronyms the user uses (CUI, FOUO, DoD, NIST,
CMMC, FIPS, RMF, POA&M, ATO, SBU) so they bias to uppercase forms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 22:10:13 -03:00
// A fresh user utterance starts its own transient-retry budget.
transientRetryCount = 0 ;
2026-08-23 22:13:52 -03:00
if ( ! bargeCancelPromise && typeof S !== 'undefined' && ( S . busy || S . activeStreamId ) ) {
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// A new utterance finished while the previous model turn was still in
// flight (continuous capture makes this a normal interruption): remember
// the interrupted transcript for stitching, then cancel the stale turn.
recordPendingStitch ( ) ;
2026-08-23 22:13:52 -03:00
suppressAutoRead = true ;
bargeCancelPromise = cancelActiveModelTurn ( ) ;
}
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// A turn whose response was barge-cancelled before any assistant output is
// restated as one stitched message, so the model answers the full thought.
const stitch = ( pendingStitch && pendingStitch . text && ( Date . now ( ) - pendingStitch . at ) <= STITCH _WINDOW _MS ) ? pendingStitch : null ;
pendingStitch = null ;
2026-08-24 13:58:35 -03:00
composer . value = stitch ? stitch . text + ' ' + text + voiceCutMarker ( stitch . cut ) : text ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( typeof window . autoResize === 'function' ) window . autoResize ( ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
setConversationUserCaption ( composer . value ) ;
setConversationAssistantCaption ( '' ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
setState ( 'thinking' ) ;
// The live VAD/STT capture is the barge-in detector while it runs; the
// energy-only monitor remains only as a fallback when capture is down.
if ( ! captureActive ) startBargeMonitor ( token ) ;
2026-08-23 22:13:52 -03:00
const cancellationSettled = await settleBargeCancellation ( token ) ;
if ( ! active || token !== generation ) return ;
if ( ! cancellationSettled ) {
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
pendingStitch = stitch ;
2026-08-23 22:13:52 -03:00
toast ( 'The previous response did not stop. Please repeat your interruption.' ) ;
2026-08-24 13:58:35 -03:00
// The stream state is now suspect: rebuild the capture turn instead of
// resuming a session whose server-side epoch may be stale.
resyncCapture ( token ) ;
2026-08-23 22:13:52 -03:00
return ;
}
2026-08-10 00:42:37 -03:00
thinkingSession = ( typeof S !== 'undefined' && S . session ) ? S . session . session _id : null ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
thinkingTurnId = turnId || captureTurnId || String ( token ) + '-' + String ( ++ turnSequence ) ;
2026-08-23 18:29:02 -03:00
rememberAssistantBaseline ( ) ;
2026-08-21 11:52:49 +00:00
rememberSttLanguage ( language , token ) ;
2026-08-24 13:58:35 -03:00
// The user's detected speech language is the sticky hands-free session
// language: it biases the next streaming STT session and localizes the
// thinking cues until the user audibly switches again.
if ( language ) {
const switched = language !== sessionLanguage ;
sessionLanguage = language ;
// Continuous capture already opened the next session (with the
// previous bias) before this turn's detection arrived: restart it with
// the switched language, but only while it has heard nothing yet.
if ( switched && captureActive && streamingStt && typeof streamingStt . latestPartial === 'function' && ! streamingStt . latestPartial ( ) ) {
startListening ( generation , { preserveDisplay : true } ) ;
}
}
2026-08-23 22:13:52 -03:00
if ( typeof window . send === 'function' ) {
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
lastSentTranscript = { text : composer . value , token : token } ;
2026-08-23 22:13:52 -03:00
window . send ( ) ;
suppressAutoRead = false ;
startResponseObserver ( token ) ;
2026-08-24 13:58:35 -03:00
scheduleThinkingCues ( token , language || sessionLanguage , thinkingTurnId ) ;
2026-08-23 22:13:52 -03:00
}
2026-08-10 00:42:37 -03:00
}
2026-08-21 11:12:15 +00:00
function audioExtension ( mimeType ) {
const normalized = String ( mimeType || '' ) . toLowerCase ( ) ;
2026-08-23 22:13:52 -03:00
if ( normalized . indexOf ( 'wav' ) >= 0 ) return 'wav' ;
2026-08-21 11:12:15 +00:00
if ( normalized . indexOf ( 'ogg' ) >= 0 ) return 'ogg' ;
if ( normalized . indexOf ( 'mp4' ) >= 0 ) return 'mp4' ;
return 'webm' ;
}
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
async function transcribe ( blob , token , turnId ) {
2026-08-10 00:42:37 -03:00
if ( ! active || token !== generation ) return ;
2026-08-20 23:13:51 +00:00
setState ( 'transcribing' ) ;
2026-08-21 11:12:15 +00:00
const ext = audioExtension ( blob . type ) ;
2026-08-10 00:42:37 -03:00
const form = new FormData ( ) ;
form . append ( 'file' , new File ( [ blob ] , 'voice-input.' + ext , { type : blob . type || 'audio/' + ext } ) ) ;
try {
const response = await fetch ( '/api/transcribe' , { method : 'POST' , body : form } ) ;
const payload = await response . json ( ) . catch ( function ( ) { return { } ; } ) ;
if ( ! response . ok ) throw new Error ( payload . error || ( 'Whisper request failed: ' + response . status ) ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
sendTranscript ( payload . transcript , token , normalizeSttLanguage ( payload . language ) , turnId ) ;
2026-08-10 00:42:37 -03:00
} catch ( error ) {
if ( ! active || token !== generation ) return ;
2026-08-20 23:13:51 +00:00
const message = errorMessage ( error , 'Private Whisper is unavailable' ) ;
showUnavailable ( message ) ;
toast ( message ) ;
2026-08-10 00:42:37 -03:00
// If the browser supplies its own recognizer, hand control back to the
// upstream voice implementation until the Jetson becomes healthy again.
if ( window . SpeechRecognition || window . webkitSpeechRecognition ) {
modeBtn . removeEventListener ( 'click' , onVoiceClick , true ) ;
window . setTimeout ( function ( ) { modeBtn . click ( ) ; } , 50 ) ;
}
}
}
2026-08-23 18:29:02 -03:00
function websocketUrl ( path ) {
const protocol = window . location && window . location . protocol === 'https:' ? 'wss:' : 'ws:' ;
return protocol + '//' + window . location . host + path ;
}
function encodePcm16 ( samples ) {
const bytes = new ArrayBuffer ( samples . length * 2 ) ;
const view = new DataView ( bytes ) ;
for ( let index = 0 ; index < samples . length ; index += 1 ) {
const value = Math . max ( - 1 , Math . min ( 1 , samples [ index ] ) ) ;
view . setInt16 ( index * 2 , value < 0 ? value * 32768 : value * 32767 , true ) ;
}
return bytes ;
}
2026-08-23 22:13:52 -03:00
function pcm16WavBlob ( buffers , sampleRate ) {
const chunks = Array . isArray ( buffers ) ? buffers : [ ] ;
const pcmBytes = chunks . reduce ( function ( total , chunk ) { return total + ( chunk ? chunk . byteLength : 0 ) ; } , 0 ) ;
const wav = new ArrayBuffer ( 44 + pcmBytes ) ;
const view = new DataView ( wav ) ;
const write = function ( offset , value ) { for ( let index = 0 ; index < value . length ; index += 1 ) view . setUint8 ( offset + index , value . charCodeAt ( index ) ) ; } ;
write ( 0 , 'RIFF' ) ;
view . setUint32 ( 4 , 36 + pcmBytes , true ) ;
write ( 8 , 'WAVE' ) ;
write ( 12 , 'fmt ' ) ;
view . setUint32 ( 16 , 16 , true ) ;
view . setUint16 ( 20 , 1 , true ) ;
view . setUint16 ( 22 , 1 , true ) ;
view . setUint32 ( 24 , sampleRate , true ) ;
view . setUint32 ( 28 , sampleRate * 2 , true ) ;
view . setUint16 ( 32 , 2 , true ) ;
view . setUint16 ( 34 , 16 , true ) ;
write ( 36 , 'data' ) ;
view . setUint32 ( 40 , pcmBytes , true ) ;
const output = new Uint8Array ( wav , 44 ) ;
let offset = 0 ;
chunks . forEach ( function ( chunk ) { const bytes = new Uint8Array ( chunk ) ; output . set ( bytes , offset ) ; offset += bytes . length ; } ) ;
return new Blob ( [ wav ] , { type : 'audio/wav' } ) ;
}
window . _atlasPcm16WavBlob = pcm16WavBlob ;
2026-08-23 18:29:02 -03:00
function createResampler ( sourceRate , targetRate ) {
let tail = null ;
let position = 0 ;
const ratio = sourceRate / targetRate ;
return function ( samples ) {
const input = new Float32Array ( samples . length + ( tail === null ? 0 : 1 ) ) ;
let offset = 0 ;
if ( tail !== null ) { input [ 0 ] = tail ; offset = 1 ; }
input . set ( samples , offset ) ;
if ( input . length < 2 ) { tail = input . length ? input [ 0 ] : tail ; return new Float32Array ( 0 ) ; }
const output = [ ] ;
while ( position < input . length - 1 ) {
const left = Math . floor ( position ) ;
const fraction = position - left ;
output . push ( input [ left ] + ( ( input [ left + 1 ] - input [ left ] ) * fraction ) ) ;
position += ratio ;
}
position -= input . length - 1 ;
tail = input [ input . length - 1 ] ;
return new Float32Array ( output ) ;
} ;
}
function createStreamingSttSession ( turnId , context ) {
if ( ! streamingCapability . stt || ! window . WebSocket || ! window . location ) return null ;
let socket ;
let settled = false ;
let committed = false ;
let speculative = false ;
let workletReady = false ;
2026-08-23 22:13:52 -03:00
let partialRevision = - 1 ;
let lastPreflightText = '' ;
2026-08-24 13:58:35 -03:00
let lastPartialText = '' ;
2026-08-23 18:29:02 -03:00
let queue = [ ] ;
2026-08-23 22:13:52 -03:00
let queuedBytes = 0 ;
let flushTimer = null ;
let archive = [ ] ;
let archiveBytes = 0 ;
let cancelled = false ;
2026-08-23 18:29:02 -03:00
let resolveFinal ;
let rejectFinal ;
const finalPromise = new Promise ( function ( resolve , reject ) { resolveFinal = resolve ; rejectFinal = reject ; } ) ;
// A transport can fail before recorder.onstop awaits it. Keep the rejection
// observed without changing what the later await receives.
finalPromise . catch ( function ( ) { } ) ;
const resample = createResampler ( context . sampleRate , 16000 ) ;
2026-08-23 22:13:52 -03:00
function clearFlushTimer ( ) {
if ( flushTimer ) { window . clearTimeout ( flushTimer ) ; flushTimer = null ; }
}
function clearQueue ( ) { queue = [ ] ; queuedBytes = 0 ; clearFlushTimer ( ) ; }
function clearArchive ( ) { archive = [ ] ; archiveBytes = 0 ; }
function archivePcm ( bytes ) {
if ( cancelled || ! bytes || ! bytes . byteLength || archiveBytes >= STT _MAX _ARCHIVE _BYTES ) return ;
const retained = Math . min ( bytes . byteLength , STT _MAX _ARCHIVE _BYTES - archiveBytes ) ;
const even = retained - ( retained % 2 ) ;
if ( ! even ) return ;
archive . push ( bytes . slice ( 0 , even ) ) ;
archiveBytes += even ;
}
2026-08-23 18:29:02 -03:00
function reject ( error ) {
if ( settled ) return ;
settled = true ;
2026-08-23 22:13:52 -03:00
cancelVoicePreflight ( turnId ) ;
clearQueue ( ) ;
2026-08-23 18:29:02 -03:00
rejectFinal ( error instanceof Error ? error : new Error ( String ( error || 'Streaming transcription failed' ) ) ) ;
}
function sendJson ( payload ) {
if ( socket && socket . readyState === 1 ) { socket . send ( JSON . stringify ( payload ) ) ; return true ; }
return false ;
}
2026-08-23 22:13:52 -03:00
function scheduleFlush ( ) {
if ( flushTimer || settled || ! queue . length || ! socket || socket . readyState !== 1 ) return ;
flushTimer = window . setTimeout ( function ( ) { flushTimer = null ; flush ( ) ; } , 20 ) ;
}
2026-08-23 18:29:02 -03:00
function flush ( ) {
2026-08-23 22:13:52 -03:00
clearFlushTimer ( ) ;
if ( settled ) { clearQueue ( ) ; return ; }
2026-08-23 18:29:02 -03:00
if ( ! socket || socket . readyState !== 1 ) return ;
2026-08-23 22:13:52 -03:00
while ( queue . length && socket . bufferedAmount < 524288 ) {
const bytes = queue . shift ( ) ;
queuedBytes = Math . max ( 0 , queuedBytes - bytes . byteLength ) ;
socket . send ( bytes ) ;
}
if ( queue . length ) scheduleFlush ( ) ;
2026-08-23 18:29:02 -03:00
}
try {
const csrf = String ( ( window . _ _HERMES _CONFIG _ _ && window . _ _HERMES _CONFIG _ _ . csrfToken ) || '' ) ;
const protocols = [ 'hermes-voice-v1' ] ;
if ( csrf ) protocols . push ( 'hermes-csrf.' + csrf ) ;
socket = new WebSocket ( websocketUrl ( STT _STREAM _PATH ) , protocols ) ;
socket . binaryType = 'arraybuffer' ;
} catch ( error ) {
reject ( error ) ;
return null ;
}
socket . onopen = function ( ) {
2026-08-24 13:58:35 -03:00
// Bias recognition toward the session's sticky language the moment the
// user has audibly switched; 'auto' remains the cold-start default.
2026-08-24 15:46:09 -03:00
sendJson ( { type : 'start' , turn _id : turnId , format : 'pcm_s16le' , sample _rate : 16000 , language : forcedLanguage || sessionLanguage || 'auto' } ) ;
2026-08-23 18:29:02 -03:00
flush ( ) ;
} ;
socket . onmessage = function ( event ) {
if ( typeof event . data !== 'string' ) return ;
let payload ;
try { payload = JSON . parse ( event . data ) ; } catch ( _ ) { return ; }
if ( payload . turn _id !== turnId ) return ;
2026-08-23 22:13:52 -03:00
if ( payload . type === 'partial' && payload . rolling === true ) {
const revision = Number ( payload . revision ) ;
if ( Number . isFinite ( revision ) && revision > partialRevision ) {
partialRevision = revision ;
const stable = String ( payload . stable _transcript || '' ) . trim ( ) ;
2026-08-24 00:36:28 -03:00
const provisional = String ( payload . transcript || '' ) . trim ( ) ;
const visible = stable || provisional ;
2026-08-24 13:58:35 -03:00
// The newest rolling partial also feeds the dynamic endpoint below,
// so it is tracked regardless of the display-only gating that keeps
// the label and captions quiet outside the listening state.
if ( visible ) lastPartialText = visible ;
2026-08-24 00:36:28 -03:00
if ( visible && active && captureTurnId === turnId && state === 'listening' ) {
const preview = visible . length > 72 ? visible . slice ( 0 , 69 ) + '…' : visible ;
label . textContent = 'Listening · ' + preview + ( stable ? '' : ' · provisional' ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
setConversationUserCaption ( visible ) ;
2026-08-23 22:13:52 -03:00
if ( stable !== lastPreflightText ) {
2026-08-24 00:36:28 -03:00
if ( stable ) {
lastPreflightText = stable ;
scheduleVoicePreflight ( turnId , revision , stable ) ;
}
2026-08-23 22:13:52 -03:00
}
}
}
} else if ( payload . type === 'final' && ! settled ) {
2026-08-23 18:29:02 -03:00
settled = true ;
2026-08-23 22:13:52 -03:00
cancelVoicePreflight ( turnId ) ;
clearQueue ( ) ;
clearArchive ( ) ;
2026-08-23 18:29:02 -03:00
resolveFinal ( { transcript : payload . transcript || '' , language : normalizeSttLanguage ( payload . language ) } ) ;
} else if ( payload . type === 'error' ) {
reject ( new Error ( payload . error || 'Streaming transcription failed' ) ) ;
}
} ;
socket . onerror = function ( ) { reject ( new Error ( 'Streaming transcription connection failed' ) ) ; } ;
socket . onclose = function ( ) { if ( ! settled ) reject ( new Error ( 'Streaming transcription closed before final result' ) ) ; } ;
return {
finalPromise : finalPromise ,
2026-08-24 13:58:35 -03:00
latestPartial : function ( ) { return lastPartialText ; } ,
2026-08-23 18:29:02 -03:00
setWorkletReady : function ( value ) { workletReady = value ; } ,
push : function ( samples ) {
2026-08-23 22:13:52 -03:00
if ( cancelled || committed || ! workletReady ) return ;
2026-08-23 18:29:02 -03:00
const pcm = resample ( samples ) ;
if ( ! pcm . length ) return ;
2026-08-23 22:13:52 -03:00
const bytes = encodePcm16 ( pcm ) ;
archivePcm ( bytes ) ;
if ( settled ) return ;
if ( queuedBytes + bytes . byteLength > STT _MAX _QUEUED _BYTES ) {
reject ( new Error ( 'Streaming transcription backpressure limit exceeded' ) ) ;
if ( socket && socket . readyState < 2 ) socket . close ( 1008 , 'client backpressure' ) ;
return ;
}
queue . push ( bytes ) ;
queuedBytes += bytes . byteLength ;
2026-08-23 18:29:02 -03:00
flush ( ) ;
} ,
2026-08-23 22:13:52 -03:00
takeFallbackBlob : function ( ) {
if ( ! archiveBytes ) return null ;
const blob = pcm16WavBlob ( archive , 16000 ) ;
clearArchive ( ) ;
return blob ;
} ,
2026-08-23 18:29:02 -03:00
speculate : function ( ) {
if ( settled || committed || speculative ) return ;
speculative = true ;
sendJson ( { type : 'speculate' , turn _id : turnId } ) ;
} ,
resume : function ( ) {
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
// Deliberately not gated on a prior speculate() from this client: the
// server also freezes an EOS snapshot on its own silence detector, so
// resume must always reach the wire when speech restarts. The server
// treats a redundant resume as a harmless epoch bump.
if ( settled || committed ) return ;
2026-08-23 18:29:02 -03:00
speculative = false ;
sendJson ( { type : 'resume' , turn _id : turnId } ) ;
} ,
commit : function ( ) {
if ( settled || committed ) return finalPromise ;
committed = true ;
flush ( ) ;
const commitDeadline = Date . now ( ) + 30000 ;
const sendCommit = function ( ) {
if ( settled ) return ;
if ( ! socket || socket . readyState > 1 || Date . now ( ) >= commitDeadline ) {
reject ( new Error ( 'Streaming transcription closed before commit' ) ) ;
return ;
}
if ( socket . readyState !== 1 || queue . length ) { flush ( ) ; window . setTimeout ( sendCommit , 20 ) ; return ; }
if ( ! sendJson ( { type : 'commit' , turn _id : turnId } ) ) reject ( new Error ( 'Streaming transcription commit failed' ) ) ;
} ;
sendCommit ( ) ;
return finalPromise ;
} ,
cancel : function ( ) {
2026-08-23 22:13:52 -03:00
cancelled = true ;
cancelVoicePreflight ( turnId ) ;
clearQueue ( ) ;
clearArchive ( ) ;
2026-08-23 18:29:02 -03:00
if ( ! settled ) { sendJson ( { type : 'cancel' , turn _id : turnId } ) ; reject ( new Error ( 'Streaming transcription cancelled' ) ) ; }
if ( socket && socket . readyState < 2 ) socket . close ( 1000 , 'cancelled' ) ;
} ,
} ;
}
async function installCaptureWorklet ( context , source , session ) {
if ( ! session ) return false ;
if ( ! context . audioWorklet || ! window . AudioWorkletNode ) {
session . cancel ( ) ;
if ( streamingStt === session ) streamingStt = null ;
return false ;
}
try {
await context . audioWorklet . addModule ( WORKLET _URL ) ;
if ( ! streamingStt || streamingStt !== session ) return false ;
const node = new AudioWorkletNode ( context , 'atlas-pcm-capture' ) ;
const silentGain = context . createGain ( ) ;
silentGain . gain . value = 0 ;
let flushResolve = null ;
node . port . onmessage = function ( event ) {
if ( ! event . data ) return ;
if ( event . data . type === 'flushed' && flushResolve ) { flushResolve ( ) ; flushResolve = null ; return ; }
if ( ! streamingStt || streamingStt !== session || event . data . type !== 'pcm' ) return ;
session . push ( new Float32Array ( event . data . samples ) ) ;
} ;
node . _atlasFlush = function ( ) {
return new Promise ( function ( resolve ) { flushResolve = resolve ; node . port . postMessage ( { type : 'flush' } ) ; } ) ;
} ;
source . connect ( node ) ;
node . connect ( silentGain ) ;
silentGain . connect ( context . destination ) ;
captureNode = node ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( captureGraph ) captureGraph . silentGain = silentGain ;
2026-08-23 18:29:02 -03:00
session . setWorkletReady ( true ) ;
return true ;
} catch ( _ ) {
session . cancel ( ) ;
if ( streamingStt === session ) streamingStt = null ;
return false ;
}
}
2026-08-23 22:13:52 -03:00
function cancelActiveModelTurn ( ) {
const sessionId = ( typeof S !== 'undefined' && S . session ) ? String ( S . session . session _id || '' ) : '' ;
const streamId = typeof S !== 'undefined' ? String ( S . activeStreamId || '' ) : '' ;
if ( ! streamId ) return { sessionId : sessionId , streamId : '' , controller : null , promise : Promise . resolve ( false ) } ;
const controller = createAbortController ( ) ;
const timer = window . setTimeout ( function ( ) { controller . abort ( ) ; } , 1800 ) ;
const promise = ( async function ( ) {
try {
const url = new URL ( 'api/chat/cancel?stream_id=' + encodeURIComponent ( streamId ) , document . baseURI || location . href ) . href ;
const response = await fetch ( url , { credentials : 'include' , signal : controller . signal } ) ;
let payload = null ;
try { payload = await response . json ( ) ; } catch ( _ ) { }
const currentSession = ( typeof S !== 'undefined' && S . session ) ? String ( S . session . session _id || '' ) : '' ;
if ( response . ok && payload && payload . cancelled === false && currentSession === sessionId && String ( S . activeStreamId || '' ) === streamId ) {
S . activeStreamId = null ;
if ( S . session ) S . session . active _stream _id = null ;
if ( typeof setBusy === 'function' ) setBusy ( false ) ; else S . busy = false ;
}
return ! ! response . ok ;
} catch ( _ ) {
return false ;
} finally {
window . clearTimeout ( timer ) ;
}
} ) ( ) ;
return { sessionId : sessionId , streamId : streamId , controller : controller , promise : promise } ;
}
function trimBargeLookback ( monitor ) {
const maximum = Math . max ( 1 , Math . round ( monitor . context . sampleRate * BARGE _LOOKBACK _MS / 1000 ) ) ;
while ( monitor . lookbackFrames > maximum && monitor . lookback . length ) {
const overflow = monitor . lookbackFrames - maximum ;
const first = monitor . lookback [ 0 ] ;
if ( first . length <= overflow ) { monitor . lookback . shift ( ) ; monitor . lookbackFrames -= first . length ; continue ; }
monitor . lookback [ 0 ] = first . slice ( overflow ) ;
monitor . lookbackFrames -= overflow ;
}
}
async function triggerBargeIn ( monitor ) {
if ( ! bargeMonitor || bargeMonitor !== monitor || monitor . cancelled || ! active || monitor . token !== generation ) return ;
bargeMonitor = null ;
if ( monitor . timer ) { window . clearInterval ( monitor . timer ) ; monitor . timer = null ; }
// Keep the old capture worklet alive until the new STT worklet is attached.
// Its lookback becomes an untrimmed handoff buffer so no syllables disappear
// during AudioWorklet/session setup.
monitor . handoff = true ;
const oldToken = monitor . token ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
recordPendingStitch ( ) ;
2026-08-23 22:13:52 -03:00
generation += 1 ;
const token = generation ;
thinkingSession = null ;
thinkingTurnId = '' ;
suppressAutoRead = true ;
clearSttLanguage ( ) ;
stopResponseObserver ( ) ;
cancelThinkingCues ( ) ;
cancelSpeechTurn ( ) ;
stopPlayback ( ) ;
if ( typeof window . stopTTS === 'function' ) window . stopTTS ( ) ;
clearBargeCancellation ( ) ;
bargeCancelPromise = cancelActiveModelTurn ( ) ;
setState ( 'listening' , 'Listening — interrupted' ) ;
if ( oldToken === token ) return ;
startListening ( token , {
stream : monitor . stream ,
context : monitor . context ,
lookback : monitor . lookback ,
handoffMonitor : monitor ,
heardSpeech : true ,
requireStreamingLookback : true ,
} ) ;
}
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
function bargeFromLiveCapture ( ) {
// Live-capture barge-in: the always-on VAD/STT listener detected a real
// speech onset while Hermes was mid-response. Cancel the RESPONSE side
// only — cues, playback, the model stream — and bump `generation` so every
// stale response-side continuation dies. The already-running capture is
// guarded by captureGeneration and keeps collecting the interrupting
// utterance without losing a syllable.
if ( ! active || ( state !== 'thinking' && state !== 'speaking' ) ) return ;
recordPendingStitch ( ) ;
generation += 1 ;
thinkingSession = null ;
thinkingTurnId = '' ;
suppressAutoRead = true ;
clearSttLanguage ( ) ;
stopResponseObserver ( ) ;
cancelThinkingCues ( ) ;
cancelSpeechTurn ( ) ;
stopPlayback ( ) ;
if ( typeof window . stopTTS === 'function' ) window . stopTTS ( ) ;
clearBargeCancellation ( ) ;
bargeCancelPromise = cancelActiveModelTurn ( ) ;
setState ( 'listening' , 'Listening — interrupted' ) ;
}
2026-08-23 22:13:52 -03:00
async function startBargeMonitor ( token ) {
if ( ! active || token !== generation || ( state !== 'thinking' && state !== 'speaking' ) || bargeMonitor ) return ;
const monitor = { token : token , cancelled : false , handoff : false , aecUsable : true , timer : null , stream : null , context : null , source : null , node : null , silentGain : null , lookback : [ ] , lookbackFrames : 0 } ;
bargeMonitor = monitor ;
try {
monitor . stream = await acquireMicrophone ( ) ;
if ( ! active || token !== generation || bargeMonitor !== monitor ) { disposeBargeResources ( monitor , false ) ; return ; }
monitor . aecUsable = captureAecIsUsable ( monitor . stream ) ;
const Context = window . AudioContext || window . webkitAudioContext ;
try { monitor . context = new Context ( { latencyHint : 'interactive' } ) ; } catch ( _ ) { monitor . context = new Context ( ) ; }
const analyser = monitor . context . createAnalyser ( ) ;
analyser . fftSize = 1024 ;
monitor . source = monitor . context . createMediaStreamSource ( monitor . stream ) ;
monitor . source . connect ( analyser ) ;
if ( monitor . context . audioWorklet && window . AudioWorkletNode ) {
await monitor . context . audioWorklet . addModule ( WORKLET _URL ) ;
if ( bargeMonitor !== monitor || monitor . cancelled ) { disposeBargeResources ( monitor , false ) ; return ; }
monitor . node = new AudioWorkletNode ( monitor . context , 'atlas-pcm-capture' ) ;
monitor . silentGain = monitor . context . createGain ( ) ;
monitor . silentGain . gain . value = 0 ;
monitor . node . port . onmessage = function ( event ) {
if ( monitor . cancelled || ( ! monitor . handoff && bargeMonitor !== monitor ) || ! event . data || event . data . type !== 'pcm' ) return ;
const samples = new Float32Array ( event . data . samples ) ;
monitor . lookback . push ( samples ) ;
monitor . lookbackFrames += samples . length ;
if ( ! monitor . handoff ) trimBargeLookback ( monitor ) ;
} ;
monitor . source . connect ( monitor . node ) ;
monitor . node . connect ( monitor . silentGain ) ;
monitor . silentGain . connect ( monitor . context . destination ) ;
}
await monitor . context . resume ( ) ;
const samples = new Uint8Array ( analyser . fftSize ) ;
let noiseFloor = 0.008 ;
let voiceFrames = 0 ;
let ducked = false ;
let speechArmAt = Date . now ( ) + 100 ;
let playbackWasActive = false ;
monitor . timer = window . setInterval ( function ( ) {
if ( ! active || token !== generation || bargeMonitor !== monitor || ( state !== 'thinking' && state !== 'speaking' ) ) { stopBargeMonitor ( ) ; return ; }
analyser . getByteTimeDomainData ( samples ) ;
let energy = 0 ;
for ( let index = 0 ; index < samples . length ; index += 1 ) { const value = ( samples [ index ] - 128 ) / 128 ; energy += value * value ; }
const rms = Math . sqrt ( energy / samples . length ) ;
const now = Date . now ( ) ;
2026-08-24 13:58:35 -03:00
const playbackActive = playbackAudible ( ) ;
2026-08-23 22:13:52 -03:00
if ( playbackActive && ! playbackWasActive ) {
// AEC needs a brief convergence window when local speech starts. PCM
// lookback preserves a real interruption spoken during this guard.
// A brief cached thinking cue uses the shorter guard so an "um" can
// still interrupt it; answer playback gets the conservative window.
speechArmAt = now + ( state === 'thinking' ? 150 : 450 ) ;
voiceFrames = 0 ;
if ( ducked ) { ducked = false ; setPlaybackDucked ( false ) ; }
} else if ( ! playbackActive && playbackWasActive ) {
speechArmAt = now + 100 ;
}
playbackWasActive = playbackActive ;
const threshold = Math . max ( 0.05 , ( noiseFloor * 3 ) + 0.008 ) ;
// Never treat speaker leakage as an interruption when the browser says
// the requested AEC was not applied. Quiet Thinking remains fully
// interruptible, and unknown/unsupported settings preserve normal use.
const automaticBargeAllowed = ! playbackActive || monitor . aecUsable ;
const voiceNow = automaticBargeAllowed && now >= speechArmAt && rms > threshold ;
if ( ! voiceNow ) noiseFloor = ( noiseFloor * 0.975 ) + ( Math . min ( rms , threshold ) * 0.025 ) ;
voiceFrames = voiceNow ? Math . min ( voiceFrames + 1 , BARGE _TRIGGER _FRAMES ) : Math . max ( voiceFrames - 1 , 0 ) ;
if ( voiceFrames >= BARGE _DUCK _FRAMES && ! ducked ) { ducked = true ; setPlaybackDucked ( true ) ; }
if ( ! voiceFrames && ducked ) { ducked = false ; setPlaybackDucked ( false ) ; }
if ( voiceFrames >= BARGE _TRIGGER _FRAMES ) triggerBargeIn ( monitor ) ;
} , 50 ) ;
} catch ( _ ) {
if ( bargeMonitor === monitor ) bargeMonitor = null ;
disposeBargeResources ( monitor , false ) ;
// Barge-in is an optional full-duplex enhancement. The normal finalized
// microphone turn remains available if the browser rejects concurrent
// capture or AudioWorklet initialization.
}
}
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
async function transcribeStreamingOrFallback ( blob , token , session , allowContainerFallback , turnId ) {
2026-08-23 18:29:02 -03:00
if ( ! active || token !== generation ) return ;
setState ( 'transcribing' ) ;
2026-08-23 22:13:52 -03:00
let pcmFallback = null ;
2026-08-23 18:29:02 -03:00
if ( session ) {
try {
const result = await session . commit ( ) ;
if ( ! String ( result . transcript || '' ) . trim ( ) ) throw new Error ( 'Streaming transcription returned no final text' ) ;
if ( active && token === generation ) {
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( streamingStt === session ) streamingStt = null ;
sendTranscript ( result . transcript , token , result . language , turnId ) ;
2026-08-23 18:29:02 -03:00
return ;
}
} catch ( _ ) {
// The finalized browser container below is the quality-preserving path
// whenever rolling PCM transport or speculative inference fails.
2026-08-23 22:13:52 -03:00
if ( typeof session . takeFallbackBlob === 'function' ) pcmFallback = session . takeFallbackBlob ( ) ;
2026-08-23 18:29:02 -03:00
}
if ( streamingStt === session ) streamingStt = null ;
}
2026-08-23 22:13:52 -03:00
if ( pcmFallback ) {
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
transcribe ( pcmFallback , token , turnId ) ;
2026-08-23 22:13:52 -03:00
return ;
}
if ( allowContainerFallback === false ) {
toast ( 'I missed the start of that interruption. Please repeat it.' ) ;
restartSoon ( token , 250 ) ;
return ;
}
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
transcribe ( blob , token , turnId ) ;
2026-08-23 18:29:02 -03:00
}
2026-08-23 22:13:52 -03:00
async function startListening ( token , reusedCapture ) {
2026-08-10 00:42:37 -03:00
if ( ! active || token !== generation ) return ;
stopCapture ( ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// A continuous-capture restart (reusedCapture.preserveDisplay) happens the
// instant an utterance finalizes, while the display legitimately shows
// Hermes's own activity (transcribing/thinking/speaking). Capture is
// therefore tracked by captureActive, never by the visible state, and the
// response-side teardown below is skipped so that turn is not disturbed.
const preserveDisplay = ! ! ( reusedCapture && reusedCapture . preserveDisplay ) ;
if ( ! preserveDisplay ) {
stopPlayback ( ) ;
cancelSpeechTurn ( ) ;
clearSttLanguage ( ) ;
}
2026-08-23 22:13:52 -03:00
captureTurnId = ( voiceTabNonce ? voiceTabNonce + '-' : '' ) + String ( token ) + '-' + String ( ++ turnSequence ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// Capture turns carry their own epoch: a response-side barge bumps
// `generation` but never this counter, so capture survives it seamlessly.
const captureToken = ++ captureGeneration ;
captureActive = true ;
if ( ! preserveDisplay ) setState ( 'listening' ) ;
2026-08-10 00:42:37 -03:00
try {
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// A barge-monitor handoff supplies its own stream/context; otherwise the
// retained session microphone is reused. Only a fresh hands-free session
// actually asks for the microphone again, keeping acquireMicrophone()
// the single getUserMedia call site.
if ( reusedCapture && reusedCapture . stream && stream && reusedCapture . stream !== stream ) {
stream . getTracks ( ) . forEach ( function ( track ) { track . stop ( ) ; } ) ;
stream = null ;
}
if ( reusedCapture && reusedCapture . context && audioContext && reusedCapture . context !== audioContext ) {
try { audioContext . close ( ) ; } catch ( _ ) { }
audioContext = null ;
}
const capture = ( reusedCapture && reusedCapture . stream ) || stream || await acquireMicrophone ( ) ;
if ( ! active || captureToken !== captureGeneration ) {
2026-08-23 22:13:52 -03:00
if ( reusedCapture && reusedCapture . handoffMonitor ) disposeBargeResources ( reusedCapture . handoffMonitor , false ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
else if ( capture !== stream ) capture . getTracks ( ) . forEach ( function ( track ) { track . stop ( ) ; } ) ;
2026-08-23 22:13:52 -03:00
return ;
}
2026-08-10 00:42:37 -03:00
stream = capture ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
applyConversationMute ( ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
const captureAec = captureAecIsUsable ( stream ) ;
2026-08-10 00:42:37 -03:00
const Context = window . AudioContext || window . webkitAudioContext ;
2026-08-23 22:13:52 -03:00
if ( reusedCapture && reusedCapture . context ) {
audioContext = reusedCapture . context ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
} else if ( ! audioContext ) {
2026-08-23 22:13:52 -03:00
try {
// Let the browser's native resampler produce Whisper's 16 kHz input.
audioContext = new Context ( { sampleRate : 16000 , latencyHint : 'interactive' } ) ;
} catch ( _ ) {
audioContext = new Context ( ) ;
}
2026-08-23 18:29:02 -03:00
}
2026-08-10 00:42:37 -03:00
const analyser = audioContext . createAnalyser ( ) ;
2026-08-10 01:47:51 -03:00
analyser . fftSize = 2048 ;
const highpass = audioContext . createBiquadFilter ( ) ;
highpass . type = 'highpass' ;
highpass . frequency . value = 140 ;
highpass . Q . value = 0.7 ;
2026-08-23 18:29:02 -03:00
const mediaSource = audioContext . createMediaStreamSource ( stream ) ;
mediaSource . connect ( highpass ) ;
2026-08-10 01:47:51 -03:00
highpass . connect ( analyser ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
captureGraph = { mediaSource : mediaSource , highpass : highpass , analyser : analyser , silentGain : null } ;
2026-08-23 18:29:02 -03:00
streamingStt = createStreamingSttSession ( captureTurnId , audioContext ) ;
// Whisper receives the browser's full-band processed microphone signal;
// the 140 Hz high-pass remains a VAD-only aid so low voices are not
// needlessly altered before final recognition.
if ( streamingStt ) await installCaptureWorklet ( audioContext , mediaSource , streamingStt ) ;
2026-08-23 22:13:52 -03:00
if ( streamingStt && reusedCapture && Array . isArray ( reusedCapture . lookback ) ) {
reusedCapture . lookback . forEach ( function ( samples ) { streamingStt . push ( samples ) ; } ) ;
}
if ( reusedCapture && reusedCapture . handoffMonitor ) {
disposeBargeResources ( reusedCapture . handoffMonitor , true ) ;
reusedCapture . handoffMonitor = null ;
}
if ( reusedCapture && reusedCapture . requireStreamingLookback && ! streamingStt ) {
toast ( 'Streaming transcription is unavailable. Please repeat your interruption.' ) ;
stopCapture ( ) ;
restartSoon ( token , 250 ) ;
return ;
}
2026-08-10 00:42:37 -03:00
const samples = new Uint8Array ( analyser . fftSize ) ;
2026-08-21 11:12:15 +00:00
const mimeTypes = [ 'audio/webm;codecs=opus' , 'audio/ogg;codecs=opus' , 'audio/mp4;codecs=mp4a.40.2' , 'audio/mp4' , 'audio/webm' ] ;
2026-08-10 00:42:37 -03:00
const mime = mimeTypes . find ( function ( value ) { return MediaRecorder . isTypeSupported ( value ) ; } ) || '' ;
const chunks = [ ] ;
2026-08-23 22:13:52 -03:00
let heardSpeech = ! ! ( reusedCapture && reusedCapture . heardSpeech ) ;
let voiceFrames = heardSpeech ? 3 : 0 ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
// Voiced milliseconds collected this utterance. A barge handoff already
// carries confirmed speech, so it starts past the young-utterance hold.
let speechMs = heardSpeech ? VAD _COMMITTED _SPEECH _MS : 0 ;
2026-08-10 01:47:51 -03:00
let noiseFloor = 0.008 ;
2026-08-10 00:42:37 -03:00
let lastSpeech = Date . now ( ) ;
2026-08-23 18:29:02 -03:00
let speculative = false ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
let ducked = false ;
let bargeArmAt = 0 ;
let playbackWasLive = false ;
2026-08-10 00:42:37 -03:00
const started = Date . now ( ) ;
recorder = new MediaRecorder ( stream , mime ? { mimeType : mime } : undefined ) ;
2026-08-21 11:12:15 +00:00
let recordedMime = recorder . mimeType || mime || '' ;
2026-08-10 01:47:51 -03:00
recorder . ondataavailable = function ( event ) {
if ( ! event . data || ! event . data . size ) return ;
2026-08-21 11:12:15 +00:00
if ( event . data . type ) recordedMime = event . data . type ;
2026-08-23 15:59:43 -03:00
chunks . push ( event . data ) ;
2026-08-10 01:47:51 -03:00
} ;
2026-08-23 18:29:02 -03:00
recorder . onstop = async function ( ) {
2026-08-10 00:42:37 -03:00
if ( vadTimer ) { clearInterval ( vadTimer ) ; vadTimer = null ; }
2026-08-23 18:29:02 -03:00
const finalCaptureNode = captureNode ;
if ( finalCaptureNode && typeof finalCaptureNode . _atlasFlush === 'function' ) {
await Promise . race ( [
finalCaptureNode . _atlasFlush ( ) ,
new Promise ( function ( resolve ) { window . setTimeout ( resolve , 100 ) ; } ) ,
] ) ;
}
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// A stale onstop (teardown by stopCapture/releaseMicrophone, or a
// newer capture turn already running) must not touch the globals: by
// now they can belong to the NEXT turn, and its session was already
// cancelled by stopCapture. Real recorders fire onstop asynchronously,
// so this guard has to come before anything else is read.
if ( ! active || captureToken !== captureGeneration ) return ;
// Continuous capture: the microphone stream and AudioContext stay hot.
// Detach only this utterance's session, then re-enter capture below so
// speech during transcribing/thinking becomes the next utterance.
const utteranceTurnId = captureTurnId ;
2026-08-23 18:29:02 -03:00
const session = streamingStt ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
streamingStt = null ;
recorder = null ;
if ( ! heardSpeech || ! chunks . length ) {
if ( session ) session . cancel ( ) ;
// Nothing worth transcribing: recycle the recorder on the hot mic.
startListening ( generation , { preserveDisplay : true } ) ;
return ;
}
// The utterance is dispatched under the CURRENT response epoch: when a
// live-capture barge just cancelled a model turn, this transcript is
// the interruption that replaces it.
2026-08-23 22:13:52 -03:00
transcribeStreamingOrFallback (
new Blob ( chunks , { type : recordedMime || 'audio/webm' } ) ,
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
generation ,
2026-08-23 22:13:52 -03:00
session ,
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
! ( reusedCapture && reusedCapture . requireStreamingLookback ) ,
utteranceTurnId
2026-08-23 22:13:52 -03:00
) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// Re-enter capture immediately — never wait for transcription or the
// response. The display may show transcribing/thinking while the next
// capture turn is already live underneath.
startListening ( generation , { preserveDisplay : true } ) ;
2026-08-10 00:42:37 -03:00
} ;
2026-08-23 15:59:43 -03:00
// Ask the browser for one finalized container at stop. Android Chromium
// can emit timeslice fragments without a reusable EBML initialization
// header; concatenating those fragments made otherwise valid recordings
// intermittently unreadable by ffmpeg. A bounded 90-second Opus capture
// is small enough to retain as one browser-owned recording.
recorder . start ( ) ;
2026-08-23 16:50:48 -03:00
const silenceMs = Math . max ( 900 , parseInt ( localStorage . getItem ( 'hermes-voice-silence-ms' ) || '1100' , 10 ) || 1100 ) ;
2026-08-23 18:29:02 -03:00
const speculateMs = Math . min ( silenceMs - 250 , Math . max ( 450 , Math . round ( silenceMs * 0.55 ) ) ) ;
2026-08-10 00:42:37 -03:00
vadTimer = window . setInterval ( function ( ) {
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( ! active || captureToken !== captureGeneration || ! recorder || recorder . state === 'inactive' ) return ;
2026-08-10 00:42:37 -03:00
analyser . getByteTimeDomainData ( samples ) ;
let energy = 0 ;
2026-08-23 18:29:02 -03:00
for ( let index = 0 ; index < samples . length ; index += 1 ) {
const normalized = ( samples [ index ] - 128 ) / 128 ;
2026-08-10 00:42:37 -03:00
energy += normalized * normalized ;
}
const rms = Math . sqrt ( energy / samples . length ) ;
2026-08-20 23:13:51 +00:00
updateInputLevel ( rms ) ;
2026-08-10 00:42:37 -03:00
const now = Date . now ( ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// Continuous capture stays live while Hermes thinks and speaks, so the
// same VAD that endpoints utterances is also the barge-in detector.
// While our own audio plays the microphone hears the speakers: without
// AEC nothing counts as speech at all, and with AEC a post-playback
// arming delay plus the sustained-frame requirement below mirror the
// conservative energy monitor this path supersedes.
2026-08-24 13:58:35 -03:00
const playbackLive = playbackAudible ( ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( playbackLive && ! playbackWasLive ) {
bargeArmAt = now + ( state === 'thinking' ? 150 : 450 ) ;
} else if ( ! playbackLive && playbackWasLive ) {
bargeArmAt = now + 100 ;
}
playbackWasLive = playbackLive ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
setConversationPlaying ( playbackLive ) ;
2026-08-10 01:47:51 -03:00
const speechThreshold = Math . max ( 0.04 , noiseFloor * 2.4 + 0.006 ) ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
const voiceNow = rms > speechThreshold && ( ! playbackLive || ( captureAec && now >= bargeArmAt ) ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
// Adapt the floor only toward energies at or below the current
// threshold (like the fallback energy monitor): echo residue during
// continuous capture must never ratchet the threshold above real
// speech and deafen onset detection for the next utterance.
if ( ! heardSpeech && ! voiceNow ) { noiseFloor = ( noiseFloor * 0.94 ) + ( Math . min ( rms , speechThreshold ) * 0.06 ) ; }
2026-08-10 01:47:51 -03:00
voiceFrames = voiceNow ? Math . min ( voiceFrames + 1 , 5 ) : Math . max ( voiceFrames - 1 , 0 ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
if ( voiceNow ) speechMs += 100 ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( playbackLive ) {
if ( voiceFrames >= BARGE _DUCK _FRAMES && ! ducked ) { ducked = true ; setPlaybackDucked ( true ) ; }
if ( ! voiceFrames && ducked ) { ducked = false ; setPlaybackDucked ( false ) ; }
} else if ( ducked ) {
ducked = false ;
setPlaybackDucked ( false ) ;
}
// Speech onset. While playback runs an echo-safe barge needs the same
// sustained evidence (BARGE_TRIGGER_FRAMES) as the old energy monitor.
const onsetFrames = playbackLive ? BARGE _TRIGGER _FRAMES : 3 ;
if ( ! heardSpeech && voiceFrames >= onsetFrames ) {
2026-08-10 01:47:51 -03:00
heardSpeech = true ;
lastSpeech = now ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// The user started a new utterance while Hermes was mid-response:
// cancel the response side only; this capture keeps running.
if ( state === 'thinking' || state === 'speaking' ) bargeFromLiveCapture ( ) ;
2026-08-10 01:47:51 -03:00
} else if ( heardSpeech && voiceNow ) {
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
const speechGapMs = now - lastSpeech ;
2026-08-10 01:47:51 -03:00
lastSpeech = now ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
// Resume after our own speculate AND after any gap long enough for
// the server's independent 650ms end-of-speech detector: either
// party may have frozen a partial-utterance snapshot by now.
if ( ( speculative || speechGapMs >= SERVER _EOS _SILENCE _MS ) && streamingStt ) { streamingStt . resume ( ) ; speculative = false ; }
2026-08-23 18:29:02 -03:00
}
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
if ( heardSpeech && ! voiceNow && ! speculative && speechMs >= SPECULATE _MIN _SPEECH _MS && ( now - lastSpeech ) >= speculateMs && streamingStt ) {
2026-08-23 18:29:02 -03:00
streamingStt . speculate ( ) ;
speculative = true ;
2026-08-10 01:47:51 -03:00
}
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
// A young utterance holds its endpoint longer: pausing to think right
2026-08-24 13:58:35 -03:00
// after the first word must not send a one-word fragment. When the
// streaming partial already reads as a plausibly complete utterance
// (>=3 words, or terminal punctuation), endpoint at the base window
// instead so short commands are not delayed by the clipping guard —
// the long hold remains only for 1-2 word partials.
const partialText = streamingStt && streamingStt . latestPartial ? streamingStt . latestPartial ( ) : '' ;
const partialWords = partialText ? partialText . split ( /\s+/ ) . filter ( Boolean ) . length : 0 ;
const partialComplete = partialWords >= 3 || ( partialWords > 0 && /[.!?…]["')\]}]*$/ . test ( partialText ) ) ;
const endpointSilenceMs = ( speechMs < VAD _COMMITTED _SPEECH _MS && ! partialComplete ) ? Math . max ( silenceMs , VAD _EARLY _SILENCE _MS ) : silenceMs ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
const finished = heardSpeech && ( now - lastSpeech ) >= endpointSilenceMs ;
2026-08-10 00:42:37 -03:00
const timedOut = now - started >= 90000 ;
const idle = ( ! heardSpeech ) && ( now - started ) >= 20000 ;
if ( finished || timedOut || idle ) {
if ( vadTimer ) { clearInterval ( vadTimer ) ; vadTimer = null ; }
try { recorder . stop ( ) ; } catch ( _ ) { }
}
} , 100 ) ;
} catch ( error ) {
2026-08-23 22:13:52 -03:00
if ( reusedCapture && reusedCapture . handoffMonitor ) {
disposeBargeResources ( reusedCapture . handoffMonitor , true ) ;
reusedCapture . handoffMonitor = null ;
}
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( ! active || captureToken !== captureGeneration ) return ;
// This capture turn is dead. A fatal microphone error ends the session
// and releases the retained microphone via showUnavailable().
captureActive = false ;
2026-08-20 23:13:51 +00:00
const message = errorMessage ( error , 'Microphone permission is required' ) ;
showUnavailable ( message ) ;
toast ( message ) ;
2026-08-10 00:42:37 -03:00
}
}
2026-08-23 22:13:52 -03:00
function stripHttpUrlsForSpeech ( text ) {
return String ( text || '' ) . replace ( /(^|\s+[([{]|\s+|[([{"'])https?:\/\/[^\s<>"']+/gi , function ( match , prefix ) {
let address = match . slice ( prefix . length ) ;
let suffix = '' ;
while ( address ) {
const last = address . slice ( - 1 ) ;
let trailing = /[.,!?;:…,。!?;:]/ . test ( last ) ;
if ( last === ')' ) trailing = ( address . match ( /\)/g ) || [ ] ) . length > ( address . match ( /\(/g ) || [ ] ) . length ;
if ( last === ']' ) trailing = ( address . match ( /\]/g ) || [ ] ) . length > ( address . match ( /\[/g ) || [ ] ) . length ;
if ( last === '}' ) trailing = ( address . match ( /\}/g ) || [ ] ) . length > ( address . match ( /\{/g ) || [ ] ) . length ;
if ( ! trailing ) break ;
suffix = last + suffix ;
address = address . slice ( 0 , - 1 ) ;
}
const pairs = { '(' : ')' , '[' : ']' , '{' : '}' } ;
const opening = prefix . slice ( - 1 ) ;
if ( pairs [ opening ] && suffix . startsWith ( pairs [ opening ] ) ) {
prefix = prefix . slice ( 0 , - 1 ) ;
suffix = suffix . slice ( 1 ) ;
}
if ( /^\s+$/ . test ( prefix ) && /^[.,!?;:…,。!?;:]/ . test ( suffix ) ) prefix = '' ;
return prefix + suffix ;
} ) ;
}
window . _atlasStripHttpUrlsForSpeech = stripHttpUrlsForSpeech ;
2026-08-10 00:42:37 -03:00
function cleanForSpeech ( text ) {
2026-08-23 22:13:52 -03:00
const cleaned = typeof window . _stripForTTS === 'function' ? window . _stripForTTS ( text ) : String ( text || '' ) . replace ( /```[\s\S]*?```/g , ' code block ' ) ;
return stripHttpUrlsForSpeech ( cleaned ) . replace ( /\s+/g , ' ' ) . trim ( ) ;
2026-08-10 00:42:37 -03:00
}
2026-08-23 18:29:02 -03:00
function sentenceEnd ( text ) {
const source = String ( text || '' ) ;
const boundary = /[.!?…]+["')\]}]*(?:\s|$)/g ;
const abbreviation = /(?:\b(?:mr|mrs|ms|dr|prof|sr|jr|st|vs|etc|e\.g|i\.e)|\b[A-Z])\.$/i ;
let match ;
while ( ( match = boundary . exec ( source ) ) ) {
const end = match . index + match [ 0 ] . trimEnd ( ) . length ;
const prefix = source . slice ( 0 , end ) . replace ( /["')\]}]+$/ , '' ) ;
if ( prefix . endsWith ( '.' ) && abbreviation . test ( prefix ) ) continue ;
return end ;
}
return - 1 ;
}
function preferredCut ( text , min , target , max ) {
const bounded = text . slice ( 0 , max ) ;
const candidates = [ ] ;
const punctuation = /[,;:—–.!?…]+["')\]}]*(?:\s|$)/g ;
let match ;
while ( ( match = punctuation . exec ( bounded ) ) ) {
const end = match . index + match [ 0 ] . trimEnd ( ) . length ;
if ( end >= min ) candidates . push ( { end : end , weight : Math . abs ( end - target ) } ) ;
}
if ( candidates . length ) {
candidates . sort ( function ( left , right ) { return left . weight - right . weight ; } ) ;
return candidates [ 0 ] . end ;
}
const spaces = [ ] ;
const whitespace = /\s+/g ;
while ( ( match = whitespace . exec ( bounded ) ) ) { if ( match . index >= min ) spaces . push ( match . index ) ; }
if ( spaces . length ) {
spaces . sort ( function ( left , right ) { return Math . abs ( left - target ) - Math . abs ( right - target ) ; } ) ;
return spaces [ 0 ] ;
}
return Math . min ( max , text . length ) ;
}
function adaptiveChunks ( text , final , firstChunk ) {
const source = String ( text || '' ) . trim ( ) ;
if ( ! source ) return { chunks : [ ] , consumed : 0 } ;
const chunks = [ ] ;
let offset = 0 ;
if ( firstChunk !== false ) {
const firstSentence = sentenceEnd ( source ) ;
if ( firstSentence < 0 && ! final ) return { chunks : [ ] , consumed : 0 } ;
const firstAvailable = firstSentence >= 0 ? firstSentence : source . length ;
const firstSlice = source . slice ( 0 , firstAvailable ) ;
const firstCut = firstSlice . length <= 60 ? firstSlice . length : preferredCut ( firstSlice , 40 , 52 , 60 ) ;
chunks . push ( source . slice ( 0 , firstCut ) . trim ( ) ) ;
offset = firstCut ;
}
while ( offset < source . length ) {
const remaining = source . slice ( offset ) . trimStart ( ) ;
offset = source . length - remaining . length ;
if ( ! remaining ) break ;
if ( remaining . length < 100 ) {
const complete = sentenceEnd ( remaining ) ;
if ( ! final && complete < 0 ) break ;
const end = final ? remaining . length : complete ;
chunks . push ( remaining . slice ( 0 , end ) . trim ( ) ) ;
offset += end ;
continue ;
}
const cut = preferredCut ( remaining , 100 , 120 , 140 ) ;
if ( ! final && cut === remaining . length && remaining . length < 140 ) break ;
chunks . push ( remaining . slice ( 0 , cut ) . trim ( ) ) ;
offset += cut ;
}
return { chunks : chunks . filter ( Boolean ) , consumed : offset } ;
}
// Export the deterministic splitter as a narrow diagnostic/test seam.
window . _atlasAdaptiveChunks = function ( text , final ) { return adaptiveChunks ( text , final !== false , true ) . chunks ; } ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
function outputRoutingSupported ( ) {
// The corner output-device selector needs to both ENUMERATE outputs and
// ROUTE to one. Feature-detected so unsupported browsers hide it entirely.
const Context = window . AudioContext || window . webkitAudioContext ;
const md = navigator . mediaDevices ;
const canRoute = ( Context && Context . prototype && typeof Context . prototype . setSinkId === 'function' ) ||
( window . HTMLMediaElement && window . HTMLMediaElement . prototype && typeof window . HTMLMediaElement . prototype . setSinkId === 'function' ) ;
return ! ! ( md && typeof md . enumerateDevices === 'function' && canRoute ) ;
}
async function applyContextSink ( context ) {
if ( ! context || ! selectedOutputSinkId || typeof context . setSinkId !== 'function' ) return ;
try { await context . setSinkId ( selectedOutputSinkId ) ; } catch ( _ ) { }
}
async function acquirePlaybackContext ( ) {
// One shared AudioContext for ALL spoken output. Its native rate is left to
// the browser; the worklet resamples each push from Piper's rate, so cues
// and answers (even at different sample rates) share the exact same sink.
const Context = window . AudioContext || window . webkitAudioContext ;
if ( ! Context ) return null ;
if ( sharedPlaybackContext && sharedPlaybackContext . state !== 'closed' ) {
if ( sharedPlaybackWorklet ) { try { await sharedPlaybackWorklet ; } catch ( _ ) { } }
return sharedPlaybackContext ;
}
let context ;
try { context = new Context ( { latencyHint : 'interactive' } ) ; } catch ( _ ) { context = new Context ( ) ; }
sharedPlaybackContext = context ;
sharedPlaybackWorklet = context . audioWorklet . addModule ( WORKLET _URL ) ;
try { await sharedPlaybackWorklet ; } catch ( error ) {
if ( sharedPlaybackContext === context ) { sharedPlaybackContext = null ; sharedPlaybackWorklet = null ; }
try { context . close ( ) ; } catch ( _ ) { }
throw error ;
}
await applyContextSink ( context ) ;
return context ;
}
function applyOutputSink ( ) {
// Route the shared context and any in-flight blob element to the chosen sink.
applyContextSink ( sharedPlaybackContext ) ;
if ( currentAudio && typeof currentAudio . setSinkId === 'function' ) {
try { currentAudio . setSinkId ( selectedOutputSinkId || '' ) ; } catch ( _ ) { }
}
}
function closeSharedPlayback ( ) {
const context = sharedPlaybackContext ;
sharedPlaybackContext = null ;
sharedPlaybackWorklet = null ;
if ( context ) { try { context . close ( ) ; } catch ( _ ) { } }
}
2026-08-23 18:29:02 -03:00
function playBlob ( blob , token ) {
2026-08-10 00:42:37 -03:00
return new Promise ( function ( resolve , reject ) {
if ( ! active || token !== generation ) { resolve ( ) ; return ; }
2026-08-23 18:29:02 -03:00
const session = playbackSession ;
2026-08-10 00:42:37 -03:00
const url = URL . createObjectURL ( blob ) ;
const audio = new Audio ( url ) ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// Same chosen output as the PCM path: the WAV fallback element is routed to
// the selected sink so it can never split onto a different device.
if ( selectedOutputSinkId && typeof audio . setSinkId === 'function' ) {
try { audio . setSinkId ( selectedOutputSinkId ) ; } catch ( _ ) { }
}
2026-08-10 00:42:37 -03:00
currentAudio = audio ;
2026-08-23 18:29:02 -03:00
let settled = false ;
function cleanup ( callback , value ) {
if ( settled ) return ;
settled = true ;
2026-08-10 00:42:37 -03:00
if ( currentAudio === audio ) currentAudio = null ;
2026-08-23 18:29:02 -03:00
if ( session && session . blobWake === cancel ) session . blobWake = null ;
2026-08-20 23:13:51 +00:00
indicator . classList . remove ( 'is-playing' ) ;
2026-08-10 00:42:37 -03:00
URL . revokeObjectURL ( url ) ;
2026-08-23 18:29:02 -03:00
callback ( value ) ;
2026-08-10 00:42:37 -03:00
}
2026-08-23 18:29:02 -03:00
function cancel ( ) { cleanup ( resolve ) ; }
if ( session ) session . blobWake = cancel ;
audio . onended = function ( ) { cleanup ( resolve ) ; } ;
audio . onerror = function ( ) { cleanup ( reject , new Error ( 'Local speech playback failed' ) ) ; } ;
2026-08-20 23:13:51 +00:00
audio . play ( ) . then ( function ( ) {
if ( active && token === generation && currentAudio === audio ) indicator . classList . add ( 'is-playing' ) ;
2026-08-23 18:29:02 -03:00
} ) . catch ( function ( error ) { cleanup ( reject , error ) ; } ) ;
2026-08-10 00:42:37 -03:00
} ) ;
}
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
function ttsSpeed ( ) {
// Hands-free speech-rate preference. Piper accepts 0.5-2.0 (length_scale
// 1/speed, no pitch shift); hands-free defaults to a slightly brisk 1.15,
// seeded once at initialize() and user-tunable via localStorage.
const stored = parseFloat ( localStorage . getItem ( 'hermes-voice-tts-speed' ) || '' ) ;
if ( ! Number . isFinite ( stored ) ) return TTS _SPEED _DEFAULT ;
return Math . min ( TTS _SPEED _MAX , Math . max ( TTS _SPEED _MIN , stored ) ) ;
}
2026-08-23 18:29:02 -03:00
function ttsRequest ( chunk , language , turnId ) {
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
const request = { text : chunk , engine : 'atlas' , turn _id : turnId , speed : ttsSpeed ( ) } ;
2026-08-23 18:29:02 -03:00
if ( language ) request . language = language ;
return request ;
}
2026-08-23 22:13:52 -03:00
async function fetchSpeech ( chunk , language , turnId , token ) {
2026-08-21 11:52:49 +00:00
// `language` is only ever the private STT result for this turn. When it is
// absent the field is omitted entirely and the server picks English.
2026-08-23 22:13:52 -03:00
if ( ! active || token !== generation ) throw cancelledError ( 'Speech turn cancelled' ) ;
const session = playbackSession ;
if ( ! session || session . cancelled || session . turnId !== turnId ) throw cancelledError ( 'Speech turn cancelled' ) ;
const controller = createAbortController ( ) ;
session . controllers . add ( controller ) ;
try {
const response = await fetch ( '/api/tts' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( ttsRequest ( chunk , language , turnId ) ) ,
signal : controller . signal ,
} ) ;
if ( ! response . ok ) {
const payload = await response . json ( ) . catch ( function ( ) { return { } ; } ) ;
throw new Error ( payload . error || ( 'Local speech request failed: ' + response . status ) ) ;
}
const blob = await response . blob ( ) ;
if ( ! active || token !== generation || session . cancelled || playbackSession !== session ) throw cancelledError ( 'Speech turn cancelled' ) ;
return blob ;
} finally {
session . controllers . delete ( controller ) ;
2026-08-10 01:21:51 -03:00
}
2026-08-23 22:13:52 -03:00
}
function cuePoolOffset ( turnId , length ) {
let hash = 0 ;
const source = String ( turnId || '' ) ;
for ( let index = 0 ; index < source . length ; index += 1 ) hash = ( ( hash * 31 ) + source . charCodeAt ( index ) ) >>> 0 ;
return length ? hash % length : 0 ;
}
function thinkingCueStillOwned ( cue ) {
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// Mute-aware: a muted conversation (the user stepped away) never chatters.
return ! ! ( cue && ! cue . cancelled && thinkingCue === cue && active && cue . token === generation && state === 'thinking' && ! ( conversation && conversation . muted ) ) ;
2026-08-23 22:13:52 -03:00
}
function scheduleNextThinkingCue ( cue , delay ) {
if ( ! thinkingCueStillOwned ( cue ) || cue . issued >= cue . pool . length ) return ;
cue . timer = window . setTimeout ( function ( ) {
cue . timer = null ;
issueThinkingCue ( cue ) ;
} , delay ) ;
}
async function issueThinkingCue ( cue ) {
if ( ! thinkingCueStillOwned ( cue ) || cue . issued >= cue . pool . length ) return ;
const entry = cue . pool [ ( cue . offset + cue . issued ) % cue . pool . length ] ;
const cueNumber = cue . issued + 1 ;
cue . issued += 1 ;
const controller = createAbortController ( ) ;
cue . controller = controller ;
try {
const request = ttsRequest ( entry . text , cue . language , cue . turnId + ':thinking-cue:' + cueNumber ) ;
request . cue _id = entry . id ;
const response = await fetch ( TTS _STREAM _URL , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' , 'Accept' : 'audio/pcm' } ,
body : JSON . stringify ( request ) ,
signal : controller . signal ,
} ) ;
if ( ! response . ok || ! response . body ) throw new Error ( 'Cached thinking cue unavailable' ) ;
const sampleRate = parseInt ( response . headers . get ( 'X-Audio-Sample-Rate' ) || '22050' , 10 ) ;
if ( ! Number . isFinite ( sampleRate ) || sampleRate < 8000 || sampleRate > 96000 ) throw new Error ( 'Cached thinking cue metadata invalid' ) ;
if ( ! thinkingCueStillOwned ( cue ) ) return ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// The cue plays through the ONE shared spoken-output sink, exactly like the
// reply — never its own AudioContext (which could route to another device).
cue . context = await acquirePlaybackContext ( ) ;
if ( ! cue . context || ! thinkingCueStillOwned ( cue ) ) return ;
2026-08-23 22:13:52 -03:00
cue . node = new AudioWorkletNode ( cue . context , 'atlas-pcm-playback' ) ;
cue . gain = cue . context . createGain ( ) ;
cue . node . connect ( cue . gain ) ;
cue . gain . connect ( cue . context . destination ) ;
const drained = new Promise ( function ( resolve ) {
cue . audioWake = resolve ;
cue . node . port . onmessage = function ( event ) {
if ( ( event . data || { } ) . type === 'drained' ) {
const wake = cue . audioWake ;
cue . audioWake = null ;
if ( wake ) wake ( ) ;
}
} ;
} ) ;
await cue . context . resume ( ) ;
indicator . classList . add ( 'is-playing' ) ;
const reader = response . body . getReader ( ) ;
let carry = null ;
while ( thinkingCueStillOwned ( cue ) ) {
const result = await reader . read ( ) ;
if ( result . done ) break ;
let bytes = result . value ;
if ( carry !== null ) { const joined = new Uint8Array ( bytes . length + 1 ) ; joined [ 0 ] = carry ; joined . set ( bytes , 1 ) ; bytes = joined ; carry = null ; }
if ( bytes . length % 2 ) { carry = bytes [ bytes . length - 1 ] ; bytes = bytes . slice ( 0 , - 1 ) ; }
if ( ! bytes . length ) continue ;
const copy = bytes . buffer . slice ( bytes . byteOffset , bytes . byteOffset + bytes . byteLength ) ;
cue . node . port . postMessage ( { type : 'push' , samples : copy , sampleRate : sampleRate } , [ copy ] ) ;
}
if ( thinkingCueStillOwned ( cue ) ) {
cue . node . port . postMessage ( { type : 'end' } ) ;
await drained ;
} else {
try { await reader . cancel ( ) ; } catch ( _ ) { }
}
cue . controller = null ;
if ( cue . node ) { try { cue . node . disconnect ( ) ; } catch ( _ ) { } cue . node = null ; }
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
if ( cue . context ) { cue . context = null ; }
2026-08-23 22:13:52 -03:00
cue . gain = null ;
indicator . classList . remove ( 'is-playing' ) ;
if ( thinkingCueStillOwned ( cue ) ) scheduleNextThinkingCue ( cue , THINKING _CUE _INTERVAL _MS ) ;
} catch ( error ) {
cue . controller = null ;
if ( cue . cancelled || ( error && error . name === 'AbortError' ) ) return ;
// Cues are optional. A playback or synthesis failure must not disturb the
// answer, retry noisily, or disable the primary hands-free conversation.
if ( thinkingCue === cue ) cancelThinkingCues ( ) ;
}
}
function scheduleThinkingCues ( token , language , turnId ) {
cancelThinkingCues ( ) ;
if ( ! active || token !== generation || state !== 'thinking' ) return ;
// Missing/unknown is the same fail-safe route as answer TTS: English/Amy.
// Known STT languages remain bound exactly to their turn voice.
const localized = normalizeSttLanguage ( language ) || 'en' ;
const pool = THINKING _CUE _POOLS [ localized ] ;
const cue = {
token : token ,
turnId : String ( turnId || token ) ,
language : localized ,
pool : pool ,
offset : cuePoolOffset ( turnId , pool . length ) ,
issued : 0 ,
timer : null ,
controller : null ,
audioWake : null ,
context : null ,
node : null ,
gain : null ,
cancelled : false ,
} ;
thinkingCue = cue ;
scheduleNextThinkingCue ( cue , THINKING _CUE _FIRST _MS ) ;
2026-08-10 01:21:51 -03:00
}
2026-08-23 18:29:02 -03:00
async function prepareSpeech ( chunk , language , turnId , token ) {
if ( streamingCapability . tts && window . ReadableStream && window . AudioWorkletNode ) {
2026-08-23 22:13:52 -03:00
const controller = createAbortController ( ) ;
2026-08-23 18:29:02 -03:00
if ( playbackSession ) playbackSession . controllers . add ( controller ) ;
try {
const response = await fetch ( TTS _STREAM _URL , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' , 'Accept' : 'audio/pcm' } ,
body : JSON . stringify ( ttsRequest ( chunk , language , turnId ) ) ,
signal : controller . signal ,
} ) ;
if ( ! response . ok || ! response . body ) throw new Error ( 'Streaming speech unavailable' ) ;
const sampleRate = parseInt ( response . headers . get ( 'X-Audio-Sample-Rate' ) || String ( streamingCapability . tts . sample _rate || 22050 ) , 10 ) ;
const channels = parseInt ( response . headers . get ( 'X-Audio-Channels' ) || '1' , 10 ) ;
if ( ! Number . isFinite ( sampleRate ) || sampleRate < 8000 || sampleRate > 96000 || channels !== 1 ) throw new Error ( 'Unsupported streaming speech format' ) ;
return { kind : 'pcm' , response : response , sampleRate : sampleRate , controller : controller , token : token , chunk : chunk , language : language , turnId : turnId , started : false } ;
} catch ( error ) {
if ( playbackSession ) playbackSession . controllers . delete ( controller ) ;
if ( error && error . name === 'AbortError' ) throw error ;
// Disable only this optional transport for the browser session. The
// complete WAV endpoint preserves voice quality and availability.
streamingCapability . tts = null ;
}
}
2026-08-24 13:58:35 -03:00
return { kind : 'blob' , chunk : chunk , blob : await fetchSpeech ( chunk , language , turnId , token ) } ;
2026-08-23 18:29:02 -03:00
}
2026-08-23 22:13:52 -03:00
async function ensurePcmPlayback ( asset , token ) {
if ( ! active || token !== generation ) return null ;
const session = playbackSession ;
if ( ! session || session . cancelled ) return null ;
if ( session . node ) {
if ( session . sampleRate !== asset . sampleRate ) throw new Error ( 'Streaming speech sample rate changed mid-turn' ) ;
return session ;
}
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// One shared sink for every spoken sound (reply, cues, blob fallback); the
// worklet resamples Piper's rate to the context's native rate. The shared
// context is never closed per-session — only when hands-free mode ends.
const context = await acquirePlaybackContext ( ) ;
if ( ! context ) return null ;
if ( ! active || token !== generation ) return null ;
2026-08-23 18:29:02 -03:00
const node = new AudioWorkletNode ( context , 'atlas-pcm-playback' ) ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
if ( ! session || session . cancelled ) return null ;
2026-08-23 22:13:52 -03:00
const gain = context . createGain ( ) ;
2026-08-23 18:29:02 -03:00
session . context = context ;
session . node = node ;
2026-08-23 22:13:52 -03:00
session . gain = gain ;
session . sampleRate = asset . sampleRate ;
session . bufferedFrames = 0 ;
session . playbackEnded = false ;
node . connect ( gain ) ;
gain . connect ( context . destination ) ;
2026-08-23 18:29:02 -03:00
await context . resume ( ) ;
2026-08-23 22:13:52 -03:00
session . drained = new Promise ( function ( resolve , reject ) {
2026-08-23 18:29:02 -03:00
session . drainWake = resolve ;
2026-08-23 22:13:52 -03:00
session . drainReject = reject ;
2026-08-23 18:29:02 -03:00
} ) ;
indicator . classList . add ( 'is-playing' ) ;
2026-08-23 22:13:52 -03:00
node . port . onmessage = function ( event ) {
const data = event . data || { } ;
if ( data . type === 'buffer' ) {
session . bufferedFrames = data . frames || 0 ;
if ( session . bufferedFrames < session . sampleRate && session . lowWaterWake ) {
const wake = session . lowWaterWake ;
session . lowWaterWake = null ;
wake ( ) ;
}
} else if ( data . type === 'drained' ) {
session . playbackEnded = true ;
const wake = session . drainWake ;
session . drainWake = null ;
if ( wake ) wake ( ) ;
} else if ( data . type === 'error' ) {
const reject = session . drainReject ;
session . drainReject = null ;
if ( reject ) reject ( new Error ( data . error || 'Streaming speech playback failed' ) ) ;
}
} ;
return session ;
}
async function playPcm ( asset , token ) {
const session = await ensurePcmPlayback ( asset , token ) ;
if ( ! session ) return ;
2026-08-23 18:29:02 -03:00
const reader = asset . response . body . getReader ( ) ;
let carry = null ;
2026-08-10 00:42:37 -03:00
try {
2026-08-23 18:29:02 -03:00
while ( active && token === generation && ! session . cancelled ) {
2026-08-23 22:13:52 -03:00
if ( session . bufferedFrames > asset . sampleRate * 2 ) {
2026-08-23 18:29:02 -03:00
await new Promise ( function ( resolve ) {
let completed = false ;
2026-08-23 22:13:52 -03:00
const wake = function ( ) { if ( completed ) return ; completed = true ; session . lowWaterWake = null ; resolve ( ) ; } ;
2026-08-23 18:29:02 -03:00
session . lowWaterWake = wake ;
window . setTimeout ( wake , 3000 ) ;
} ) ;
}
const result = await reader . read ( ) ;
if ( result . done ) break ;
let bytes = result . value ;
if ( carry !== null ) {
const joined = new Uint8Array ( bytes . length + 1 ) ;
joined [ 0 ] = carry ; joined . set ( bytes , 1 ) ; bytes = joined ; carry = null ;
}
if ( bytes . length % 2 ) { carry = bytes [ bytes . length - 1 ] ; bytes = bytes . slice ( 0 , - 1 ) ; }
if ( ! bytes . length ) continue ;
const copy = bytes . buffer . slice ( bytes . byteOffset , bytes . byteOffset + bytes . byteLength ) ;
2026-08-23 22:13:52 -03:00
session . bufferedFrames += bytes . byteLength / 2 ;
2026-08-23 18:29:02 -03:00
asset . started = true ;
2026-08-23 22:13:52 -03:00
session . node . port . postMessage ( { type : 'push' , samples : copy , sampleRate : asset . sampleRate } , [ copy ] ) ;
2026-08-10 00:42:37 -03:00
}
2026-08-23 18:29:02 -03:00
} finally {
2026-08-23 22:13:52 -03:00
if ( ( ! active || token !== generation || session . cancelled ) ) { try { await reader . cancel ( ) ; } catch ( _ ) { } }
2026-08-23 18:29:02 -03:00
session . controllers . delete ( asset . controller ) ;
}
}
2026-08-23 22:13:52 -03:00
async function drainPcmPlayback ( session , token ) {
if ( ! session || ! session . node || session . cancelled || ! active || token !== generation ) return ;
if ( session . playbackEnded ) return ;
session . node . port . postMessage ( { type : 'end' } ) ;
await session . drained ;
}
async function closePcmBeforeBlob ( session , token ) {
if ( ! session || ! session . node ) return ;
await drainPcmPlayback ( session , token ) ;
if ( session . node ) { try { session . node . disconnect ( ) ; } catch ( _ ) { } session . node = null ; }
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
if ( session . context ) { session . context = null ; }
2026-08-23 22:13:52 -03:00
session . gain = null ;
session . drained = null ;
indicator . classList . remove ( 'is-playing' ) ;
}
2026-08-23 18:29:02 -03:00
async function playPrepared ( asset , token ) {
2026-08-23 22:13:52 -03:00
if ( asset . kind !== 'pcm' ) {
if ( playbackSession && playbackSession . node ) await closePcmBeforeBlob ( playbackSession , token ) ;
return playBlob ( asset . blob , token ) ;
}
2026-08-23 18:29:02 -03:00
try {
return await playPcm ( asset , token ) ;
2026-08-10 00:42:37 -03:00
} catch ( error ) {
2026-08-23 18:29:02 -03:00
if ( asset . started ) throw error ;
streamingCapability . tts = null ;
2026-08-23 22:13:52 -03:00
if ( playbackSession && playbackSession . node ) await closePcmBeforeBlob ( playbackSession , token ) ;
return playBlob ( await fetchSpeech ( asset . chunk , asset . language , asset . turnId , token ) , token ) ;
2026-08-23 18:29:02 -03:00
}
}
function nextSpeechChunk ( turn ) {
if ( turn . cancelled ) return Promise . resolve ( null ) ;
if ( turn . queue . length ) return Promise . resolve ( turn . queue . shift ( ) ) ;
if ( turn . final ) return Promise . resolve ( null ) ;
return new Promise ( function ( resolve ) { turn . waiters . push ( resolve ) ; } ) ;
}
function enqueueSpeech ( turn , chunks ) {
chunks . forEach ( function ( chunk ) {
if ( ! chunk ) return ;
if ( turn . waiters . length ) turn . waiters . shift ( ) ( chunk ) ; else turn . queue . push ( chunk ) ;
} ) ;
}
function finishSpeechQueue ( turn ) {
turn . final = true ;
while ( turn . waiters . length && ! turn . queue . length ) turn . waiters . shift ( ) ( null ) ;
}
2026-08-24 13:58:35 -03:00
function scheduleSpeakingIdleFallback ( turn , session ) {
// Interim-message turns speak in cycles (speak → tools → speak): once no
// further chunk is queued and the turn is not final, fall back to
// Thinking after the buffered audio has played out. The observer flips
// the state back to Speaking when the next segment yields a chunk.
cancelSpeakingIdleFallback ( turn ) ;
const bufferedMs = session && session . sampleRate ? Math . ceil ( ( ( session . bufferedFrames || 0 ) / session . sampleRate ) * 1000 ) : 0 ;
turn . idleTimer = window . setTimeout ( function ( ) {
turn . idleTimer = null ;
if ( ! active || turn . token !== generation || turn . cancelled || speechTurn !== turn ) return ;
if ( turn . queue . length || turn . final ) return ;
if ( state === 'speaking' ) setState ( 'thinking' ) ;
} , bufferedMs + 250 ) ;
}
function cancelSpeakingIdleFallback ( turn ) {
if ( turn && turn . idleTimer ) { window . clearTimeout ( turn . idleTimer ) ; turn . idleTimer = null ; }
}
2026-08-23 18:29:02 -03:00
async function runSpeechQueue ( turn ) {
if ( turn . running ) return ;
turn . running = true ;
2026-08-23 22:13:52 -03:00
playbackSession = { turnId : turn . turnId , controllers : new Set ( ) , context : null , node : null , gain : null , sampleRate : 0 , bufferedFrames : 0 , drained : null , drainReject : null , playbackEnded : false , cancelled : false , lowWaterWake : null , drainWake : null , blobWake : null } ;
2026-08-23 18:29:02 -03:00
const session = playbackSession ;
try {
let chunk = await nextSpeechChunk ( turn ) ;
let current = chunk ? prepareSpeech ( chunk , turn . language , turn . turnId , turn . token ) : null ;
while ( current ) {
const asset = await current ;
// At most one later synthesis request exists while this asset plays.
// If streaming has not produced it yet, the waiter starts it the
// instant a punctuation-safe chunk arrives.
const next = nextSpeechChunk ( turn ) ;
const nextPrepared = next . then ( function ( value ) {
return value ? prepareSpeech ( value , turn . language , turn . turnId , turn . token ) : null ;
} ) ;
2026-08-23 22:13:52 -03:00
// Barge-in can abort both the playing request and its one-ahead request.
// Observe the latter even when the cancelled current turn returns first.
nextPrepared . catch ( function ( ) { } ) ;
2026-08-24 13:58:35 -03:00
turn . speakingChunk = asset . chunk || '' ;
2026-08-23 18:29:02 -03:00
await playPrepared ( asset , turn . token ) ;
2026-08-24 13:58:35 -03:00
turn . lastSpokenChunk = asset . chunk || turn . lastSpokenChunk ;
turn . speakingChunk = '' ;
2026-08-23 18:29:02 -03:00
if ( ! active || turn . token !== generation || turn . cancelled || session . cancelled ) return ;
2026-08-24 13:58:35 -03:00
if ( ! turn . queue . length && ! turn . final ) scheduleSpeakingIdleFallback ( turn , session ) ;
2026-08-23 18:29:02 -03:00
current = await nextPrepared ;
2026-08-24 13:58:35 -03:00
cancelSpeakingIdleFallback ( turn ) ;
if ( current ) setState ( 'speaking' ) ;
2026-08-23 18:29:02 -03:00
}
2026-08-23 22:13:52 -03:00
await drainPcmPlayback ( session , turn . token ) ;
2026-08-23 18:29:02 -03:00
if ( turn . final && active && turn . token === generation && ! turn . cancelled ) restartSoon ( turn . token , 300 ) ;
} catch ( error ) {
if ( active && turn . token === generation && ! turn . cancelled ) {
2026-08-20 23:13:51 +00:00
const message = errorMessage ( error , 'Local speech is unavailable' ) ;
setState ( 'error' , message ) ;
toast ( message ) ;
2026-08-23 18:29:02 -03:00
restartSoon ( turn . token , 500 ) ;
}
} finally {
if ( playbackSession === session ) stopPlayback ( ) ;
if ( speechTurn === turn ) speechTurn = null ;
}
}
2026-08-24 13:58:35 -03:00
function collectAssistantResponse ( ) {
2026-08-23 18:29:02 -03:00
const rows = assistantRows ( ) ;
2026-08-24 13:58:35 -03:00
if ( ! rows . length ) return { text : '' , error : false } ;
const turn = assistantTurnOf ( rows [ rows . length - 1 ] ) ;
const response = readAssistantTurn ( turn ) ;
if ( assistantBaseline ) {
if ( rows . length < assistantBaseline . count ) return { text : '' , error : false } ;
if ( turn === assistantBaseline . row && response . text === assistantBaseline . text ) return { text : '' , error : response . error } ;
}
return response ;
}
function currentAssistantText ( ) {
return collectAssistantResponse ( ) . text ;
2026-08-23 18:29:02 -03:00
}
function ensureSpeechTurn ( token ) {
if ( speechTurn ) return speechTurn ;
2026-08-24 13:58:35 -03:00
const sttDetected = takeSttLanguage ( token ) ;
2026-08-23 18:29:02 -03:00
speechTurn = {
token : token ,
turnId : thinkingTurnId || String ( token ) + '-' + String ( ++ turnSequence ) ,
2026-08-24 13:58:35 -03:00
language : sttDetected ,
sttLanguage : sttDetected ,
2026-08-23 18:29:02 -03:00
sourceText : '' ,
2026-08-24 13:58:35 -03:00
speakingChunk : '' ,
lastSpokenChunk : '' ,
idleTimer : null ,
2026-08-23 18:29:02 -03:00
consumed : 0 ,
first : true ,
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
voiceResolved : false ,
2026-08-23 18:29:02 -03:00
queue : [ ] ,
waiters : [ ] ,
final : false ,
cancelled : false ,
running : false ,
} ;
return speechTurn ;
}
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
function flushRetainedTail ( turn ) {
// An interim message we had BEGUN speaking just stopped being readable — it
// folded into the hidden .assistant-segment-worklog-source when the model's
// tool call began, so collectAssistantResponse() now returns '' or a
// distinct segment. Its retained sourceText still holds an unspoken tail
// (turn.consumed < turn.sourceText.length): speak that tail IN FULL, as
// final-quality chunks from the text the client already holds — never by
// re-reading the now-hidden DOM — so the whole acknowledgement is heard
// before the turn transitions to Thinking or a new segment is chunked.
// Marking it fully consumed makes a transient empty read that later recovers
// the SAME message a no-op instead of a double-speak.
if ( ! turn || typeof turn . sourceText !== 'string' ) return false ;
const tail = turn . sourceText . slice ( turn . consumed ) . trim ( ) ;
if ( ! tail ) { turn . consumed = turn . sourceText . length ; return false ; }
const flushed = adaptiveChunks ( tail , true , false ) ;
enqueueSpeech ( turn , flushed . chunks . length ? flushed . chunks : [ tail ] ) ;
turn . consumed = turn . sourceText . length ;
return true ;
}
2026-08-23 18:29:02 -03:00
function pumpAssistantResponse ( token , isFinal ) {
if ( ! active || token !== generation || ( state !== 'thinking' && state !== 'speaking' ) ) return ;
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if ( isFinal ) {
// The completion callback marks a normally completed response: any
// interrupted-thought stitch from an earlier barge is now stale.
pendingStitch = null ;
}
2026-08-23 18:29:02 -03:00
const currentSession = ( typeof S !== 'undefined' && S . session ) ? S . session . session _id : null ;
if ( thinkingSession && currentSession && thinkingSession !== currentSession ) {
thinkingSession = null ;
thinkingTurnId = '' ;
clearSttLanguage ( ) ;
stopResponseObserver ( ) ;
2026-08-23 22:13:52 -03:00
cancelThinkingCues ( ) ;
2026-08-23 18:29:02 -03:00
cancelSpeechTurn ( ) ;
stopPlayback ( ) ;
restartSoon ( token , 250 ) ;
return ;
}
2026-08-24 13:58:35 -03:00
const response = collectAssistantResponse ( ) ;
if ( response . error ) {
handleAssistantResponseError ( token ) ;
return ;
}
const text = response . text ;
2026-08-23 18:29:02 -03:00
if ( ! text ) {
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// The message we had begun speaking just stopped being readable (an
// interim acknowledgement folded into the hidden worklog source when the
// tool call began). Speak its retained tail in full FIRST, so the
// acknowledgement is never truncated and the queue stays non-empty — the
// idle fallback can no longer drop to Thinking with an unspoken tail out.
// Only for a message we have actually BEGUN speaking (consumed>0): a not-
// yet-started message must not be flushed whole on a transient empty read.
if ( speechTurn && speechTurn . consumed > 0 ) flushRetainedTail ( speechTurn ) ;
2026-08-24 15:46:09 -03:00
if ( isFinal ) {
// First-sentence-stop guard: once a reply has begun speaking, the
// completion callback can fire a frame before the settle re-render
// re-stamps the answer onto data-raw-text, making this read transiently
// empty. Tearing down here would drop every unspoken sentence and jump
// to "Listening" after sentence one (even with the mic muted). While a
// reply is genuinely mid-flight (something was queued/spoken) retry for
// up to ~1s so the WHOLE reply is read, then close the queue cleanly so
// the buffered audio drains instead of a hard restart. A truly empty
// completion — no reply produced at all — still tears down immediately,
// so it never holds the speaking state open across the next utterance.
const midReply = ! ! ( speechTurn && ( speechTurn . consumed > 0 || speechTurn . queue . length || ( speechTurn . waiters && speechTurn . waiters . length ) ) ) ;
if ( midReply && finalizeAttempts < 8 && active && token === generation && ( state === 'thinking' || state === 'speaking' ) ) {
finalizeAttempts += 1 ;
window . setTimeout ( function ( ) { pumpAssistantResponse ( token , true ) ; } , 120 ) ;
return ;
}
finalizeAttempts = 0 ;
if ( midReply ) {
finishSpeechQueue ( speechTurn ) ;
stopResponseObserver ( ) ;
thinkingSession = null ; thinkingTurnId = '' ; clearSttLanguage ( ) ; cancelThinkingCues ( ) ;
return ;
}
thinkingSession = null ; thinkingTurnId = '' ; clearSttLanguage ( ) ; stopResponseObserver ( ) ; cancelThinkingCues ( ) ; restartSoon ( token , 250 ) ;
}
2026-08-23 18:29:02 -03:00
return ;
}
2026-08-24 15:46:09 -03:00
finalizeAttempts = 0 ;
hermes(voice): auto-retry transient provider errors; prime STT acronyms
Diagnosed from a live voice session (e1b9fccb90ef): three turns failed with
raw '**Error:** HTTP 502 ... hermes-{claude,codex}-broker' because the agent
pod hosting the model brokers rolled mid-conversation. The voice client
correctly refused to speak the error envelope, but it then dropped the user's
utterance and forced them to repeat it three times.
Voice: on a TRANSIENT provider error (5xx/502/'error sending request'/timeout),
conversation mode now re-runs the errored turn in place through the app's own
regenerate action (which truncates the errored turn — no duplicate user
message) and stays in Thinking so its cues cover the reconnect gap. Bounded to
MAX_TRANSIENT_RETRIES (2); a non-transient error or an exhausted budget still
drops cleanly to 'let's try that again — listening'. The raw error is never
spoken. New probe scenarios cover retry-then-recover and the bounded-then-drop
path; source contract updated.
STT: the same session mis-transcribed 'CUI' as 'cue'. Prime the default
initial_prompt with the domain acronyms the user uses (CUI, FOUO, DoD, NIST,
CMMC, FIPS, RMF, POA&M, ATO, SBU) so they bias to uppercase forms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 22:10:13 -03:00
// A real answer arrived: the provider recovered, so clear the transient
// retry budget for the next turn.
transientRetryCount = 0 ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
setConversationAssistantCaption ( text ) ;
2026-08-23 22:13:52 -03:00
// Any answer text, even a not-yet-speakable partial clause, owns the audio
// timeline from this point forward. A cue must never overlap or become part
// of the answer queue.
cancelThinkingCues ( ) ;
2026-08-23 18:29:02 -03:00
const turn = ensureSpeechTurn ( token ) ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
if ( ! turn . voiceResolved ) {
2026-08-24 13:58:35 -03:00
// Reply-voice routing, finalized when the first chunk is cut: the
// turn's STT-detected language wins, but script evidence in the reply
// text corrects a wrong or missing detection (a Spanish reply must
// never be spoken by the English Amy voice), and a detection-less turn
// falls back to the reply-text heuristic. A correction on a
// detection-less turn also re-biases the sticky session language so
// the next streaming STT turn recognizes the switch promptly.
2026-08-24 15:46:09 -03:00
// A user-forced language (FIX 3) wins over every auto signal: the reply is
// spoken by the chosen voice regardless of script evidence or detection.
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
const resolved = resolveReplyLanguage ( text , turn . sttLanguage , forcedLanguage ) ;
2026-08-24 13:58:35 -03:00
if ( resolved ) turn . language = resolved ;
2026-08-24 15:46:09 -03:00
if ( ! forcedLanguage && ! turn . sttLanguage && resolved ) sessionLanguage = resolved ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// One audio timeline, one voice: a new logical message after an interim
// fold keeps the language already committed to the queue (see the
// new-message reset below, which sets turn.first but never re-resolves).
turn . voiceResolved = true ;
2026-08-24 13:58:35 -03:00
}
2026-08-23 18:29:02 -03:00
if ( text . length < turn . sourceText . length || ! text . startsWith ( turn . sourceText ) ) {
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
const spokenPrefix = turn . sourceText . slice ( 0 , turn . consumed ) ;
if ( turn . consumed > 0 && spokenPrefix && ! text . startsWith ( spokenPrefix ) ) {
// A DISTINCT new message replaced the one we were speaking: the interim
// acknowledgement folded to the hidden worklog source and the final
// answer rendered as its own segment (the already-spoken interim prefix
// is no longer a prefix of what we read). Speak the interim's retained
// tail in full, then fall through and process `text` as a BRAND-NEW
// message — its chunk offset resets against its OWN text and its audio
// queues AFTER the interim drains. Never advance the interim's consumed
// offset into the new message's text (that resurfaced the tail out of
// order). The voice is kept (turn.voiceResolved), not re-resolved.
flushRetainedTail ( turn ) ;
turn . sourceText = '' ;
turn . consumed = 0 ;
turn . first = true ;
} else if ( turn . consumed > 0 ) {
// Same message, unspoken tail revised by the renderer. Already-spoken
// text is immutable. On the completion callback, advance from the old
// spoken offset and close the queue even when the renderer rewrote an
// earlier span; replaying the revised prefix would be more disruptive
// than preserving the already-heard words.
2026-08-23 18:29:02 -03:00
if ( ! isFinal ) return ;
thinkingSession = null ;
thinkingTurnId = '' ;
stopResponseObserver ( ) ;
const revisedTail = text . slice ( Math . min ( turn . consumed , text . length ) ) . trim ( ) ;
if ( revisedTail ) {
const revised = adaptiveChunks ( revisedTail , true , false ) ;
enqueueSpeech ( turn , revised . chunks . length ? revised . chunks : [ revisedTail ] ) ;
turn . consumed = text . length ;
}
finishSpeechQueue ( turn ) ;
return ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
} else {
turn . sourceText = '' ;
2026-08-20 23:13:51 +00:00
}
2026-08-23 18:29:02 -03:00
}
turn . sourceText = text ;
const remaining = text . slice ( turn . consumed ) . trimStart ( ) ;
const skipped = text . slice ( turn . consumed ) . length - remaining . length ;
const extracted = adaptiveChunks ( remaining , ! ! isFinal , turn . first ) ;
if ( extracted . chunks . length ) {
turn . first = false ;
turn . consumed += skipped + extracted . consumed ;
enqueueSpeech ( turn , extracted . chunks ) ;
setState ( 'speaking' ) ;
runSpeechQueue ( turn ) ;
}
if ( isFinal ) {
thinkingSession = null ;
thinkingTurnId = '' ;
stopResponseObserver ( ) ;
const tail = text . slice ( turn . consumed ) . trim ( ) ;
if ( tail ) { enqueueSpeech ( turn , [ tail ] ) ; turn . consumed = text . length ; }
finishSpeechQueue ( turn ) ;
}
}
function startResponseObserver ( token ) {
stopResponseObserver ( ) ;
// A bounded poll is deliberately used in addition to the upstream
// completion callback. It sees data-raw-text while SSE is still appending,
// so the first complete sentence can reach Piper before generation ends.
responsePollTimer = window . setInterval ( function ( ) { pumpAssistantResponse ( token , false ) ; } , 75 ) ;
}
function speakResponse ( token ) {
pumpAssistantResponse ( token , true ) ;
}
function validStreamingCapability ( payload ) {
2026-08-23 22:13:52 -03:00
if ( ! payload || typeof payload !== 'object' ) return { tts : null , stt : null , preflight : null } ;
2026-08-23 18:29:02 -03:00
const tts = payload . tts && payload . tts . available === true && payload . tts . transport === 'http' && payload . tts . format === 'pcm_s16le' ? payload . tts : null ;
const stt = payload . stt && payload . stt . available === true && payload . stt . transport === 'websocket' && payload . stt . format === 'pcm_s16le' && payload . stt . sample _rate === 16000 ? payload . stt : null ;
2026-08-23 22:13:52 -03:00
const preflight = payload . preflight && payload . preflight . available === true && payload . preflight . path === VOICE _PREFLIGHT _URL && payload . preflight . advisory === true ? payload . preflight : null ;
return { tts : tts , stt : stt , preflight : preflight } ;
2026-08-23 18:29:02 -03:00
}
async function discoverStreamingCapability ( ) {
try {
const response = await fetch ( STREAMING _CAPABILITY _URL , { cache : 'no-store' } ) ;
if ( ! response . ok ) return ;
streamingCapability = validStreamingCapability ( await response . json ( ) ) ;
} catch ( _ ) {
2026-08-23 22:13:52 -03:00
streamingCapability = { tts : null , stt : null , preflight : null } ;
2026-08-10 00:42:37 -03:00
}
}
function activate ( ) {
generation += 1 ;
const token = generation ;
active = true ;
2026-08-20 23:13:51 +00:00
clearErrorTimer ( ) ;
2026-08-21 11:52:49 +00:00
clearSttLanguage ( ) ;
2026-08-24 13:58:35 -03:00
sessionLanguage = '' ;
2026-08-24 15:46:09 -03:00
forcedLanguage = '' ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
selectedOutputSinkId = '' ;
2026-08-24 20:24:31 -03:00
outputSinkUserChosen = false ;
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
outputDevices = [ ] ;
2026-08-24 15:46:09 -03:00
finalizeAttempts = 0 ;
2026-08-10 00:42:37 -03:00
modeBtn . classList . add ( 'active' ) ;
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
toast ( 'Conversation mode on' ) ;
hermes(voice): fix first-word clipping, always-stitch, panel glow, conversation mode
Round 2 from live testing:
- Clipping root cause: the endpointer committed on any 1.1s pause, so a
thinking pause after a sentence opener sent one word; a speculative
Whisper pass over that fragment then stalled the real commit ~3.5s on
the Jetson. Young utterances now hold a 1.8s endpoint until 1.2s of
speech accrues, speculative decode waits for 700ms of speech, resume
is unconditional after any 650ms gap (server-frozen snapshots can
never reach commit), and the noise floor is capped so playback echo
cannot deafen onset. Deterministic capture-continuity probe added.
- Stitching now fires for any barge-cancelled send within 20s,
regardless of partial assistant output.
- The workspace-drawer dark rectangle was our own HUX chrome resolving
undefined theme tokens (--bg-primary) to a flat box; bootstrap.css
bridges the real theme tokens and drops a compositor-hazard
backdrop-filter.
- Conversation mode: full-viewport hands-free overlay with an energy-
driven orb (mic RMS in, TTS activity out), state caption, live
transcript/reply captions, mute and exit controls, focus trap,
Escape, reduced-motion support. Voice lane 253 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 11:21:35 -03:00
openConversationOverlay ( ) ;
2026-08-10 00:42:37 -03:00
if ( typeof window . stopTTS === 'function' ) window . stopTTS ( ) ;
if ( typeof S !== 'undefined' && S . busy ) { setState ( 'thinking' ) ; return ; }
startListening ( token ) ;
}
function onVoiceClick ( event ) {
if ( ! ready ) return ;
event . preventDefault ( ) ;
event . stopImmediatePropagation ( ) ;
if ( active ) deactivate ( true ) ; else activate ( ) ;
}
2026-08-25 00:13:10 -03:00
const CONVERSATION _MIC _SVG = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>' ;
function isEmptyConversationButtonEnabled ( ) {
try { return localStorage . getItem ( 'hermes-voice-mode-button' ) !== 'false' ; } catch ( _ ) { return true ; }
}
function setupEmptyStateConversationButton ( ) {
// New-session affordance: a prominent, centred "Start conversation" button on
// the empty new-chat screen so conversation mode is discoverable without
// hunting for the small composer icon. Created only when voice is available;
// it lives INSIDE #emptyState, so it shows and hides with the new-session
// screen automatically, and it honours the same visibility preference as the
// composer toggle. Clicking it enters conversation mode through the same
// activate() path. Fully guarded so it degrades to nothing if the empty
// state is absent or the DOM shape changes.
try {
const empty = document . getElementById ( 'emptyState' ) ;
if ( ! empty || document . getElementById ( 'btnEmptyConversation' ) ) return ;
const btn = document . createElement ( 'button' ) ;
btn . type = 'button' ;
btn . id = 'btnEmptyConversation' ;
btn . className = 'empty-conversation-btn' ;
btn . setAttribute ( 'aria-label' , 'Start conversation' ) ;
const icon = document . createElement ( 'span' ) ;
icon . className = 'empty-conversation-icon' ;
icon . setAttribute ( 'aria-hidden' , 'true' ) ;
icon . innerHTML = CONVERSATION _MIC _SVG ;
const text = document . createElement ( 'span' ) ;
text . className = 'empty-conversation-label' ;
text . textContent = 'Start conversation' ;
btn . appendChild ( icon ) ;
btn . appendChild ( text ) ;
btn . style . display = isEmptyConversationButtonEnabled ( ) ? '' : 'none' ;
btn . addEventListener ( 'click' , function ( ) { if ( ready && ! active ) activate ( ) ; } ) ;
// Place it under the subtitle, above the suggestion grid, so it sits in the
// vertical centre of the new-session screen. Fall back to the end if the
// grid is not found.
const grid = ( typeof empty . querySelector === 'function' ) ? empty . querySelector ( '.suggestion-grid' ) : null ;
if ( grid && grid . parentNode === empty && typeof empty . insertBefore === 'function' ) empty . insertBefore ( btn , grid ) ;
else empty . appendChild ( btn ) ;
} catch ( error ) { conversationWarn ( 'empty-state conversation button unavailable' , error ) ; }
}
2026-08-10 00:42:37 -03:00
async function initialize ( ) {
try {
const response = await fetch ( '/api/transcribe/capability' , { cache : 'no-store' } ) ;
const capability = await response . json ( ) . catch ( function ( ) { return { } ; } ) ;
if ( ! response . ok || ! capability . available || capability . provider !== 'local_command' ) return ;
ready = true ;
2026-08-23 18:29:02 -03:00
discoverStreamingCapability ( ) ;
2026-08-10 00:42:37 -03:00
if ( localStorage . getItem ( 'hermes-atlas-voice-initialized' ) !== '1' ) {
localStorage . setItem ( 'hermes-atlas-voice-initialized' , '1' ) ;
localStorage . setItem ( 'hermes-voice-mode-button' , 'true' ) ;
localStorage . setItem ( 'hermes-tts-engine' , 'atlas' ) ;
localStorage . setItem ( 'hermes-tts-enabled' , 'true' ) ;
2026-08-23 16:50:48 -03:00
}
// Move existing installations to the lower-latency silence window once;
// subsequent user changes remain untouched.
if ( localStorage . getItem ( 'hermes-atlas-voice-latency-v2' ) !== '1' ) {
localStorage . setItem ( 'hermes-atlas-voice-latency-v2' , '1' ) ;
localStorage . setItem ( 'hermes-voice-silence-ms' , '1100' ) ;
2026-08-10 00:42:37 -03:00
}
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
// Seed the hands-free speech-rate default once; later user changes to
// hermes-voice-tts-speed (clamped to 0.5-2.0) are respected as-is.
if ( localStorage . getItem ( 'hermes-voice-tts-speed' ) === null ) {
localStorage . setItem ( 'hermes-voice-tts-speed' , String ( TTS _SPEED _DEFAULT ) ) ;
}
2026-08-10 00:42:37 -03:00
const selector = document . getElementById ( 'settingsTtsEngine' ) ;
if ( selector && ! selector . querySelector ( 'option[value="atlas"]' ) ) {
const option = document . createElement ( 'option' ) ;
option . value = 'atlas' ;
option . textContent = 'Atlas Jetson (private)' ;
selector . insertBefore ( option , selector . firstChild ) ;
}
modeBtn . style . display = localStorage . getItem ( 'hermes-voice-mode-button' ) === 'false' ? 'none' : '' ;
modeBtn . addEventListener ( 'click' , onVoiceClick , true ) ;
2026-08-25 00:13:10 -03:00
setupEmptyStateConversationButton ( ) ;
2026-08-10 00:42:37 -03:00
window . _applyVoiceModePref = function ( ) {
if ( typeof originalApplyPreference === 'function' ) originalApplyPreference ( ) ;
if ( ready ) {
const enabled = localStorage . getItem ( 'hermes-voice-mode-button' ) !== 'false' ;
modeBtn . style . display = enabled ? '' : 'none' ;
2026-08-25 00:13:10 -03:00
const emptyBtn = document . getElementById ( 'btnEmptyConversation' ) ;
if ( emptyBtn ) emptyBtn . style . display = enabled ? '' : 'none' ;
2026-08-10 00:42:37 -03:00
if ( ! enabled && active ) deactivate ( false ) ;
}
} ;
window . autoReadLastAssistant = function ( ) {
2026-08-23 22:13:52 -03:00
if ( active ) {
if ( suppressAutoRead || bargeCancelPromise || state === 'listening' || state === 'transcribing' ) return ;
if ( state === 'thinking' || state === 'speaking' ) { speakResponse ( generation ) ; return ; }
}
2026-08-10 00:42:37 -03:00
if ( typeof originalAutoRead === 'function' ) originalAutoRead . apply ( this , arguments ) ;
} ;
window . _voiceModeActive = function ( ) { return active ; } ;
window . _voiceModeDeactivate = function ( ) { deactivate ( false ) ; } ;
window . _voiceModeImmediateSend = function ( ) {
if ( active && recorder && recorder . state !== 'inactive' ) recorder . stop ( ) ;
} ;
} catch ( _ ) {
// The upstream browser voice implementation remains available as fallback.
}
}
2026-08-24 15:46:09 -03:00
// Behaviour-neutral instrumentation surface: pure references to the internal
// extraction and one-ahead chunking helpers so a deterministic node probe can
// drive them against a realistic rendered assistant turn (never touched by the
// running app). Regression-locks the caption/TTS extraction contract.
if ( typeof window !== 'undefined' ) {
window . _ _atlasVoiceInternals = {
readAssistantTurn : readAssistantTurn ,
collectAssistantResponse : collectAssistantResponse ,
currentAssistantText : currentAssistantText ,
adaptiveChunks : adaptiveChunks ,
sentenceEnd : sentenceEnd ,
cleanForSpeech : cleanForSpeech ,
hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
width: nav.rail on desktop, the top app titlebar on mobile. The
floating pill that pushed the mobile composer's control row (and the
conversation-mode button) off screen is gone - a fallback exists only
for headless DOMs and is pinned to a top corner, never over the
composer.
- The conversation orb watermark is now the Hermes character avatar
(static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
accented char, so an English reply naming European cities (Zürich,
Málaga) overrode the correct English STT detection and was spoken by
the Spanish voice. Detection now requires density (Cyrillic >=4 at
>=50%, or inverted punctuation / >=2 accents corroborated by Spanish
stopwords); plain English always speaks English, forced language wins,
accent-free Spanish still routes via trusted STT. 294 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:20 -03:00
// Pure, behaviour-neutral reply-language routing helpers so a probe can
// regression-lock FIX 4: plain English (European proper nouns included)
// never leaves the English voice, Cyrillic → ru, clear Spanish → es, and a
// user force always wins.
strongReplyLanguage : strongReplyLanguage ,
detectReplyLanguage : detectReplyLanguage ,
resolveReplyLanguage : resolveReplyLanguage ,
2026-08-24 20:24:31 -03:00
// FIX 2 / overlay-regression seams. openConversationOverlay is exercised
// directly under a hostile navigator (mediaDevices undefined, or
// enumerateDevices rejecting) to prove the full-screen overlay always
// attaches; pickLoudspeakerSink locks the default-to-speaker choice.
openConversationOverlay : openConversationOverlay ,
removeConversationOverlay : removeConversationOverlay ,
hasConversationOverlay : function ( ) { return ! ! conversation ; } ,
pickLoudspeakerSink : pickLoudspeakerSink ,
hermes(voice): interim-ack tail, natural fillers, unified audio + output picker
Three conversation-mode fixes in one pass:
- Interim-acknowledgement truncation: when an interim ack folds into the
hidden worklog segment mid-speech, the retained unspoken tail is now
flushed and spoken in full before the Thinking transition, and a
distinct follow-up message is chunked from its own start and queued
after the interim drains (no more 'stops after the first clause, rest
resurfaces with the next message').
- Natural thinking fillers: brief per-language interjections (Umm/Hmm/
One sec; Mmm/A ver; Хм/Секунду) on genuine >1.9s thinking gaps only,
non-repeating, answer-preempting, mute-aware.
- One unified audio sink for every spoken output (reply, cues, fillers,
WAV fallback) - fixes cues playing the loudspeaker while the reply
used a different output - plus a tidy corner output-device selector
(enumerateDevices + setSinkId, feature-detected, session-only) styled
like the language selector. Also realigns two STT-server decode-param
assertions to the dict form from the STT tuning commit. 284 voice tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 17:32:13 -03:00
// Read-only, behaviour-neutral: lets a deterministic probe observe that the
// interim fold never leaves an unspoken tail outstanding while the state
// has fallen back to Thinking (the dropped-tail regression).
speechTurnSnapshot : function ( ) {
if ( ! speechTurn ) return null ;
return {
state : state ,
consumed : speechTurn . consumed ,
sourceLength : ( speechTurn . sourceText || '' ) . length ,
queue : speechTurn . queue . slice ( ) ,
queueLength : speechTurn . queue . length ,
speakingChunk : speechTurn . speakingChunk || '' ,
waiterLength : speechTurn . waiters . length ,
final : ! ! speechTurn . final ,
} ;
} ,
2026-08-24 15:46:09 -03:00
} ;
}
2026-08-10 00:42:37 -03:00
initialize ( ) ;
} ) ( ) ;