Always signal stack

Path 1 — browser microphone to accepted transcript

Capture

Most of the capture code is not transcription. It is refusal. Between the microphone and the language model sit five independent gates, each of which can return the empty string, and every one of them exists because a specific class of false transcript reached a user and was spoken back at them.

frame size 1,280 samples · 80 ms pre-roll 0.35 s utterance bounds 0.45 – 30 s gates 5 hallucination filters 4

01 — The chain

From ArrayBuffer to accepted text

The browser sends raw interleaved int16 frames on the same WebSocket that carries JSON control messages; the gateway distinguishes them by whether the received message has a "bytes" key. Conversion is one line: np.frombuffer(message["bytes"], dtype=np.int16).astype(np.float32) / 32768.0. From that point on, audio is a float array in [-1, 1] and every threshold in the system is expressed in those units.

The capture chain, with every rejection branch drawn A left-to-right chain from browser microphone frames through the wake-word detector, the utterance buffer, a 120 hertz high-pass, a conditional peak normaliser, the Silero voice-activity gate and the Parakeet recogniser to a transcript. Five separate rejection branches drop downward out of the chain: below-threshold wake scores, audio over thirty seconds, low-signal clips shorter than 0.45 seconds, voice-activity below 150 milliseconds, and the transcript filter cascade. accept path mic frames int16 ÷ 32768 wake detector openWakeWord utterance buffer append_float() 120 Hz high-pass one-pole, in Python peak normalise conditional → 0.7 Silero VAD asr-shim :8788 Parakeet :5092 1,280-sample chunks · 0.35 s pre-roll ring pre-roll prepended on fire only if the clip is already loud enough never called if VAD says no reject branches score < 0.55 or < 2 consecutive stay asleep > 30 s buffered max_audio_seconds error + stop listening low-signal skip < 0.45 s, or < 0.8 s & rms < .006 & peak < .06 speech < 150 ms threshold 0.45 fail closed by default empty result + rms ≥ .015 + peak ≥ .18 → retry once with the high-pass disabled stage 6 — transcript filter cascade prompt echo · empty-response vocabulary · CJK / non-English hallucination · low-confidence backchannel runs on the returned string, not the audio; see Figure 5 accepted transcript handed to path 2 everything else resolves to "" and the turn is abandoned before the model is ever consulted
Figure 3. The capture chain and its five exits. Note the shape: the expensive components (Parakeet, then the language model) sit at the far right, and each gate to their left is cheaper than the one after it. The retry edge is the only backward arrow — an empty transcript on audio that looks like speech is re-sent once with the high-pass filter disabled, on the theory that the filter removed the signal.
02 — The wake gate

Waking on hey mycroft

RemoteWakeDetector (gateway/server.py:195) is a thin, stateful wrapper over an openWakeWord ONNX model. It is worth reading closely because it does three things that a naive wrapper does not.

  • It buffers to a fixed inference size. Incoming WebSocket frames are arbitrary lengths; append() accumulates into _pending and only calls model.predict() on exact 1,280-sample chunks — 80 ms at 16 kHz.
  • It requires consecutive hits. A single frame over wake_threshold (0.55) is not enough; _consecutive_hits must reach wake_patience (2), and any below-threshold frame resets it to zero. Two frames is roughly 160 ms of sustained detection.
  • It keeps a pre-roll ring. A 0.35 second trailing buffer is maintained at all times and trimmed sample-accurately by _trim_pre_roll(). When the word fires, the gateway calls consume_pre_roll(), clears the utterance buffer and re-seeds it with that audio — so the syllables spoken before detection completed are not lost.
gateway/server.pylines 244–260 RemoteWakeDetector.append()
required_hits = max(1, int(self.patience or 1))
self.last_fired = False
while self._pending.size >= self.chunk_frames:
    chunk = self._pending[: self.chunk_frames]
    self._pending = self._pending[self.chunk_frames :]
    pred = self.model.predict(float_to_int16(chunk))
    score = float(pred.get(self.model_name, 0.0))
    self.score_frames += 1
    self.last_score = score
    if score >= self.threshold:
        self._consecutive_hits += 1
    else:
        self._consecutive_hits = 0
    if self._consecutive_hits >= required_hits:
        self.last_fired = True
        return True
The highlighted reset is what makes patience mean consecutive rather than cumulative. Without it, a noisy room would accumulate scattered hits and eventually trip the word.

Detection state is fully observable: trace_snapshot() returns the model name, threshold, patience, required hits, current consecutive count, frames scored, last score and total audio seconds. The gateway logs it on every chunk while _wake_listening_allowed() is true, which is how threshold tuning was presumably done.

