Four states and one arbitration
The gateway tracks four device states — idle, listening,
thinking, speaking — and publishes every transition to the browser as a
state frame. Ordinary transitions are unremarkable. The interesting edge is the one that
leaves speaking, because the gateway cannot know from the audio alone whether it should
be taken.
barge_in_ignored, where the assistant resumes speaking.
The design bias is that a missed interruption is an annoyance and a false interruption is a broken
conversation.Detection lives in the browser
The gateway never decides that a barge-in happened. It receives a boolean —
state.barge_in_candidate = bool(data.get("barge_in_candidate", False)) — on the
start_listening frame and arbitrates from there. All the frame counting, threshold
comparison and decay happens in voice-controls.tsx. A client that always sends
true would put the gateway permanently in arbitration mode; a client that never sends it
disables echo protection at gates A–C entirely.
Three frames, and a decay instead of a reset
While the assistant is speaking, the browser raises its bar twice: the RMS requirement goes from
SPEECH_THRESHOLD (0.0055) to 0.02, and a separate peak requirement of
BARGE_IN_PEAK_THRESHOLD (0.09) applies. Both must persist. The counting rule is the part
worth copying:
if (peak >= BARGE_IN_PEAK_THRESHOLD) { bargeInFrameCountRef.current += 1 if (bargeInFrameCountRef.current >= BARGE_IN_FRAMES) { console.info(`[voice] barge-in! peak=… rms=… frames=…`) bargeInFrameCountRef.current = 0 stopPlayback("interrupt") resumeRecognitionAfterPlayback() } } else { // Quiet frame — decay count so noise spikes don't accumulate. bargeInFrameCountRef.current = Math.max( 0, bargeInFrameCountRef.current - 1 ) }
The constants are annotated in the source with their derivations, which is unusual and worth preserving here verbatim:
| Constant | Value | Comment in the source |
|---|---|---|
BARGE_IN_PEAK_THRESHOLD | 0.09 | “Room/speaker echo of our own TTS typically falls well under this; actual user speech over TTS clears it easily.” |
BARGE_IN_FRAMES | 3 | “Each frame is ~128ms at 16kHz/2048-sample blocks, so 3 frames ≈ 380ms — long enough to ignore stray clicks or AEC residual but short enough to feel immediate.” |
SILENCE_MS | 600 | “Was 1500ms back when we needed a long timeout to avoid Parakeet hallucinating on near-silent fragments. Backend audio stats and filler filters now reject short junk clips server-side, so the client can end turns quickly.” |
MIN_SPEECH_MS | 360 | — |
SELF_PLAYBACK_SUPPRESSION_MS | 1800 | — |
SPEECH_THRESHOLD / SPEECH_PEAK_THRESHOLD | 0.0055 / 0.03 | raised to 0.02 RMS while assistantSpeaking |
The SILENCE_MS comment is a small piece of architectural history: a client-side timeout
was shortened by 900 ms because a server-side filter cascade (Figure 5) took over the job it
had been doing. The two layers were tuned against each other.
Four places the assistant's own voice is stopped
Speaker to room to microphone is a physical feedback path that no amount of application logic can remove. Always attacks it at four points, two of them acoustic and two of them semantic.
ExecStartPre on
pi-voice-daemon.service and by bin/hermes-voice, both of which serve
hermes_voice_daemon.py — the always-listening on-device program, not the browser
gateway. A browser client on another machine gets traps 1, 3 and 4 only.“RTCPeerConnection loopback hack — exposes locally-generated audio to Chrome's AEC engine as if it were a remote stream, so the AEC reference signal includes our TTS playback and the engine subtracts it from all mic captures (including Web Speech's hidden one).”apps/zola/.../voice-controls.tsx, lines 160–165 — verbatim
The implementation is defensive in a way that suggests it has failed in the field: it checks that the
destination produced audio tracks, that the hidden <audio> element actually played,
and that the AudioContext reached the running state, resuming it twice if
necessary. Any of those failing sets aecLoopbackReadyRef back to false and falls back to
ordinary playback — with trap 1 silently gone and traps 3 and 4 carrying the whole load.
Comparing what was heard to what was just said
When acoustic cancellation fails, the assistant transcribes itself. _looks_like_self_echo()
catches that by comparing the new transcript with self.last_answer. Both strings are
reduced to lower-case alphanumeric words, and three rules are tried:
normalized_transcript = cls._normalized_echo_text(transcript) normalized_answer = cls._normalized_echo_text(spoken_answer) if not normalized_transcript or not normalized_answer: return False if normalized_transcript == normalized_answer: # rule 1 — identical return True if len(normalized_transcript) >= 24 and normalized_transcript in normalized_answer: return True # rule 2 — a long substring transcript_tokens = normalized_transcript.split() answer_tokens = set(normalized_answer.split()) if len(transcript_tokens) < 4 or not answer_tokens: return False overlap = sum(1 for token in transcript_tokens if token in answer_tokens) return (overlap / len(transcript_tokens)) >= 0.85 # rule 3 — bag of words
The check only runs when barge_in_candidate is set, so it costs nothing on an ordinary
turn and cannot suppress an unrelated utterance spoken while the assistant is silent. It also runs
before the early-transcript callback that shows the user what was heard: an echo that is going
to be discarded never appears in the interface at all.
The frames the browser gets back
Both outcomes are explicit rather than silent. barge_in_ignored carries
reason, the offending transcript, session_id and the full
metrics block; barge_in_confirmed carries the transcript, session id and metrics. Both
are in the WebSocket replay allow-list, so a client that reconnects mid-turn is told how the
arbitration went rather than having to infer it from the state stream.