Goal: measure how often Ambient’s diarizer assigns speech to the wrong
speaker, on a public meeting corpus, with the same Diarizer::diarize call
ambient diarize makes; then tune the diarizer against that number.
Architecture: a diarization error rate (DER) scorer in src/der.rs, an
AMI fixture fetched into ~/.cache/ambient, a harness binary that runs
Diarizer::diarize over the fixture and scores it, a der= figure on the
quality script’s last line, then one experiment per task with a keep-or-restore
rule.
Tech Stack: Rust 1.85 floor, no new crates (anyhow, serde_json, hound only);
bash + ffmpeg + curl + shasum for the fixture; ONNX models already in models/.
Global Constraints
- The number is span-level DER: how the diarizer’s spans compare with the
reference turns. The later step in
session::diarize_sessionthat gives eachraw.jsonlrecord its majority speaker (src/session.rs:1241) is not scored here; a record straddling two turns is a transcript-level error and gets its own plan onceraw.jsonl-level fixtures exist. - The fixture is AMI Mix-Headset audio (CC BY 4.0) with the pyannote
only_wordsRTTM references pinned to commit67c2d539286e89f68952d5dcf83912bd9f01dfae. Cached under~/.cache/ambient/fixtures/der/, never committed. - The number is DER with a 0.25 s collar as
cargo run --release --bin derprints it in its total row. Report it in the pull request body. - An experiment that makes the number worse reverts its code change before the
task ends and records the table in
docs/developing/measurements.md. scripts/checkdoes not look at docs. After any docs edit runnode .github/scripts/check-links.mjsand quote its… 0 broken link(s)line.- Nothing here touches capture, the menu bar, the settings UI, or
src/asr.rs. - No
.github/workflows/,.claude/, orloop/changes; no editing existing tests.
Task 1: A DER scorer with tests
Files:
- Create:
src/der.rs - Modify:
src/lib.rs(addpub mod der;in alphabetical position, before writing any test)
Interfaces:
-
Produces:
pub struct Turn { pub start_s: f64, pub end_s: f64, pub speaker: String };pub fn parse_rttm(text: &str) -> anyhow::Result<Vec<Turn>>;pub struct Der { pub missed_s: f64, pub false_alarm_s: f64, pub confusion_s: f64, pub reference_s: f64 }withpub fn rate(&self) -> f64=(missed + false_alarm + confusion) / reference;pub fn score(reference: &[Turn], hypothesis: &[Turn], collar_s: f64) -> anyhow::Result<Der>, an error only when the reference is empty (empty reference); any number of speakers on either side is scored, so an over-clustered candidate in a sweep gets a number, not an abort. -
Step 1: add
pub mod der;tosrc/lib.rs, createsrc/der.rsholding only the#[cfg(test)] mod testsbelow (every test passescollar_s0.0 except the collar test), and runcargo test der::once to see the red:parse_rttmonSPEAKER ES2004a 1 12.34 5.00 <NA> <NA> FEE013 <NA> <NA>yieldsTurn{start_s:12.34,end_s:17.34,speaker:"FEE013"}; blank lines and lines starting with;;are skipped; any other line not starting withSPEAKER, or with fewer than 8 fields, is an error naming the line number (an HTML error page saved as an RTTM must fail, not parse to nothing).- identical reference and hypothesis score
rate() == 0.0. - reference
A 0–10, hypothesis empty:missed_s == 10.0. - reference empty, hypothesis
A 0–4:scoreis an error containingempty reference. - reference
A 0–10, B 10–20, hypothesisX 0–10, Y 10–20: rate 0.0 (labels are mapped, not compared). - reference
A 0–10, B 10–20, hypothesisX 0–20:confusion_s == 10.0(within 0.05). - over-clustering: reference
A 0–17, B 17–25, hypothesisX 0–9, Y 9–17, X 17–25: the optimal map is A→Y, B→X (16 s correct,confusion_s == 9.0within 0.05); a greedy assignment takes A→X first (9 s) and leaves B nothing, scoring 16 s of confusion, so this test fails on greedy. - many clusters: reference
A 0–10, B 10–20, hypothesis with twelve labelsH0..H11, labelHicovering[i, i+1):scoreisOk; one label maps to A and one to B (2 s correct), soconfusion_s == 10.0,missed_s == 8.0(12–20 has no hypothesis),false_alarm_s == 0.0. - collar: reference
A 0–10, hypothesisA 0.2–10, collar 0.25: rate 0.0; collar 0.0:missed_s ≈ 0.2.
-
Step 2: expect FAIL with “cannot find function” for each item.
-
Step 3: implement. Discretise both sides into 10 ms frames over
max(end)of both. A frame is excluded when it lies withincollar_sof any reference turn boundary. Per frame collect the set of reference speakers and hypothesis speakers. Speaker mapping is exact for any count: build the overlap matrix (reference × hypothesis, in included frames), pad it to square with zeros, and solve the maximum-weight assignment with the Hungarian algorithm (O(n³), about sixty lines, written insrc/der.rswith a comment naming it; no crate). Then per included frame, withn_refthe number of reference speakers,n_hypthe number of hypothesis speakers, andn_correctthe reference speakers whose mapped hypothesis label is present: missed =max(n_ref − n_hyp, 0), false alarm =max(n_hyp − n_ref, 0), confusion =min(n_ref, n_hyp) − n_correct, each × 0.01 s (the NIST md-eval cardinality formula, so an unmapped label speaking over a reference speaker is confusion, not a miss plus a false alarm).reference_s= sum ofn_ref× 0.01 s over included frames. Comment the frame size. -
Step 4: run
scripts/check, expect last lineCHECK OK. -
Step 5: commit:
git add src/der.rs src/lib.rs && git commit -m "der: diarization error rate scorer".
Task 2: Fetch the AMI fixture
Files:
- Create or modify:
scripts/fetch-fixtures(if it exists, add aderset beside the existing sets and keep its existing behaviour and last line; if it does not exist, create it with only thederset, ending withFIXTURES OK <n> files <total seconds>s). Executable,set -euo pipefail. - Modify:
docs/developing/measurements.md(a## Fixturessection; create it at the end if absent, otherwise add a paragraph for the DER set with the exact command).
Interfaces:
-
Produces:
~/.cache/ambient/fixtures/der/manifest.json, a JSON array of{"wav": "<abs>", "rttm": "<abs>", "meeting": "ES2004a", "seconds": 300}. -
Step 1: the meetings are pinned:
ES2004a,IS1009a,TS3003a(the first meeting of three AMI test-set series; say so in a comment). For each meetingM, download withcurl --fail --location --silent --show-error -o <tmp>thenmvinto place, only if absent:https://groups.inf.ed.ac.uk/ami/AMICorpusMirror/amicorpus/<M>/audio/<M>.Mix-Headset.wavto~/.cache/ambient/ami/<M>.Mix-Headset.wav, andhttps://raw.githubusercontent.com/pyannote/AMI-diarization-setup/67c2d539286e89f68952d5dcf83912bd9f01dfae/only_words/rttms/test/<M>.rttmto~/.cache/ambient/ami/<M>.rttm. Verify each wav withffprobeand each RTTM by checking it is non-empty and every non-blank line starts withSPEAKER <M>(delete the file, exit non-zero and name the URL otherwise). -
Step 2: on the first run print
shasum -a 256of each downloaded file; then pin those six digests in the script as asha256sum-style table and have the script verify cached files against it on every run, so an upstream change surfaces as a failure and not as a moved baseline. -
Step 3: produce
~/.cache/ambient/fixtures/der/<M>.wav: the first 300 s, 16 kHz mono 16-bit, viaffmpeg -t 300 -ac 1 -ar 16000 -sample_fmt s16. Produce<M>.rttm: the reference lines whose start is under 300 s, withdurclipped sostart + dur ≤ 300, written withawkin the same 10-field RTTM layout. Writemanifest.json. Idempotent: a second run does no network and rewrites nothing unless--force. Print one line per file produced. -
Step 4: run
scripts/fetch-fixtures, then run it again. The report quotes the last line of both runs, the six digests now pinned, andls -la ~/.cache/ambient/fixtures/der. -
Step 5: run
node .github/scripts/check-links.mjs, expect0 broken link(s). Commit:git add scripts/fetch-fixtures docs/developing/measurements.md && git commit -m "fixtures: AMI diarization set".
Task 3: A harness that scores Ambient’s own diarize path
Files:
- Create:
src/bin/der.rs - Modify:
src/der.rs(addspans_to_turns) - Modify:
docs/developing/measurements.md(add the binary and its command to the table of tools, or a## DERsection if there is no such table)
Interfaces:
-
Consumes:
ambient::der::{parse_rttm, score, Turn};ambient::resample::{read_wav_any, to_16k};ambient::diarize::{Diarizer, Span, DEFAULT_THRESHOLD};ambient::session::models_root. -
Produces:
pub fn spans_to_turns(spans: &[Span], sr: usize) -> Vec<Turn>insrc/der.rs;cargo run --release --bin der -- [--manifest <path>] [--threshold <f32>] [--collar <f64>] [--json <path>], a table with one row per meeting and atotalrow, JSON rows as{"meeting","seconds","reference_speakers","hypothesis_speakers","missed_s","false_alarm_s","confusion_s","der","run_s"}. -
Step 1: write the failing test in
src/der.rs’s test module:[Span{start:0,end:16000,speaker:0}, Span{start:32000,end:48000,speaker:1}]at 16 kHz becomes[Turn{0.0,1.0,"0"}, Turn{2.0,3.0,"1"}]. -
Step 2: run
cargo test der::, expect FAIL “cannot find functionspans_to_turns”. -
Step 3: implement
spans_to_turns, then the binary: parse args (default manifest~/.cache/ambient/fixtures/der/manifest.json, thresholdDEFAULT_THRESHOLD, collar 0.25). Resolve the models exactly assession::diarize_sessiondoes (src/session.rs:1188-1197):models_root()?joined withpyannote-segmentation-3.0/model.onnxandwespeaker_en_voxceleb_resnet34_LM.onnx, bailing withmissing <path> — run ./fetch-models.sh. For each manifest entry:read_wav_any→to_16k→Diarizer::diarize(&samples, threshold)→spans_to_turns(.., 16_000)→scoreagainstparse_rttmof the rttm file; an empty reference is an error naming the file, never a zero. Print the table; the total row sums the four seconds fields and computes DER from the sums. Exit non-zero with the file name and the fetch script to run when the manifest is missing. -
Step 4: run
scripts/check, expectCHECK OK; thencargo run --release --bin derand quote the total row in the report; runnode .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add src/bin/der.rs src/der.rs docs/developing/measurements.md && git commit -m "der: harness over the diarize path".
Task 4: The quality script reports DER
Files:
- Create or modify:
scripts/quality(if it exists, add a DER step and appendder=<total>to its existing last line; if it does not exist, create it quiet likescripts/check, runningscripts/fetch-fixturesthen the DER harness, ending withQUALITY OK der=<total>). - Create:
quality/der.jsonfrom your own run (the harness’s--jsonoutput). - Modify:
docs/developing/landing.md(one sentence under “What a night looks like” saying the quality script’s last line carriesder=, and that wiringscripts/qualityintoCHECK_CMDinloop/configis a change a person commits, because the hooks denyloop/commits from a task; the script is the gate, the config line is the switch, and this plan delivers the gate).
Interfaces:
-
Consumes:
cargo run --release --bin der -- --json <tmp>. -
Produces: the regression rule: any meeting’s
deror the total more than 0.01 abovequality/der.jsonfails the script, printing the offending rows;--updaterewritesquality/der.json. -
Step 1: run
scripts/qualitybefore the change (or note it does not exist yet). -
Step 2: implement the step with
jq(present on this machine) comparing per-meeting and totalderagainst the baseline file; whenquality/der.jsonis absent, write it and printQUALITY BASELINE der=<total>as the last line instead. -
Step 3: run
scripts/qualitytwice: the first run writes the baseline, the second endsQUALITY OK … der=<total>. Quote both last lines. -
Step 4: run
scripts/check, expectCHECK OK; runnode .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add scripts/quality quality/der.json docs/developing/landing.md && git commit -m "quality: DER baseline". The report’s first line says whetherloop/config’sCHECK_CMDalready runsscripts/quality; if not, it names that as the one-line daytime change for a human.
Task 5: The clustering threshold
Files:
- Modify:
src/diarize.rs:38(DEFAULT_THRESHOLD), only if the rule below says so. - Modify:
docs/developing/measurements.md(a## DER by thresholdtable with the date). - Modify:
quality/der.jsonviascripts/quality --update, only if the constant changed.
Interfaces:
-
Consumes:
cargo run --release --bin der -- --threshold <t> --json <tmp>. -
Step 1: run the harness at thresholds 0.30, 0.40, 0.50, 0.60, 0.70, 0.80 and record, per threshold, total DER and the hypothesis speaker count per meeting next to the reference count.
-
Step 2: keep the threshold with the lowest total DER only if it beats 0.50 by more than 0.01 on every meeting and never produces more than twice the reference speaker count; otherwise restore 0.50 and write that the default holds.
-
Step 3: if the constant changed, run
scripts/quality --updateand include the diff toquality/der.json. -
Step 4: run
scripts/check, expectCHECK OK; runnode .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add src/diarize.rs docs/developing/measurements.md quality/der.json && git commit -m "diarize: threshold measured against AMI"(omit files that did not change).
Task 6: Minimum embedding length
Files:
- Modify:
src/diarize.rs:34(MIN_EMBED, currently one second of samples), swapped between runs as a constant. - Modify:
docs/developing/measurements.md(a## DER by minimum embedding lengthtable).
Interfaces:
-
Consumes: the DER harness.
-
Step 1: run the harness with
MIN_EMBEDat 0.5 s, 1.0 s (current), 1.5 s and 2.0 s of samples, recording total DER,missed_s, and the hypothesis speaker counts. -
Step 2: same keep-or-restore rule: keep a value only if it beats the current one by more than 0.01 on every meeting; otherwise restore and record that the default holds. Explain in one sentence in the measurements table what moved (segments shorter than
MIN_EMBEDare not embedded, so a lower value trades confusion for missed speech or the reverse). -
Step 3: if the constant changed, run
scripts/quality --update. -
Step 4: run
scripts/check, expectCHECK OK; runnode .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add src/diarize.rs docs/developing/measurements.md quality/der.json && git commit -m "diarize: minimum embedding length measured"(omit files that did not change).