Model resolution is a small saga

_find_wake_model() searches a list of candidate filenames derived from the configured wake word across several directories, and _download_wake_model() will fetch one if ALWAYS_WAKE_AUTO_DOWNLOAD_MODELS is left at its default of true. If nothing resolves, create_wake_detector() logs a warning and returns None — wake mode then reports wake_unavailable to the browser rather than failing the connection.

03 — Calibration

Every amplitude threshold in the system

Both ends of the connection measure the same two quantities — RMS and peak — on the same [-1, 1] float scale, and both make decisions from them. Collected on one ruler, the constants show the design: the client is permissive about starting, the server is strict about accepting, and the gap between them is where echo and room noise are meant to die.

Level meter: all RMS and peak thresholds in the Always capture path Two segmented meter rails. The upper rail spans RMS values from zero to 0.09 and is marked at 0.0055 where the browser starts recording, 0.006 below which the server skips recognition on short clips, 0.015 above which an empty result is retried, 0.025 below which single-word backchannels are discarded, and 0.08 which is full scale on the browser level indicator. The lower rail spans peak values from zero to 0.35 and is marked at 0.03, 0.06, 0.08, 0.09, 0.18 and 0.30. rail 1 — rms = sqrt(mean(x²)) · full scale 0.09 RMS 0 0.02 0.04 0.06 0.08 0.0055 · browser starts recording 0.006 · server skips ASR on clips under 0.8 s 0.015 · retry the recogniser without the high-pass 0.025 · below this a one-word reply is discarded as echo 0.08 · full deflection on the browser level bar rail 2 — peak = max |x| · full scale 0.35 PEAK 0 0.10 0.20 0.30 0.03 browser speech peak 0.06 server low-signal floor 0.08 minimum raw peak before normalising 0.09 barge-in peak while the assistant is speaking 0.18 retry floor 0.30 backchannel keep-threshold under 1.75 s Amber = decided in the browser. Teal = decided in the gateway. Off-scale: a clip whose peak is under 0.70 is scaled up to exactly 0.70 before encoding.
Figure 4. Every amplitude constant in the capture path on one ruler. Sources: apps/zola/.../voice-controls.tsx:54–64, always_assistant/asr.py:86–90, 130, 239, 244. The clustering just above zero is not an accident — that band is the difference between “the microphone is on” and “a person spoke,” and almost every false transcript this system has produced originated inside it.
always_assistant/asr.pylines 126–131 write_wav_bytes()
# 2) Peak-normalize quiet browser audio so Parakeet doesn't treat real speech
# as silence. Do not boost tiny low-energy clips: that turns room noise into
# plausible one-word hallucinations such as "yeah".
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
if peak > 1e-4 and peak < 0.7 and raw_peak >= 0.08 and raw_rms >= 0.006:
    audio = audio * (0.7 / peak)
Four conditions to apply a single multiplication. The comment records the bug that motivated the last two: gain applied indiscriminately turned room tone into confident one-word transcripts.
04 — The shim

A recogniser that pretends to be another recogniser

asr_shim.py is 227 lines and does something slightly mischievous. The gateway is written to speak Qwen3-ASR's API — an OpenAI chat completion whose user message contains an input_audio part. The shim accepts exactly that shape, throws away the text instruction, extracts the audio, forwards it to Parakeet's ordinary /v1/audio/transcriptions multipart endpoint, and reassembles the answer into a chat completion. Its own header says it plainly:

“Shim: speaks Qwen3-ASR's OpenAI-compat chat-completion-with-audio API, translates to Parakeet's /v1/audio/transcriptions, returns chat-completion shape. always-gateway calls this on :8788 and doesn't know the difference.”
asr_shim.py, lines 2–4 — verbatim

The reason it exists is the second half of the file. Parakeet 0.6B, given a near-silent clip, does not return nothing — it returns a plausible short English phrase. So the shim loads Silero VAD lazily via torch.hub, resamples to 16 kHz by linear interpolation, sums the durations of all detected speech segments, and refuses to call Parakeet at all unless that total reaches VAD_MIN_SPEECH_MS. The default failure mode is notable:

asr_shim.pylines 29–32, 61–66 fail-closed by default
VAD_THRESHOLD      = float(os.environ.get("VAD_THRESHOLD", "0.45"))
VAD_MIN_SPEECH_MS  = int(os.environ.get("VAD_MIN_SPEECH_MS", "150"))
VAD_ENABLED        = os.environ.get("VAD_ENABLED", "1") == "1"
VAD_FAIL_OPEN      = os.environ.get("VAD_FAIL_OPEN", "0") == "1"

