Skip to content

Sessions and the edit layer

Updated View as Markdown

A session is what ambient record leaves on disk: the audio, what the recogniser heard, every subsequent repair, and a markdown export derived from the two. This chapter covers recording one, stopping it, editing it, and exporting it.

ambient record [--name <s>] [--app <bundle-id>]… [--seconds <n>] captures until stopped, then resamples, transcribes and writes a session under ~/Documents/Ambient (AMBIENT_HOME overrides):

2026-08-29T1101-smoke/
  session.json     metadata, written when the capture ends
  raw.jsonl        what the recogniser heard — never rewritten
  edits.jsonl      repairs, speaker names, reverts — append-only
  transcript.md    the export, regenerated on every change
  status           live level meter while recording, then the stage
  audio/room.wav   mono 16 kHz, mic
  audio/call.wav   mono 16 kHz, tap

capture_into owns the tap and preserves native audio while a background worker publishes completed turns to raw.jsonl. Recognition processes bounded blocks; the capture drain does not wait for it. After Stop, the capture half writes the 16 kHz WAVs and session.json, and transcribe_session completes the remaining transcription, speaker labels and transcript.md without replacing published lines. The CLI runs finalization after capture; the menu bar app uses its serial queue, so another recording can start while the previous session finishes. The queue runs each job as ambient finalize <session-dir>, a child process of the same signed executable, because macOS’s allocator keeps the pages a finalize frees and a menu bar app that runs all day would hold them between meetings. The child reports its stage on stdout and exits when its stdin closes, so it never outlives the app. The live pass and the child still take one decoder lane, a flock beside the in-process mutex, so only one recogniser runs at a time.

status records the stage: recording … with the level meter, then finishing, captured, transcribing, separating voices, done — or failed: …. The implementation plan describes the live transcript’s cursor and recovery requirements. A session with session.json and no transcript.md when the app is not running is waiting on a queue that no longer exists; the app re-queues it at its next launch. While a transcriber is working it holds transcribing.lock, a file naming its pid, so the CLI and the app cannot both transcribe one session; a lock whose pid is dead is a crash’s leftovers and is taken over.

Two append-only logs and one derived file, folded on read:

flowchart LR
    raw[("raw.jsonl<br/>append-only<br/>what the recogniser heard")]
    edits[("edits.jsonl<br/>append-only<br/>repairs, names, reverts")]
    fold{{fold}}
    md["transcript.md<br/>derived and disposable"]

    raw --> fold
    edits --> fold
    fold --> md

    revert["a revert appends a record<br/>naming an earlier line"] -.-> edits

Stopping one

ambient stop — with no argument it finds the recording in flight and prints what it stopped:

$ ambient stop
  recording 00:22  room 0.310  call 0.828
/Users/…/Documents/Ambient/2026-08-29T1223-standup

It writes a STOP file that the capture loop notices on its next 200 ms tick. A file rather than a signal because the only launch that gets system audio is LaunchServices’, which has no terminal to Ctrl-C and no pid a user can see — and for the same reason the level meter is mirrored to status, since stderr under open -a goes nowhere. Ctrl-C still works when there is a terminal, and --seconds <n> bounds an unattended run.

Nothing guards against a second recording. record creates its directory (src/session.rs:246) and starts the tap (src/session.rs:248) without ever asking whether one is already live, and live_session — the only way a bare ambient stop finds its target (src/session.rs:948) — collects every session directory whose audio/room.native.wav was written to in the last ten seconds, sorts them and pops (src/session.rs:925).

The sort is lexicographic over the whole directory name, and that name is a minute-resolution timestamp plus, when --name was given, a slug (src/session.rs:240-243). So what pops is the lexicographically last name, which is the newest only when the two recordings started in different minutes; inside one minute the slug decides, and a named session sorts after an unnamed one whatever the clock said. A bare stop reaches whichever that is and leaves the other running, so ambient stop <dir> (src/main.rs:66) is the reliable form when two are in flight.

Two same-minute recordings with the same name — or with no name, which is what both a bare ambient record and the menu bar use — compute the identical id, and used to share the directory it named, because create_dir_all is idempotent. They no longer can. SessionDir::claim is the only constructor and claiming is what creates the directory, with create_dir rather than create_dir_all: an occupied name comes back AlreadyExists instead of succeeding into it, so the second recording retries as -02, -03 and so on up to -99. A SessionDir is therefore evidence that no other recording is writing there, and the suffix is zero-padded because every listing here is a byte sort and an unpadded -10 would sort before -2. The id a session reports is the directory’s own name and never the string that was formatted to ask for it.

