Everything below was measured on the home machine — M5 Max, 128 GB, macOS 26.6.2 — on 2026-08-29. Any table measured differently says so.
End to end: decode, realtime and peak RSS
v3-int8, synthesised speech via say.
| Audio | Decode | Realtime | Peak RSS |
|---|---|---|---|
| 5 s | 0.12 s | 42× | — |
| 16.5 s | 0.32 s | 52× | — |
| 127.5 s (chunked) | 2.38 s | 54× | 2317 MB |
Peak memory on the 127 s file is flat at the 30 s-chunk level rather than the ~3.7 GB an unchunked 120 s run needed, which is the chunking working.
Memory gate
Measured 2026-10-08 on the M5 Max with scripts/memory --budget 99999, which
runs src/bin/memrun.rs under /usr/bin/time -l: AMI TS3003a Mix-Headset, all
1505.6 s, resampled to 48 kHz and streamed in 200 ms drains into
LiveTranscriber on both tracks at 15× realtime, then Stop and
session::transcribe_session, with finalize in-process. These runs predate
disabling prepacking on the Parakeet sessions (#103) and streaming
live::repair_from_native (#100), both now on main.
| Run | Peak footprint | Peak RSS | Wall |
|---|---|---|---|
| 1 | 2204 MiB | 2570 MiB | 180 s |
| 2 | 2012 MiB | 2359 MiB | 307 s |
Run 2 shared the machine with another replay, which is the wall-time
difference; the footprint spread is the run-to-run noise the budget has to
clear. A third run against --budget 1434, also from before #100 and #103
landed, failed with ERROR peak footprint 2144 MiB is over the 1434 MiB budget.
The same replay on main after #100 and #103 landed, before #107 moved the app’s finalize into a child process:
| Run | Peak footprint | Peak RSS | Wall |
|---|---|---|---|
| 1 | 859 MiB | 1312 MiB | 525 s |
| 2 | 1116 MiB | 1471 MiB | 418 s |
Run 2 ran under heavy load from other work (load average 12 to 27), so the
257 MiB spread may be wider than on a quiet machine. scripts/memory fails
above 1320 MiB, about 200 MiB over the higher run (--budget or
AMBIENT_MEMORY_BUDGET_MIB overrides it). That is already under the 1434 MiB
(1.4 GiB) target. Lower the default to a fresh
measurement on current main, never raise it to make a change pass. The menu
bar app now finalizes in a child process (#107; see
Idle memory after finalize),
but the replay still finalizes in process: the gate measures one process that
records and then finalizes. It does not measure the peak of the app’s finalize
child on its own.
Phase 0: provider comparison, CPU against CoreML
Parakeet TDT 0.6b encoder, 60 s of audio, via
cargo run --release --bin bench -- <encoder.onnx> <coreml|cpu> [seconds].
Input is a zeroed tensor of the correct shape — wall clock for a fixed-shape
graph is content-independent, so the comparison holds, but these are not
accuracy numbers.
| Model | Provider | Steady run | Realtime | Peak RSS |
|---|---|---|---|---|
| v2 fp16 | CPU | 1013 ms | 59× | 3745 MB |
| v2 fp16 | CoreML (ANE requested) | 1012 ms | 59× | 3767 MB |
| v3 int8 | CPU | 1029 ms | 58× | 2594 MB |
| v3 int8 | CoreML (ANE requested) | 1670 ms | 36× | 16482 MB |
On fp16 CoreML is identical to CPU to within a millisecond. On int8 it is actively harmful — 1.6× slower and 16.5 GB peak, which would OOM a 16 GB machine outright.
Memory scales with audio length
v3 int8 on CPU.
| Audio | Steady run | Realtime | Peak RSS |
|---|---|---|---|
| 30 s | 488 ms | 62× | 2158 MB |
| 60 s | 1029 ms | 58× | 2594 MB |
| 120 s | 2252 ms | 53× | 3721 MB |
| 300 s | 7539 ms | 40× | 6291 MB |
Roughly linear in memory and worse than linear in time. Extrapolated, a 30-minute session fed whole would want ~35 GB and would fail on the base M5; chunked at 30 s it is ~60 chunks ≈ 30 s of compute at a flat ~2.2 GB.
VAD chunking: speech, chunks, realtime and RSS
| File | Speech / total | Chunks | Realtime | Peak RSS |
|---|---|---|---|---|
| 127 s continuous | 127.5 / 127.5 s | 6 | 39x | 2255 MB |
| mic track | 3.6 / 9.7 s | 1 | 14x | — |
The VAD pass costs throughput — 39x against 54x without — because it runs one inference per 32 ms frame. The same 127 s file yields 375 words with zero adjacent duplicates once cut on VAD boundaries rather than on local energy.
Embedding front-end: confusion matrix
bin/embtest.rs, on two clips each of two say voices.
A1 A2 B1 B2
A1 1.000 0.880 0.192 0.138
A2 0.880 1.000 0.145 0.132
B1 0.192 0.145 1.000 0.823
B2 0.138 0.132 0.823 1.000
same voice 0.851 cross voice 0.152 separation 0.700A wrong filterbank does not error — it collapses that matrix toward uniform and the damage arrives as confidently mislabelled speakers.
Diarization cost on a 10.2-minute track
Peak RSS 249 MB, 5.9 s wall. The sliding window does not accumulate.
Three signals, none of which 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 |
Two decibels apart, so any absolute level threshold that rejects the empty room also rejects real speech. Decoder confidence does catch the derailment case — 0.992 for the trimmed utterance against 0.613 for the same one with noise in front.
What noise in front of an utterance does to the decode
| 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.” |
A noise prefix does not merely add junk; it derails the decode into a different sentence outright.
Fixtures
Word error rate is measured against LibriSpeech test-clean (CC BY 4.0, openslr.org/12). Build it with:
scripts/fetch-fixtures # cached after the first run
scripts/fetch-fixtures --force # rebuild the stitched fixtureEverything lands in ~/.cache/ambient/, never in the repo: the archive is
330 MB and the licence is the corpus’s, not ours. The archive caches at
~/.cache/ambient/librispeech/; the fixture the harness reads is
~/.cache/ambient/fixtures/wer/, holding a wav and a reference transcript per
speaker and a manifest.json naming both.
The three speakers are the first three directories of test-clean in sorted
order, each one’s first chapter — which audio this scores is a property of the
corpus, not a list someone typed. A speaker’s utterances are concatenated in id
order with 700 ms of silence between them, so the fixture is one long track of
the kind ambient record sees and the gaps give the VAD turn boundaries to
find.
| Speaker | Utterances | Seconds |
|---|---|---|
| 1089 | 38 | 302.11 |
| 1188 | 45 | 522.73 |
| 121 | 15 | 88.89 |
A second run does no network and rewrites nothing; --force restitches from
the extracted corpus without re-downloading it.
Diarization error rate is measured against AMI Mix-Headset audio (CC BY 4.0, groups.inf.ed.ac.uk/ami) with the pyannote only_words references, pinned to that revision. The same command builds it:
scripts/fetch-fixtures # cached after the first run
scripts/fetch-fixtures --force # recut the 300 s clipsThe meetings are ES2004a, IS1009a and TS3003a — the first meeting of
three AMI test-set series, one per recording site, so which meetings this
scores is a property of the corpus and not a list someone typed. The full
recordings cache at ~/.cache/ambient/ami/; the fixture the harness reads is
~/.cache/ambient/fixtures/der/, holding each meeting’s first 300 s as 16 kHz
mono 16-bit wav, the reference RTTM cut to match (turns starting past 300 s
dropped, a turn straddling the cut clipped), and a manifest.json naming both.
All six downloads are pinned by shasum -a 256 in the script and checked on
every run, so an upstream re-encode or a reference edit fails the fetch rather
than moving the DER baseline underneath the quality gate.
This set needs ffmpeg and ffprobe on PATH; the WER set does not, and
still builds without them. A machine missing either is told so on the first
line and gets the WER set only — the DER harness then reports the missing
manifest and names this script.
| Meeting | Seconds | Reference turns | Full seconds | Full turns |
|---|---|---|---|---|
| ES2004a | 300 | 41 | 1049.35 | 260 |
| IS1009a | 300 | 47 | 838.83 | 195 |
| TS3003a | 300 | 36 | 1505.64 | 242 |
The same run writes ~/.cache/ambient/fixtures/der-full/manifest.json, which
names the cached recordings and their uncut references directly — nothing is
decoded or cut, and the digests above already vouch for both. Five minutes is
too short to show what clustering does to a real meeting: see
DER on whole meetings.
Word error rate on the fixture
cargo run --release --bin wer -- [--manifest <path>] [--json <path>] [--model <dir>], on 2026-09-05. The harness runs what ambient record runs
for a finished track — session::model_paths, Vad::turns(&samples, 30),
transcribe_segments — so these are the numbers a user gets, on the shipped
v3-int8 model. The decode column is one run; see the section below for how far
it moves between runs.
| Speaker | Seconds | Ref words | S | I | D | WER | Decode | Realtime |
|---|---|---|---|---|---|---|---|---|
| 1089 | 302.11 | 721 | 3 | 0 | 0 | 0.0042 | 6.61 s | 46x |
| 1188 | 522.73 | 1296 | 29 | 2 | 4 | 0.0270 | 11.12 s | 47x |
| 121 | 88.89 | 135 | 7 | 1 | 1 | 0.0667 | 2.03 s | 44x |
| total | 913.74 | 2152 | 39 | 3 | 5 | 0.0218 | 19.77 s | 46x |
The total is computed over the summed counts rather than averaged over the
rows, so the 89-second speaker cannot outvote the 522-second one. Substitutions
dominate at 39 against 3 insertions and 5 deletions: on clean read speech the
segmenter is not losing words and the recogniser is not inventing them, it is
getting them wrong. --json writes the same rows, the total among them with
speaker reading total.
cargo run --release --bin wer -- --manifest ~/.cache/ambient/fixtures/calls/manifest.json,
on 2026-09-06, against the calls fixture: three Earnings-21 conference-bridge
calls, 300 s of each. Each call’s reference is its whole human transcript, but
the fixture wav is only the call’s first 300 s, so the reference is truncated
to the hypothesis (wer::score_prefix) before scoring — otherwise everything
said after the cut would count as deletions.
| Speaker | Seconds | Ref words | S | I | D | WER | Decode | Realtime |
|---|---|---|---|---|---|---|---|---|
| 4320211 | 300.00 | 755 | 37 | 32 | 4 | 0.0967 | 7.40 s | 40.6x |
| 4330115 | 300.00 | 773 | 54 | 25 | 5 | 0.1087 | 7.09 s | 42.3x |
| 4341191 | 300.00 | 762 | 43 | 26 | 3 | 0.0945 | 7.22 s | 41.5x |
| calls total | 900.00 | 2290 | 134 | 83 | 12 | 0.1000 | 21.71 s | 41.5x |
Substitutions and insertions are of the same order here, unlike the clean
total’s 39-against-3. Deletions stay small, which is the check that the
truncation is scoring the audio the fixture contains rather than penalising
the call for having ended.
The two shipped models on the same fixture
Both Parakeet builds in models/, same fixture, same VAD turns, --model the
only difference — on 2026-09-05, this machine. Three runs of each, interleaved
so both models see the same machine load; the table is each model’s median run
by total decode.
| Model | Speaker | Seconds | Ref words | S | I | D | WER | Decode | Realtime |
|---|---|---|---|---|---|---|---|---|---|
| v3 int8 | 1089 | 302.11 | 721 | 3 | 0 | 0 | 0.0042 | 7.73 s | 39x |
| v3 int8 | 1188 | 522.73 | 1296 | 29 | 2 | 4 | 0.0270 | 12.69 s | 41x |
| v3 int8 | 121 | 88.89 | 135 | 7 | 1 | 1 | 0.0667 | 2.16 s | 41x |
| v3 int8 | total | 913.74 | 2152 | 39 | 3 | 5 | 0.0218 | 22.58 s | 40x |
| v2 fp16 | 1089 | 302.11 | 721 | 1 | 0 | 0 | 0.0014 | 6.50 s | 46x |
| v2 fp16 | 1188 | 522.73 | 1296 | 26 | 1 | 1 | 0.0216 | 14.84 s | 35x |
| v2 fp16 | 121 | 88.89 | 135 | 4 | 1 | 1 | 0.0444 | 3.50 s | 25x |
| v2 fp16 | total | 913.74 | 2152 | 31 | 2 | 2 | 0.0163 | 24.84 s | 37x |
The two columns are not equally trustworthy, and the runs say which is which.
Every run of a model returned the same counts to the last substitution — v3 at
39/3/5 and v2 at 31/2/2, v3’s reproducing quality/wer.json exactly — so the
accuracy gap is a property of the models: 0.0163 against 0.0218, half a point
of word error rate, and v2 fp16 wins on each speaker individually rather than
on one fixture carrying it.
Decode seconds are the opposite. Across the three runs the total moved between
21.59 s and 27.44 s for v3 and between 20.80 s and 26.15 s for v2 — ranges that
overlap for most of their length, on a machine with other work on it. The
19.77 s recorded for v3 in the section above is a further run of the same
binary on the same fixture, and it sits below all three here. So this fixture
on this machine does not separate the two models on speed at all, and any
decode figure quoted from a single run, including the ones in the table above,
is worth roughly ±3 s. That is why scripts/quality gates the counts and not
the timings.
Accuracy and speed are not the whole decision, and here only accuracy is
measured well. The Phase 0 table above puts v2 fp16 at 3745 MB peak against v3
int8’s 2594 MB for the same 60 s of audio, and the 16 GB target is still
unmeasured. The default stays v3 int8 — session::model_paths
with no --model — because switching spends 1.1 GB of headroom on a machine
nobody has run this on to buy half a point of word error rate. Measure the port
first; the table says which way it should then go.
Diarization error rate on the fixture
cargo run --release --bin der -- [--manifest <path>] [--threshold <f32>] [--collar <f64>] [--json <path>], on 2026-09-05. The harness runs what
ambient record runs for a finished session — the model resolution of
session::diarize_session, the same pyannote segmentation and WeSpeaker
embedding, Diarizer::diarize at the shipped 0.5 threshold, before the
small-cluster fold in DER on whole meetings existed —
so these were the numbers a user got then. Scored at the conventional 0.25 s collar; --collar 0
scores every frame instead.
| Meeting | Seconds | Ref spk | Hyp spk | Missed | False alarm | Confusion | DER | Run |
|---|---|---|---|---|---|---|---|---|
| ES2004a | 300.00 | 3 | 6 | 9.65 s | 4.49 s | 2.61 s | 0.1279 | 1.90 s |
| IS1009a | 300.00 | 4 | 7 | 4.80 s | 8.21 s | 17.23 s | 0.2058 | 2.21 s |
| TS3003a | 300.00 | 4 | 6 | 15.69 s | 2.27 s | 7.08 s | 0.1116 | 2.67 s |
| total | 900.00 | 11 | 19 | 30.14 s | 14.97 s | 26.92 s | 0.1434 | 6.77 s |
The total is computed over the summed error seconds rather than averaged over the rows, so a meeting with less speech in it cannot outvote a talkative one. Speaker counts sum rather than dedupe: speaker 0 of one meeting is not speaker 0 of the next.
Every meeting over-clusters — 19 hypothesis speakers against 11 in the
reference — and IS1009a pays for it with 17.23 s of confusion and a DER nearly
double the other two. It is still not where most of the error is. Missed
speech is the largest single component at 30.14 s, and --threshold cannot
touch it: swept from 0.4 to 0.9 it leaves missed and false alarm at exactly
30.14 s and 14.97 s while confusion ranges from 16.00 s to 58.71 s.
Diarizer::diarize takes one winning speaker per frame, so relabelling
clusters cannot change how many speakers are heard at once — and the
references carry 21.5 s of overlapping speech that a single-label output has
no way to attribute. Those 45.11 s put a floor of 0.090 under the DER on this
fixture that no clustering change reaches; the rest is segmentation.
The threshold does move confusion, and not monotonically: 0.6 scores 0.1217 against the shipped 0.5’s 0.1434, and 0.7 gives 0.1375 back. Three meetings is not a holdout to move the default on. Speed is not the constraint: 900 s of audio separated in 6.77 s.
--json writes the same rows as
{"meeting", "seconds", "reference_speakers", "hypothesis_speakers", "missed_s", "false_alarm_s", "confusion_s", "der", "run_s"}, the total among
them with meeting reading total.
DER by threshold
cargo run --release --bin der -- --threshold <t> --json <tmp>, on 2026-09-05,
on the same fixture and the same 0.25 s collar as the section above. Each
meeting cell is that meeting’s DER with the hypothesis speaker count beside it;
the reference count is in the header.
| Threshold | ES2004a (ref 3) | IS1009a (ref 4) | TS3003a (ref 4) | Total DER |
|---|---|---|---|---|
| 0.30 | 0.2242 (12 spk) | 0.3204 (22 spk) | 0.2739 (17 spk) | 0.2745 |
| 0.40 | 0.1538 (9 spk) | 0.2332 (13 spk) | 0.1332 (8 spk) | 0.1678 |
| 0.50 (shipped) | 0.1279 (6 spk) | 0.2058 (7 spk) | 0.1116 (6 spk) | 0.1434 |
| 0.60 | 0.1218 (5 spk) | 0.1741 (5 spk) | 0.0873 (4 spk) | 0.1217 |
| 0.70 | 0.1163 (4 spk) | 0.2443 (3 spk) | 0.0800 (2 spk) | 0.1375 |
| 0.80 | 0.1157 (3 spk) | 0.3588 (2 spk) | 0.0800 (2 spk) | 0.1709 |
The default holds at 0.5. The lowest total is 0.6 at 0.1217, and its speaker counts stay inside twice the reference on every meeting, but the keep rule for this sweep asks for more than 0.01 of DER on every meeting and ES2004a gives 0.0061 — 0.1279 to 0.1218. One meeting of three carrying a change to a shipped constant is exactly what that margin is there to refuse.
Missed speech and false alarm do not move: 30.14 s and 14.97 s at all six thresholds. Clustering only relabels spans, so the whole sweep is confusion, from 16.00 s at 0.6 to 92.78 s at 0.3. Nor is the curve monotonic — past 0.6 the total climbs back to 0.1375 and 0.1709 while ES2004a keeps improving, because IS1009a merges its four speakers down to three and then two and pays 22.89 s and 39.72 s of confusion for it. A threshold that suits one meeting is already wrong for another at three meetings; that is the argument for a holdout, not for a new constant.
DER by minimum embedding length
MIN_EMBED in src/diarize.rs swapped between builds, cargo run --release --bin der -- --json <tmp> each time, on 2026-09-05, on the same fixture and
the same 0.25 s collar as the sections above. A (window, local speaker)
candidate holding less than MIN_EMBED of audio is never embedded, so it
never becomes a cluster: raising the constant drops the short fragments that
would have been their own speaker, and lowering it admits them.
MIN_EMBED |
ES2004a (ref 3) | IS1009a (ref 4) | TS3003a (ref 4) | Missed | Total DER |
|---|---|---|---|---|---|
| 0.5 s | 0.1315 (9 spk) | 0.2058 (9 spk) | 0.1184 (10 spk) | 30.11 s | 0.1474 |
| 1.0 s (shipped) | 0.1279 (6 spk) | 0.2058 (7 spk) | 0.1116 (6 spk) | 30.14 s | 0.1434 |
| 1.5 s | 0.1279 (6 spk) | 0.2032 (7 spk) | 0.1044 (4 spk) | 30.14 s | 0.1394 |
| 2.0 s | 0.1386 (5 spk) | 0.1578 (5 spk) | 0.0877 (1 spk) | 34.87 s | 0.1215 |
The default holds at one second. No value beats it by more than 0.01 of DER on every meeting, which is what the keep rule asks: 1.5 s ties ES2004a exactly at 0.1279, and 2.0 s — the lowest total on the page at 0.1215 — is 0.0107 worse on ES2004a, so the two candidates fail on the same meeting from opposite directions.
Unlike the threshold sweep, this constant does move missed speech, because it decides what gets embedded rather than how embeddings cluster: 30.11 s at 0.5 s against 34.87 s at 2.0 s, and ES2004a alone pays 9.65 s → 12.67 s of that. It buys confusion back — 28.47 s down to 13.06 s over the same range — and the totals are the trade netting out, not one term improving.
The 2.0 s row is worth reading before believing its total. TS3003a scores
0.0877 there with one hypothesis speaker against four in the reference,
and zero confusion, because that meeting is very nearly a monologue: its
reference gives 242.03 s of 243.80 s of speech to MTD009PM and the other
three speakers 10.25 s between them. Calling the whole meeting one person is
almost right there and would be badly wrong anywhere else. IS1009a is the
honest part of that row — 0.2058 to 0.1578, seven speakers to five, on a
meeting with four real ones — and it is not enough on its own.
DER on whole meetings
cargo run --release --bin der -- --manifest ~/.cache/ambient/fixtures/der-full/manifest.json --json <tmp>, on 2026-10-08,
M5 Max: the same three meetings uncut, 3393.83 s in all, at the same 0.25 s
collar. scripts/quality now gates this set against quality/der-full.json
beside the clips.
The 300 s clips hid the real failure. At 0.5 with clustering alone, the whole meetings, each with four real speakers, came out at 36, 28 and 31:
| Meeting | Seconds | Ref spk | Hyp spk | Missed | False alarm | Confusion | DER |
|---|---|---|---|---|---|---|---|
| ES2004a | 1049.35 | 4 | 36 | 74.92 s | 13.27 s | 30.89 s | 0.1794 |
| IS1009a | 838.83 | 4 | 28 | 45.40 s | 13.23 s | 49.30 s | 0.2101 |
| TS3003a | 1505.64 | 4 | 31 | 60.91 s | 24.46 s | 33.81 s | 0.1395 |
| total | 3393.83 | 12 | 95 | 181.23 s | 50.96 s | 114.00 s | 0.1704 |
AMBIENT_DEBUG_DIAR=1 prints how much speech each cluster ends up holding, and
it shows the shape: four clusters of 77-800 s, then a tail of thirty-odd
holding 0-9 s each. That tail is one voice drifting — distance from the
microphone, laughter, crosstalk — far enough to clear the threshold for a few
seconds. Raising the threshold cuts the tail, but by 0.7 it also starts
merging real people on the clips (IS1009a 4 → 3).
So Diarizer::diarize now folds a cluster into a bigger one when two things
hold: it owns less than MIN_CLUSTER of speech, and the cosine distance
between its mean embedding and the nearest cluster that owns more is at most
FOLD_DISTANCE. Neither test works alone.
- Time alone erases brief real speakers. ES2004a’s clip has participants with 9 s and 17 s of reference speech; a 10 s floor with no distance test folds that clip into one speaker and raises its DER by 0.0425, past the gate’s 0.01. A floor set as a share of the track (1.5% at threshold 0.6) did the same to TS3003a’s clip, 4 reference speakers to 1, and dropped one of TS3003a’s four on the whole meeting.
- Distance is what tells the two apart. At 0.6 with a 10 s floor, the small clusters on ES2004a’s clip, which are its real quiet participants, sit 0.78-0.94 from the nearest big cluster; the excursions on the whole meetings mostly sit 0.43-0.78. A drifted voice is still that voice.
Swept at thresholds 0.50-0.65, floors 5-30 s and fold distances 0.65-0.90. Speaker counts are ES2004a/IS1009a/TS3003a; spk err is |hyp − ref| summed over the three; clip Δ is the worst clip meeting against the clip baseline before this change (0.5, no fold: 6/7/6 speakers, spk err 8, DER 0.1434), where the gate allows +0.01. Rows at 0.50 unless marked:
| Floor | Distance | Full spk | Full DER | Full spk err | Clip spk | Clip DER | Clip spk err | Clip Δ |
|---|---|---|---|---|---|---|---|---|
| none | — | 36/28/31 | 0.1704 | 83 | 6/7/6 | 0.1434 | 8 | +0.0000 |
| 5 s | 0.80 | 4/10/7 | 0.1474 | 9 | 3/5/3 | 0.1323 | 2 | −0.0072 |
| 8 s | 0.80 | 4/7/4 | 0.1419 | 3 | 4/5/3 | 0.1341 | 3 | −0.0050 |
| 10 s | 0.65 | 7/7/7 | 0.1345 | 9 | 6/4/2 | 0.1160 | 5 | +0.0000 |
| 10 s | 0.70 | 5/6/7 | 0.1343 | 6 | 6/4/2 | 0.1160 | 5 | +0.0000 |
| 10 s | 0.75 | 4/6/6 | 0.1337 | 4 | 6/4/2 | 0.1160 | 5 | +0.0000 |
| 10 s | 0.80 | 4/5/4 | 0.1336 | 1 | 4/4/2 | 0.1147 | 3 | −0.0050 |
| 10 s | 0.85 | 4/5/3 | 0.1356 | 2 | 4/4/2 | 0.1147 | 3 | −0.0050 |
| 10 s | 0.90 | 4/5/3 | 0.1356 | 2 | 2/4/2 | 0.1182 | 3 | +0.0086 |
| 12 s | 0.80 | 4/5/4 | 0.1336 | 1 | 4/4/2 | 0.1147 | 3 | −0.0050 |
| 15 s | 0.80 | 4/5/4 | 0.1336 | 1 | 4/4/2 | 0.1147 | 3 | −0.0050 |
| 20 s | 0.80 | 4/5/4 | 0.1336 | 1 | 4/3/2 | 0.1393 | 4 | +0.0385 |
| 30 s | 0.80 | 4/4/4 | 0.1507 | 0 | 4/3/2 | 0.1521 | 4 | +0.0824 |
| 10 s @ 0.55 | 0.80 | 4/5/4 | 0.1335 | 1 | 4/4/2 | 0.1147 | 3 | −0.0050 |
| 10 s @ 0.60 | 0.80 | 4/5/3 | 0.1362 | 2 | 3/4/2 | 0.1131 | 2 | −0.0112 |
| 10 s @ 0.65 | 0.80 | 4/5/4 | 0.1323 | 1 | 3/3/2 | 0.1377 | 3 | +0.0385 |
The shipped rule is a 12 s floor and a 0.8 fold distance, with the threshold left at 0.5. Floors of 10, 12 and 15 s give identical rows, so 12 s sits mid-plateau. Distance is the more sensitive knob: 0.75 leaves 4/6/6 on the meetings and 0.85 drops one of TS3003a’s four, so 0.8 is the best point rather than a flat one, though both neighbours still pass the gate. The threshold stays where it was because 0.55 moves the full DER by 0.0001 and 0.6 costs a speaker on TS3003a, so nothing here argues for a new default, and leaving it means a config file saved with 0.5 gets exactly these numbers. The lowest full DER in the sweep, 0.1323 at 0.65, merges two of IS1009a’s clip speakers and fails the gate by 0.0385.
At the shipped rule, every clip meeting improves or holds and the clip speaker error falls from 8 to 3:
| Meeting | Ref spk | Hyp spk | Missed | False alarm | Confusion | DER |
|---|---|---|---|---|---|---|
| ES2004a | 3 | 4 | 9.65 s | 4.49 s | 1.96 s | 0.1230 |
| IS1009a | 4 | 4 | 4.80 s | 8.21 s | 10.54 s | 0.1602 |
| TS3003a | 4 | 2 | 15.69 s | 2.27 s | 0.00 s | 0.0800 |
| total | 11 | 10 | 30.14 s | 14.97 s | 12.50 s | 0.1147 |
and on the whole meetings:
| Meeting | Ref spk | Hyp spk | Missed | False alarm | Confusion | DER |
|---|---|---|---|---|---|---|
| ES2004a | 4 | 4 | 74.92 s | 13.27 s | 6.93 s | 0.1433 |
| IS1009a | 4 | 5 | 45.40 s | 13.23 s | 20.17 s | 0.1534 |
| TS3003a | 4 | 4 | 60.91 s | 24.46 s | 12.21 s | 0.1142 |
| total | 12 | 13 | 181.23 s | 50.96 s | 39.31 s | 0.1336 |
TS3003a’s clip at two speakers is the near-monologue described under DER by minimum embedding length: its other two reference speakers hold 0.3 s and 2.0 s, too little to embed at all, and the clip it scored before this change found six. Missed speech and false alarm do not move anywhere in this section: folding relabels spans, so every gain is confusion, 114.00 s → 39.31 s on the meetings.
Live transcription: cost per block
M5 Max, 128 GB, macOS 26.6.2, on 2026-09-06. cargo build --release --bin asrbench, then the built binary run directly (not under cargo run, so
/usr/bin/time -l’s rusage is the binary’s own, not cargo’s) with no --wav,
so it replays the manifest’s first entry — the 1089 fixture upsampled once to
~/.cache/ambient/bench/1089.48k.wav (302.11 s):
/usr/bin/time -l <target>/release/asrbench --block-seconds 30 --json <path>The harness cuts native-rate 48 kHz audio into --block-seconds blocks, each
resampled to 16 kHz, run through VAD, its turns decoded — a turn ending within
PAD_MS of a block edge is carried into the next block rather than cut.
model_load_s is a one-time cost measured outside the per-block figures below.
Total row, 30 s blocks:
| blocks | audio_s | prep_s | decode_s | rtf | max_block_work_s | model_load_s |
|---|---|---|---|---|---|---|
| 11 | 302.11 | 0.881 | 5.966 | 0.023 | 0.866 | 0.607 |
Lag rows, 30 s blocks — tracks=N is the workload once through; tracks=N (x2) is bench::repeat(rows, 2), the same blocks played twice in a row to
tell a bounded backlog from a growing one:
| max_lag_s | final_lag_s | |
|---|---|---|
| tracks=1 | 0.866 | 0.125 |
| tracks=1 (x2) | 0.866 | 0.125 |
| tracks=2 | 1.731 | 0.249 |
| tracks=2 (x2) | 1.731 | 0.249 |
The (x2) rows land on exactly the same numbers as their single-pass
counterparts, at both track counts. That is the finding, not a coincidence:
at an rtf of 0.023 each block finishes long before the next one arrives, so
a worker that repeats the workload never falls further behind than it did the
first time through — a bounded backlog, not a growing one.
maximum resident set size from /usr/bin/time -l (bytes on macOS, divided
by 1048576 for MB): 2038 MB peak for the 30 s run.
The single most expensive block is index 5 (end_s=180.00): prep_s=0.107,
decode_s=0.758, turns=3, prep_s + decode_s = 0.866 s — the block behind
max_block_work_s above.
The benchmark pays Vad’s per-call recurrent-state reset on every block, and
accepts it, because a live pass built on the current Vad would pay it too.
Verdict
The rule was fixed before these numbers were seen: a go requires, at 30 s
blocks, tracks=2 max_lag_s under 30, the tracks=2 doubled-workload
max_lag_s within 1 s of it, the tracks=2 implied rtf — 2 × (prep_s + decode_s) / audio_s — under 0.5, and the worst drain overshoot
(see the section below) under 1 s; otherwise a no-go naming the
constraint that failed.
| block_seconds | prep_s + decode_s | rtf | tracks=2 max_lag_s | tracks=2 (x2) max_lag_s |
|---|---|---|---|---|
| 10 | 7.064 | 0.023 | 0.859 | 0.859 |
| 20 | 6.864 | 0.023 | 1.390 | 1.390 |
| 30 | 6.846 | 0.023 | 1.731 | 1.731 |
At 30 s blocks tracks=2 max_lag_s is 1.731 s, under 30. The doubled
workload (tracks=2 (x2)) is also 1.731 s, 0.000 s from the single pass, well
within 1 s. The implied rtf is 2 × (0.881 + 5.966) / 302.11 = 13.692 / 302.11 = 0.045, under 0.5. The worst drain overshoot across the three runs
below is 10.87 ms = 0.011 s, under 1 s. All four clauses hold: go.
A go means a follow-up plan is in scope, and these are the facts it must
respect. session::transcribe_session opens raw.jsonl with
std::fs::File::create before the models load, deliberately: the file’s
existence is what the window’s “Interrupted” test reads, and File::create
truncates, so a second transcriber on the same session must be refused rather
than allowed to race the first for that file — claim_transcription is what
refuses it today. capture_into writes both tracks through hound writers
and only calls finalize() on them when the drain loop ends, so the RIFF and
data length fields in a still-growing capture’s WAV header are stale until
then; anything that reads a capture before it stops cannot trust that header’s
length. The overshoot number above assumes the ASR worker runs at
QOS_CLASS_BACKGROUND, the same class queue::Queue uses for a real
transcription job; the per-block costs in the table above, by contrast, were
measured on asrbench‘s main thread at default QoS with nothing else
running, so they are optimistic relative to a background-QoS live worker.
And asr::Recognizer::transcribe_segments takes &mut self — ADR-0015’s
serial queue transcribes one job at a time on one long-lived thread, so the
tracks=2 rows above are one worker carrying two tracks’ work, not two
workers running at once.
Live transcription: drain overshoot beside ASR
M5 Max, 128 GB, macOS 26.6.2, on 2026-09-06, from the three drainbench runs
already logged for this outcome: cargo run --release --bin drainbench -- 60 ~/.cache/ambient/bench/1089.48k.wav, each exit 0. The worst run’s max
overshoot is 10.87 ms (run 2, under ASR).
| run | periods | decodes completed | mean overshoot | max overshoot | ring margin |
|---|---|---|---|---|---|
| 1 (idle) | 25 | — | 6.38 ms | 10.03 ms | 0.033% of 30 s |
| 1 (under ASR) | 289 | 4 | 8.00 ms | 10.10 ms | 0.034% of 30 s |
| 2 (idle) | 25 | — | 6.91 ms | 10.03 ms | 0.033% of 30 s |
| 2 (under ASR) | 289 | 4 | 8.23 ms | 10.87 ms | 0.036% of 30 s |
| 3 (idle) | 25 | — | 7.06 ms | 10.03 ms | 0.033% of 30 s |
| 3 (under ASR) | 289 | 4 | 8.19 ms | 10.08 ms | 0.034% of 30 s |
The idle rows are the same 200 ms drain loop with no ASR beside it; no
capture was started (no tap, per the non-goal). The ASR worker ran at
QOS_CLASS_BACKGROUND, which is the QoS the number above assumes. Every run’s
verdict line reads the same: ok — worst overshoot is under 1% of the 30 s ring.
Word error rate through the resampler
cargo run --release --bin wer -- --via <rate>, on 2026-09-06. Each fixture
wav is transcoded with ffmpeg -ar <rate> -ac 1 -sample_fmt s16 first, cached
under ~/.cache/ambient/fixtures/via/<rate>/, then read back with
resample::read_wav_any and downsampled with resample::to_16k — the same
path Core Audio’s 48 kHz delivery (or a 44.1 kHz interface) takes before
features::read_wav’s hard 16 kHz check could ever see it. The native row is
features::read_wav on the fixture unchanged, restated here rather than
copied from the section above because this is a fresh run. The fixture itself
is 16 kHz LibriSpeech, so the 48 000/44 100 rows measure a round trip through
the resampler on already band-limited audio, not native capture with real
energy above 8 kHz — they exercise the code path, not Core Audio’s content.
| Via | Seconds | Ref words | S | I | D | WER |
|---|---|---|---|---|---|---|
| native | 913.74 | 2152 | 39 | 3 | 5 | 0.0218 |
| 48 000 | 913.74 | 2152 | 45 | 8 | 6 | 0.0274 |
| 44 100 | 913.74 | 2152 | 48 | 6 | 5 | 0.0274 |
Both resampled rows land 0.0056 above native, more than the 0.005 keep-rule
margin, so the resampler is the suspect: going through a 48 kHz or 44.1 kHz
round trip before to_16k costs more than half a point of WER on this
fixture. That is a finding, not a fix — resample::to_16k is unchanged by
this section, and no shipped constant moved, so quality/wer.json does not
change with it.
Insertions by gap
scripts/fetch-fixtures --gap <ms> and cargo run --release --bin wer, on
2026-09-06. The default fixture puts 700 ms of silence between utterances;
--gap 3000 builds the same three speakers, the same chapters, the same
.trans.txt lines with 3 s of silence between them instead, under its own
~/.cache/ambient/fixtures/wer-3000ms/. Same words, more silence: both total
rows read 2152 reference words, so the raw insertion count is comparable
between them without normalising for anything. Total seconds rise too — 2.3 s
extra per gap per speaker, 218.49 s over the fixture — so turns(&samples, 30) sees different turn boundaries at 3 s than at 700 ms; gap length is not
the only thing that changed between the rows.
| Gap | Seconds | Ref words | S | I | D | WER |
|---|---|---|---|---|---|---|
| 700 ms | 913.74 | 2152 | 39 | 3 | 5 | 0.0218 |
| 3 s | 1132.23 | 2152 | 40 | 3 | 5 | 0.0223 |
Insertions net out at 3 in both totals (one speaker gains one, another loses
one), and total WER moves 0.0218 → 0.0223, inside the 0.005 keep-rule margin
the resampler section above cites. On this fixture a longer silence between
utterances does not make Parakeet invent words. That is a finding, not a fix:
nothing in the pipeline changed, so quality/wer.json does not change with
it. If insertions had risen with gap length, the lever to name would be
last_confidence — not tuned here, since it also drops real quiet speech and
needs its own number.
Turn padding
cargo run --release --bin wer -- --pad <ms>, on 2026-09-06. --pad overrides
Vad::pad_ms, which reaches both places vad::PAD_MS is read: the margin
trim_quiet re-applies after cutting a turn’s quiet edges, and the hysteresis
pad segments_from applies before merging overlapping turns. 200 ms is the
shipped default; cargo run --release --bin wer -- --pad 200 reproduces the
native row from the section above exactly, confirming the parameter reaches
both sites rather than one of them twice. Total reference words hold at 2152
across all four rows — padding does not change what is being scored, only how
much room noise sits at each turn’s edges before the recogniser sees it.
| Pad | Seconds | Ref words | S | I | D | WER |
|---|---|---|---|---|---|---|
| 100 ms | 913.74 | 2152 | 36 | 7 | 6 | 0.0228 |
| 200 ms (default) | 913.74 | 2152 | 39 | 3 | 5 | 0.0218 |
| 300 ms | 913.74 | 2152 | 39 | 3 | 3 | 0.0209 |
| 400 ms | 913.74 | 2152 | 39 | 3 | 3 | 0.0209 |
The keep rule is per speaker: a value replaces 200 ms only if it beats the
shipped per-speaker WER (1089 0.0042, 1188 0.0270, 121 0.0667) by more
than 0.005 on every speaker, not just in the total. None do. At 300 ms and
400 ms 1089 barely moves (0.0042 → 0.0028, then flat at 0.0042 — 0.0014 and
0.0000 of the 0.005 margin) while 121 — the shortest speaker at 88.89 s —
gets worse (0.0667 → 0.0889); 1188 improves at both but only by 0.0031 and
0.0039, still under the bar. At 100 ms 1089 gets worse (0.0042 → 0.0097),
121 is unchanged (0.0667), and 1188’s 0.0015 gain is again under the
margin; 100 ms is also the only pad value where insertions move at all, 3 at
200 ms and 300 ms up to 7 here. The default holds. src/vad.rs’s
PAD_MS stays at 200 and quality/wer.json does not change with it, because
no shipped constant moved.
Chunk length
cargo build --release then /usr/bin/time -l ./target/release/wer --chunk <seconds> --json <path>, on 2026-09-06. --chunk sets the max_seconds
argument to Vad::turns — the length above which a turn is split at its
least-voiced frame, the best available approximation of a pause — not
Vad::chunks, a separate method that merges turns back together up to
max_seconds and which record deliberately does not call. Peak RSS is
maximum resident set size from /usr/bin/time -l, converted from the bytes
Darwin reports (not the kilobytes Linux would) by dividing by 1,048,576;
models load once per manifest entry inside the loop, so each row’s figure is
one number for the whole three-speaker run, not a per-speaker one, the same
way the WER section’s decode column is qualified. --chunk 30 reproduces the
native row exactly (913.74 s, 2152 reference words, S=39 I=3 D=5, WER
0.0218), confirming the parameter reaches the shipped path rather than a
second copy of it.
| Chunk | Seconds | Ref words | S | I | D | WER | Peak RSS |
|---|---|---|---|---|---|---|---|
| 15 s | 913.74 | 2152 | 40 | 3 | 5 | 0.0223 | 2062 MB |
| 20 s | 913.74 | 2152 | 39 | 3 | 5 | 0.0218 | 2055 MB |
| 30 s (default) | 913.74 | 2152 | 39 | 3 | 5 | 0.0218 | 2145 MB |
| 40 s | 913.74 | 2152 | 39 | 3 | 5 | 0.0218 | 2146 MB |
This 30 s row’s 2145 MB is the figure the keep rule below compares against.
It is not the 2317 MB at the 30 s-chunk level in ## End to end, the 2158 MB
in the 30 s row of ## Memory scales with audio length, or the 2255 MB in
## VAD chunking — those three predate this sweep and come from bench and
say-synthesised audio, not from the wer binary run over LibriSpeech, so
they are context here, not the bar.
The keep rule is per speaker, and RSS besides: a value replaces the shipped
30 s only if it beats the shipped per-speaker WER (1089 0.0042, 1188
0.0270, 121 0.0667) by more than 0.005 on every speaker and does not
raise peak RSS above the 30 s row above. None do. The 20 s, 30 s and 40 s
rows are identical per speaker, so no turn in this fixture reaches the 20 s
cap — nothing to split further between 20 s and 40 s — and 40 s costs ~1.7 MB
more RSS on top of tying, not beating, the WER. 15 s is the one value that
changes anything: 1188 gets worse (0.0270 → 0.0278, one more substitution)
and the total moves from 0.0218 to 0.0223, both the wrong direction, while
1089 and 121 are unchanged; 15 s also has the lowest RSS of the four,
which is the RSS half of the keep rule doing nothing useful when the WER half
already fails. The default holds. src/session.rs’s shipped chunk
length stays at 30 and quality/wer.json does not change with it, because no
shipped constant moved.
Weight prepacking
cargo build --release then /usr/bin/time -l ./target/release/wer --json <path> and the same with --manifest ~/.cache/ambient/fixtures/calls/manifest.json, on 2026-10-08, once on the
parent commit and once with session.disable_prepacking=1 on the three
Parakeet sessions (with_prepacking(false) in src/asr.rs). ONNX Runtime’s
prepacking copies each weight matrix into a kernel-friendly layout and keeps
both; disabling it keeps one. Peak RSS is maximum resident set size from
/usr/bin/time -l, divided by 1,048,576. Both runs reproduce
quality/wer.json and quality/calls.json exactly, so neither baseline
changes.
| Fixture | Prepacking | S | I | D | WER | Decode | Realtime | Peak RSS |
|---|---|---|---|---|---|---|---|---|
| clean | on | 39 | 3 | 5 | 0.0218 | 23.06 s | 39.6× | 2301 MB |
| clean | off | 39 | 3 | 5 | 0.0218 | 41.63 s | 21.9× | 1337 MB |
| calls | on | 134 | 83 | 12 | 0.1000 | 29.12 s | 30.9× | 2118 MB |
| calls | off | 134 | 83 | 12 | 0.1000 | 31.09 s | 28.9× | 1114 MB |
Peak RSS falls by ~1 GB on both fixtures with the transcript unchanged. The decode column is not a clean comparison: a repeat of the prepacking-on clean run on the same busy machine came in at 37.86 s (24.1×), so the decode cost of disabling prepacking is somewhere between ~7% (calls) and the clean row’s spread, and still well above realtime either way.
Talk: release to text, and the worker’s footprint
M5 Max, 128 GB, on 2026-10-09: ambient talk --wav q.wav --dry-run, release
build, the installed app’s models, against a stand-in fm-inbox.sh. q.wav
is a 2.8 s macOS say rendering of “What is the status of the ambient talk
pull request?”, transcribed word for word. “Release to text” runs from the
audio being in hand to the transcript; the process is new for every turn, so
it includes loading the VAD and Parakeet. Peak footprint and resident size are
/usr/bin/time -l’s, for the whole worker process.
| Run | Release to text | Of which model load | Peak footprint | Max RSS |
|---|---|---|---|---|
| 1 | 640 ms | 513 ms | 906 MiB | 924 MiB |
| 2 | 603 ms | 473 ms | 904 MiB | 921 MiB |
| 3 | 714 ms | 586 ms | 904 MiB | 922 MiB |
Loading the models is most of the wait; the VAD and decode of 2.3 s of speech
take about 130 ms. A slip of the key (say "uh", no speech found) exits in
20 ms at 12 MiB of footprint, because the recogniser is never loaded. The
voice helper is its own process and is not in these figures: with the v1-gravel
Qwen3-TTS voice and --no-play, the reply’s first audio came 84 ms after the
request, once the helper had warmed while the reply was awaited.
Idle memory after finalize: in process and in a child
M5 Max, 128 GB, macOS 26.6.2, on 2026-10-08, shipped model settings. A scratch
replay harness streamed AMI TS3003a (1505.6 s, four speakers, resampled to
48 kHz) from disk in 200 ms drains into live::LiveTranscriber on both tracks
at 15× realtime, wrote the session’s audio as capture_into does, then
finalized it either in process (session::transcribe_session) or through
finalize::run, the menu bar queue’s path. The figures are the harness
process’s own proc_pid_rusage physical footprint and resident size, in MiB,
3 s after each step. Every completed run wrote the same 650 lines and 638 speaker
labels. Other work held the machine’s load average between 13 and 50, so the
wall times are noisy; the default-QoS pair ran side by side under the same
load and is the fair timing comparison.
| Run | After the live pass | After finalize | Finalize wall |
|---|---|---|---|
| In process, default QoS | 32 fp / 388 rss | 450 fp / 1078 rss | 48.2 s |
| Child, default QoS | 32 fp / 388 rss | 32 fp / 388 rss | 49.6 s |
The live pass already gives its memory back when it stops: the process falls from ~1.45 GiB of footprint while recording to ~30 MiB. What stayed was finalize’s: 430–690 MiB of footprint and over 1 GiB resident in process across five in-process runs, and nothing in the child across five child runs. The child peaked at 1.97–2.15 GiB sampled RSS of its own and returned all of it on exit.
Wall time under the queue’s background QoS is not comparable from these runs.
A background-QoS thread that another thread pthread_joins inherits the
joiner’s QoS, so an in-process harness that joins its worker measured default
QoS (80–98 s here); the same run polling instead of joining was still
transcribing after 420 s, as was the child (473–2330 s). The app’s queue
thread is never joined, so it has always run at true background QoS, and on a
machine this loaded that starves finalize in either arrangement.
Every number on this page is from the 128 GB machine; the 16 GB target is still unmeasured — see porting to the work M5.