Skip to content

15. Capture and transcription are separate work, joined by a serial queue

Updated View as Markdown

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.json are 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.json is written at the end of capture, not after ASR. A session with session.json and no raw.jsonl is captured and waiting, not interrupted, and the window says so.
  • status gains a value, captured, between finishing and transcribing.
  • The phase’s Transcribing variant is now Stopping: 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.json and no transcript.md at launch (captured_awaiting_transcript), so the state can exist but cannot persist. That rule ignores raw.jsonl on purpose — a transcriber that died after creating it must be re-run, not skipped. What separates a dead transcriber from a live one is transcribing.lock, a file holding the transcriber’s pid: transcribe_session takes 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 status file says failed: … from the moment it happens.
  • transcribe_session creates raw.jsonl before 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close