Skip to content

Measurements

Updated View as Markdown

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.700

A 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 fixture

Everything 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 clips

The 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close