Always signal stack

Arbitration — who is allowed to interrupt whom

Barge-in

An assistant with a speaker and an open microphone hears itself. Every interruption is therefore an attribution problem before it is a control problem: was that a person, or was that us? Always answers it four times, in four different layers, and only the last of them looks at words.

sustained frames 3 ≈ 380 ms peak while speaking 0.09 RMS while speaking 0.02 self-echo overlap 0.85 playback lockout 1,800 ms

01 — The machine

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.

Turn state machine and the barge-in arbitration path Above, a four-state machine: idle, listening, thinking and speaking, with transitions for start listening, wake detection, stop listening, the first audio chunk, the final chunk with playback finished, and an interrupt or reset returning to idle from anywhere. Below, the arbitration chain taken when audio arrives while the assistant is speaking: the client requires three consecutive frames above peak 0.09, then flags the request as a barge-in candidate; the gateway rejects clips that are too short or too quiet outright, rejects empty transcripts, rejects transcripts that overlap the assistant's own last answer by 85 percent or more, and only otherwise confirms the interruption. state machine — every edge emits a state frame to the browser idle mic open, nothing buffered listening append_float() thinking turn in flight speaking is_speaking = true start_listening or wake_detected stop_listening ≥ 0.45 s buffered first audio_chunk index 0 is_final_chunk + playback_finished  ·  or interrupt_playback / reset_session from any state > 30 s buffered → error, drop to idle barge-in confirmed — this edge is the whole problem arbitration — how that amber edge is decided client detector 3 consecutive frames peak ≥ 0.09 start_listening barge_in_candidate: true the gateway trusts this flag gate A — signal long enough? loud enough? checked before ASR runs gate B — transcript did ASR return anything? "ignored empty barge-in" gate C — attribution _looks_like_self_echo() compares against last_answer barge_in_ignored → the assistant keeps talking is_speaking restored to true, device state re-sent as "speaking" the turn that would have been created is discarded before it exists barge_in_confirmed is_speaking = false turn proceeds normally The flag is one-shot: _handle_barge_in_result() clears state.barge_in_candidate on entry, so a second utterance in the same turn is treated as an ordinary request rather than an interruption. It is also force-cleared on turn completion, on failure recovery, on interrupt_playback, and on reset_session — eight call sites in total. gateway/server.py 2461–2524 · always_assistant/session_engine.py 66–84, 242–256
Figure 10. The state machine and its one contested edge. Note that gates A, B and C all fail closed — into 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.

02 — The client detector

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:

apps/zola/app/components/suggestions/voice-controls.tsx lines 1376–1391
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
  )
}
A decay rather than a reset. A hard reset would drop the count on any single quiet frame, which real speech produces constantly at plosives and word gaps. Decrementing tolerates those while still refusing to accumulate isolated spikes — three loud frames separated by three quiet ones nets zero.

The constants are annotated in the source with their derivations, which is unusual and worth preserving here verbatim:

Client-side timing constants and the reasoning attached to them
ConstantValueComment in the source
BARGE_IN_PEAK_THRESHOLD0.09“Room/speaker echo of our own TTS typically falls well under this; actual user speech over TTS clears it easily.”
BARGE_IN_FRAMES3“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_MS600“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_MS360
SELF_PLAYBACK_SUPPRESSION_MS1800
SPEECH_THRESHOLD / SPEECH_PEAK_THRESHOLD0.0055 / 0.03raised 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.

03 — The loop

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.

The acoustic echo loop and its four interception points A closed loop from synthesised audio through the speaker, the room, and back into the microphone. Four numbered traps sit on the loop: a Chrome acoustic echo cancellation loopback that routes locally generated audio through two peer connections so the browser treats it as a remote stream, a PulseAudio echo-cancel module using the WebRTC method on the desktop daemon host, raised amplitude thresholds with a three-frame requirement in the client detector, and a server-side transcript overlap test against the assistant's own last answer. Kokoro WAV audio_chunk, base64 speaker AudioContext → sink the room the part nobody controls microphone getUserMedia, 16 kHz frame analysis rms, peak, counters a "new" utterance which may be us and round again 1 Chrome AEC loopback TTS routed through two local RTCPeerConnections so Chrome treats it as remote-participant audio and subtracts it from every mic capture — including Web Speech's 2 PulseAudio module-echo-cancel aec_method=webrtc, noise_suppression=1, voice_detection=1 analog and digital gain control both disabled loaded by ExecStartPre — applies to the on-device daemon, not the browser path 3 3 — raised thresholds while speaking RMS 0.0055 → 0.02, plus peak ≥ 0.09 sustained over 3 frames acoustic residual that survives traps 1 and 2 is usually below this 4 4 — semantic attribution, server side the transcript is compared with the assistant's own last answer the only trap that can catch echo which survived all three acoustic layers Traps 1 and 3 run in the browser; trap 2 runs only on the host that owns a physical microphone; trap 4 runs in the gateway and is the last line.
Figure 11. The feedback loop and its interception points. Trap 2 is worth calling out precisely: the PulseAudio module is loaded by 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.

04 — The last trap

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:

always_assistant/session_engine.pylines 69–84 _looks_like_self_echo()
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
Rule 3 is order-insensitive and set-based, so word repetition in the answer is free. Four or more transcript tokens, 85% of which appear anywhere in the last answer, is judged to be echo. Since assistant replies are constrained to “1 to 3 short spoken sentences,” the answer's vocabulary is small — which makes false positives on genuine short replies to the assistant a real possibility.

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.