That is the shape the sentinel forces rather than an oversight. A STOP file carries no pid, and nothing keeps a registry of live sessions, so liveness is inferred from one file’s modification time and “the newest” is the only question that mechanism can answer. The same check is why a record killed mid-recording does not intercept a later stop: its scratch wavs stay on disk, and without the ten-second window that corpse answers ambient stop, pointing it at a directory nothing is writing to. The menu’s level line used to be exposed to the same thing from the other side: during transcription the scratch wavs are gone, so it fell back to whichever entry in the sessions folder sorted last — a corpse, or app.log, which sorts after every 2… id. It reads the capture worker’s shared Meter now and consults the folder for nothing, which is what let that fallback be deleted rather than repaired; app.log has also moved out to ~/Library/Logs/Ambient/. Clearing a corpse out is under when it does not work.

ambient show <dir> folds edits.jsonl over raw.jsonl; --verbatim skips the fold. Reverts append a record naming an earlier line rather than deleting it, so the raw transcript is always recoverable byte-for-byte — verified by hashing raw.jsonl either side of applying an edit layer.

Track is the attribution record writes on its own, and for a one-to-one call it is already complete: room is you, call is them. Several people round a table or on the same call need ambient diarize.

record writes one record per turn rather than per recogniser call; why that matters, and how Vad::turns differs from Vad::chunks, is in VAD and chunking. The capture layer runs at the device rate and everything downstream demands 16 kHz — see Resampling for how that gap is closed.

Measured on one 19.8 s two-track recording — the same sentence reaching the tap directly and the microphone acoustically:

call (tap, peak 0.869)   "... Priya merged the chunking fix, so the retry-storm ..."
room (mic, peak 0.261)   "... Pre emerge the chunking fix, so the retry storm ..."

Which is the argument for tapping rather than pointing a microphone at a speaker, in one line.

Naming speakers

ambient name <dir> call-1 Priya renames every line currently carrying that label. It appends speaker edits like anything else, so it is undoable, and re-running diarize afterwards leaves the name alone — diarization reverts its own labels but skips any record a person has since named. Without that guard the later edit would win the fold and quietly turn Priya back into call-1.

Export

ambient export <dir> [--out <path>] writes transcript.md, and record, diarize and name each regenerate it, so the file on disk is never stale. It is derived — raw.jsonl plus edits.jsonl stay the only source of truth, and deleting the markdown loses nothing. YAML front matter carries the session metadata, which is what a downstream Confluence pipeline would read — that pipeline lives outside this repo:

---
id: "2026-08-29T1223-standup"
name: "standup"
started_at: "2026-08-29T12:23:47.913732+10:00"
duration_s: 20.0
model: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
speakers: ["Priya", "call-2"]
---

# standup

**[00:00] Priya** (call)
Right, let's start with the Transcoda backlog…

---
_2 room line(s) suppressed as call-track duplicates._

The microphone overhears the call

The room mic picks up the laptop speakers, so on speakerphone every remote utterance is transcribed twice — once badly by the mic, once well by the tap. Both tracks in the recording above carried all the same lines. Merging them without dedup produces a document that reads as broken, and nothing errors: the file is just wrong.

dedup_bleed drops a room line when a call line overlaps it by at least half the shorter span and shares at least 60% of the shorter line’s words, after lowercasing and stripping punctuation. Only room lines are ever dropped — the call track is both the better copy and the side that cannot be reconstructed if it goes missing.

The threshold has to survive two independent ASR passes disagreeing (retry storm against retry-storm, Transcoda against Transcoder) without eating genuine cross-talk, which is the failure that would matter. Both cases are unit tests, and the fixture strings are real ones the two tracks produced.

Room noise does not merely add junk — it derails the decode

Silero reports 0.6–0.9 on quiet room noise, confidently enough that no probability threshold separates it from real speech at 0.92–1.00. So a VAD turn would open in noise and run into the speech, and Parakeet given that prefix did not transcribe the speech plus some junk — it invented a different sentence outright. The same audio, measured:

Fed to the recogniser Out
2.6 s of room noise + the utterance “The gap had not closed. If anything, it had wide.”
the utterance alone “The migration is scheduled for Thursday morning.”

Level separates a turn’s quiet EDGES from its speech, and trimming them matters because a noise prefix does not merely add junk — it derails the decode. So each turn is trimmed on level before the recogniser sees it.

The floor is relative to the track’s own loudest content, and only that. An absolute floor was tried and removed after it threw away a real recording whole: quiet speech captured across a room measured −42 dB p90 while a genuinely silent room measured −44 dB. Two decibels apart. Any absolute threshold that rejects the empty room also rejects real speech.

Three signals were measured and none of them separates quiet speech from an empty room:

Signal Real quiet speech Hallucination from silence
level (p90) −42 dB −44 dB
Silero probability 0.6–0.9 0.6–0.9
decoder confidence 0.959 0.917

Decoder confidence does catch the derailment case — 0.992 for the trimmed utterance against 0.613 for the same one with noise in front — which is why the trim is worth having. But Parakeet is confidently wrong on pure noise, so a room track that is nothing but noise still reaches the recogniser and can still produce an invented line.

That is a semantic problem, not a threshold problem: it needs something that can read the words. confidence is recorded per line in raw.jsonl to give a downstream cleanup pass something to weigh.

See ADR-0008.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close