---
title: "15. Capture and transcription are separate work, joined by a serial queue"
---

> Documentation Index
> Fetch the complete documentation index at: https://jasonm4130-labs.github.io/ambient/llms.txt
> Use this file to discover all available pages before exploring further.

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

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

**Status:** accepted · **Date:** 2026-09-02 · **Supersedes:** —

**Update, 2026-09-21:** recordings now publish completed turns during capture.
The [live transcription plan](../plans/2026-09-21-realtime-transcription.md)
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](0005-cpu-not-coreml.md)), 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 `Live`s 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.

Source: https://jasonm4130-labs.github.io/ambient/adr/0015-capture-and-transcription-are-separate/index.mdx