def _vad_error_result(reason, duration_s=0.0):
    if VAD_FAIL_OPEN:
        log.warning("VAD %s; fail-open forwards audio to Parakeet", reason)
        return True, duration_s, 0.0
    log.warning("VAD %s; fail-closed returns empty transcript", reason)
    return False, duration_s, 0.0
If the VAD model fails to load, or the WAV fails to decode, or inference raises, the default is to transcribe nothing. A broken VAD makes the assistant deaf rather than hallucinating — a deliberate choice, and the opposite of what most pipelines do.

The shim's health endpoint returns the full gate configuration, including whether the model actually loaded. It is the only way to distinguish “VAD is filtering correctly” from “VAD never loaded and is silently rejecting everything”:

asr-shim gate configuration — asr_shim.py:148
$ curl -s http://127.0.0.1:8788/healthz | python3 -m json.tool
{
    "ok": true,
    "upstream": "http://127.0.0.1:5092/v1/audio/transcriptions",
    "vad_enabled": true,
    "vad_loaded": true,
    "vad_fail_open": false,
    "vad_threshold": 0.45,
    "vad_min_speech_ms": 150,
    "parakeet_model": "parakeet-tdt-0.6b-v3"
}

$ curl -s http://127.0.0.1:8788/v1/models | python3 -m json.tool
{
    "data": [
        {"id": "qwen3-asr-1.7b", "object": "model", "owned_by": "parakeet-shim"},
        {"id": "parakeet",       "object": "model", "owned_by": "parakeet-shim"}
    ]
}
The shim advertises qwen3-asr-1.7b — a model it does not have — because the gateway's default ALWAYS_QWEN3_ASR_MODEL is that string. The impersonation is complete down to the model listing. vad_loaded: false with vad_fail_open: false is the silent-deafness state described above.
05 — The cascade

Four ways a returned string is thrown away

Audio-domain gates cannot catch a recogniser that returns confident nonsense. So transcribe_audio_qwen3() ends with a text-domain cascade — four predicates, each of which logs a distinct warning and returns the empty string. Every one names a specific observed failure.

The transcript filter cascade A left-to-right cascade. A raw recogniser string is first cleaned of an asr_text tag and a leading language marker, then rewritten by five regular-expression corrections, then tested by four rejection predicates in order: prompt echo, empty-response vocabulary, non-English hallucination, and low-confidence backchannel. Each predicate has a downward branch into a discard bin listing the exact strings or conditions it matches. raw string from the recogniser clean strip <asr_text>, "language xx" normalise 5 regex corrections prompt echo? 4 fragments empty reply? 11 phrases non-English? CJK + 4 strings backchannel? 21 words + level discarded "what does the speaker say" "return only the english words" "if there is no clear english" "return nothing" discarded nothing · none · empty no speech · inaudible no english speech · n/a no clear english speech discarded any U+3400–U+9FFF 慢慢嘴 · 中文字幕 字幕 · 谢谢观看 discarded if… the whole utterance is one of: yeah, yep, ok, thanks, you, i, uh, um, hmm, right, sure, mhm … (21 total) AND it is short or quiet accepted handed to the model the five corrections, verbatim voice demon → voice daemon    pi voice demon → pi voice daemon q when → qwen    queue when → qwen    codex → Codex a domain-specific vocabulary patch: the words this user says most often are the ones the recogniser gets wrong Order matters: cleaning runs before matching, so a reply wrapped in an <asr_text> tag is still caught by the empty-response set, and the level-dependent backchannel test runs last because it needs the audio statistics. four predicates · asr.py lines 184–203, applied at 268–285
Figure 5. The transcript filter cascade from always_assistant/asr.py. The last gate is the only one that consults the audio: a single-word backchannel is kept only if the utterance ran at least 1.25 s, and between 1.25 and 1.75 s it must additionally clear RMS 0.025 and peak 0.30. Anything quieter is treated as the assistant hearing its own playback.

The cost of a strict cascade

Every predicate here can also reject a real utterance. A genuine one-word “yeah” spoken quickly and quietly is indistinguishable, at this layer, from echo — and is dropped. The code accepts that trade explicitly; the comment at asr.py:84–85 reads “Single-word backchannels are common ASR/TTS echo artifacts. Require a longer and stronger standalone utterance before treating one as intent.”