Status: accepted · Date: 2026-09-02 · Supersedes: —
Update, 2026-09-21: recordings now publish completed turns during capture. The live transcription plan extends this decision with bounded background processing and stable append cursors. The original rationale and measurements below are retained as history.
Context and Problem Statement
Back-to-back meetings lost the start of the second one. session::record_into
was one blocking call — capture, resample, ASR, diarisation, transcript — on
one thread, and the menu bar’s state machine owned exactly one of them. For
the minutes after Stop, Start was a no-op and the watcher did not fire, so a
call starting in Zoom was not even noticed, let alone offered.
The obvious fix, transcribing beside a live capture, has a cost the wait does not: ASR runs on CPU (ADR-0005), and a model saturating cores next to a tap that must be drained every 200 ms risks dropped audio. A late transcript is an inconvenience; a hole in the recording is the only copy of what was said, gone.
Considered Options
- Keep one pipeline, allow a second worker. Two
Lives in the phase, two taps possible at once, and transcription of the first running under the capture of the second. Rejected: it is exactly the contention above, and it doubles every invariant the state machine exists to hold. - Split capture from transcription; transcribe concurrently. Same contention, less state. Rejected for the same reason.
- Split capture from transcription; transcribe on a serial queue. Capture
finishes when the 16 kHz audio and
session.jsonare on disk. Transcription runs afterwards, one session at a time, on one long-lived thread. Chosen.
Decision Outcome
capture_into and transcribe_session are separate functions, and
record_into is their composition for the CLI. In the menu bar app the phase
returns to Idle the moment capture finishes, and the session goes onto
queue::Queue, which transcribes one job at a time and reports each back
through the delegate’s tick. One transcription at a time, never blocking a new
recording.
Be precise about what that guarantees. During back-to-back meetings one ASR
pass does run beside the live tap; the serial queue rules out two. What
protects the capture is the ring and the scheduler, not the queue: the tap’s
ring holds 30 s of mic and 60 s of call audio before anything is overwritten,
the drain loop wakes every 200 ms, and the queue thread runs at background
QoS so the drain loop wins every contest for a core. Measured on the M5 with
drainbench — the same 200 ms loop, with Parakeet decoding in a loop on a
background-QoS thread: worst overshoot 5.15 ms under ASR against 5.02 ms
idle, p99 5.11 ms against 5.02 ms, while ASR still ran at 37× realtime. The
budget is 30 000 ms. That is three orders of magnitude of headroom, which is
why the queue is not paused during capture.
The one fact that decided it: the tap is the only thing that cannot be redone. Everything downstream of it can wait — and, measured, it does not even need to.
Consequences
session.jsonis written at the end of capture, not after ASR. A session withsession.jsonand noraw.jsonlis captured and waiting, not interrupted, and the window says so.statusgains a value,captured, betweenfinishingandtranscribing.- The phase’s
Transcribingvariant is nowStopping: stop signalled, audio still being written, seconds not minutes. Transcription is not a phase. - A crash between capture and transcript leaves audio on disk with no
transcript. The app re-queues every session with
session.jsonand notranscript.mdat launch (captured_awaiting_transcript), so the state can exist but cannot persist. That rule ignoresraw.jsonlon purpose — a transcriber that died after creating it must be re-run, not skipped. What separates a dead transcriber from a live one istranscribing.lock, a file holding the transcriber’s pid:transcribe_sessiontakes it before writing anything and refuses a session whose holder is alive, and the launch scan skips those too. A lock naming a dead pid is a crash’s leftovers and is taken over. - Quit waits for the queue as it already waited for the capture. The watcher does not start a recording while a quit is pending.
- A transcript that fails while the app is busy with the next recording is
held and applied on the first idle tick, so it is drawn and dismissable
like a capture failure; the session’s
statusfile saysfailed: …from the moment it happens. transcribe_sessioncreatesraw.jsonlbefore it loads the models, so the window’s “Interrupted” test — which reads its absence — is right within milliseconds of transcription starting rather than after a model load.
Confirmation
queue::tests drive the queue with a fake worker: order, the menu summary,
failure reporting, and a dead worker. state::tests cover the quit table with
a busy queue and where a failed transcript may enter Failed.
menubar::tests::a_session_being_transcribed_does_not_deafen_the_watcher is
the issue itself. windowcheck still walks a recording through Stop.
cargo run --release --bin drainbench re-measures the drain loop under ASR
and fails if the worst overshoot approaches the ring; run it on a new machine
or after changing the queue thread’s QoS. What no check covers is two real
recordings back to back in the bundle; that is a person with Zoom open.