1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
//! Native CoreML segmentation and embedding backends for the
//! pyannote-community-1 diarization pipeline (its clustering core `diaric`,
//! extracted from the ancestor `dia` crate).
//!
//! Design spec:
//! `docs/superpowers/specs/2026-07-11-dia-coreml-backends-design.md`.
//! Product driver: [`findit-studio/desktop#120`][desktop-120] (components
//! 3+4: speaker segmentation ~20x and speaker embedding ~30x uplift
//! targets). The clustering *algorithms* are pyannote's, their implementation
//! extracted from `dia` into `diaric` — the backend-free runtime clustering core
//! this crate drives. This crate replaces `dia`'s `ort`-backed segmentation and
//! embedding inference with native CoreML/ANE execution, producing tensors
//! bit-compatible with `diaric`'s public compute entry points so its
//! parity-proven clustering/reconstruction runs unchanged. As of the clustering
//! phase it also *drives* that clustering at runtime through a thin
//! backend-selecting stage (see the clustering example below and the [`cluster`]
//! module) — it does not reimplement the clustering algorithms.
//!
//! [desktop-120]: https://github.com/findit-studio/desktop/issues/120
//!
//! macOS only (built on [`crate`]).
//!
//! Sans-I/O, like `whisper`: audio enters as 16 kHz mono `&[f32]`. Behind the
//! `speaker` feature the runtime clustering core is `diaric` — the backend-free,
//! `ort`-free crate that owns the clustering algorithms this module drives: the
//! offline pyannote-community-1 AHC→VBx pipeline
//! ([`diaric::offline::diarize_offline`]) and the online FluidAudio
//! `SpeakerManager`-parity centroid matcher
//! ([`diaric::cluster::online::OnlineClusterer`]), plus PLDA projection and
//! reconstruction. So this module DOES assign speaker labels at runtime: cluster
//! an [`extract::Extraction`] through its public
//! [`extract::Extraction::diarize`] / [`extract::Extraction::diarize_with`] /
//! [`extract::Extraction::diarize_online`] methods (backend selection lives in
//! the [`cluster`] module), each returning a speaker-labelled
//! [`diaric::offline::OfflineOutput`]. `dia` (the former speakerkit `dia`
//! feature) is NOT the runtime dependency — it is the test-only DER reference
//! oracle, and it is not even a dependency of this crate: it lives in the
//! never-published `coremlit-parity` package, behind that package's
//! `speaker-oracle` feature, which alone pulls dia's `ort` inference.
//!
//! ```no_run
//! use coremlit::audio::speaker::extract::Options;
//! use coremlit::audio::speaker::source::{AnySource, ModelSource, Source};
//! # let audio: Vec<f32> = vec![0.0; 16_000];
//! let options = Options::new().with_source(Source::FluidAudio); // the default
//! let source = AnySource::load("Models/speakerkit", options)?;
//! let extraction = source.extract(&audio)?;
//! // cluster behind `speaker`: usual entry `extraction.diarize(&plda)?` (speaker-
//! // labelled spans); lower-level bridge `into_offline_input(&plda) -> diaric::offline::OfflineInput`
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
//!
//! # Multi-source backend
//!
//! As of the multi-source split
//! (`docs/superpowers/specs/2026-07-13-speakerkit-multisource-diarizer-backend-design.md`),
//! the segmentation/embedding pipeline above is [`source::ModelSource`]'s
//! first implementation ([`source::FluidAudioSource`]), not the only one — see
//! [`source`] for the pluggable-source abstraction. Two user-selectable CoreML
//! conversions of (functionally) the same pyannote pipeline sit behind one
//! [`extract::Options::with_source`] switch — genuinely different code paths
//! (argmax bakes preprocessing/decoding into its graph; FluidAudio does host-
//! side powerset/mask/window decode), not interchangeable weight swaps:
//!
//! - **FluidAudio** (`Source::FluidAudio`, default;
//! `FluidInference/speaker-diarization-coreml`, except the segmentation
//! graph, which is the fp16-safe re-conversion published as
//! `FinDIT-Studio/speakerkit-coreml` — see `tests/speaker/model_io.rs` for
//! the pinned revision and per-file SHA-256):
//! powerset log-probs `[1,589,7]`, host-side decode; seg 99.97% / embed
//! cosine 0.99999989 vs fp32 dia-ort. **Validated** default.
//! - **argmax** (`Source::Argmax`, optional; `argmaxinc/speakerkit-coreml`):
//! already-decoded in-graph; tensor-fidelity **validated** bit-exact vs
//! argmax's own Swift, but clustering parity **CHARACTERIZED, NOT VALIDATED**.
//!
//! ## ⚠ Two safety-critical, test-pinned caveats
//!
//! **1. Do not use `Source::Argmax` on multi-speaker audio.** Measured: 0.0000%
//! standard-collar DER at 1-2 speakers, then 3.3-9.3% DER on three of four
//! multi-speaker clips (e.g. `14_mrbeast` 9.29% finding 5 of 4 speakers). The
//! error is ~100% confusion — argmax's embedder consumes an 80-mel spectrogram
//! from its own preprocessor, landing in a differently-scaled space the
//! **frozen, pretrained** community-1 PLDA (fixed 0.6 AHC linkage) was never
//! fit for. The ~0.94 embedding cosine is NOT benign at DER; any earlier claim
//! it was is retracted (it was measured only on 1-2 speaker clips).
//!
//! **2. The FluidAudio default is not frame-exact on multi-speaker audio
//! either** — 0.0000% vs dia-ort on every ≤2-speaker clip and the 7-speaker
//! clip, but 0.12% / 0.39% on `12`/`14`, over this module's 0.1% DER-parity
//! bound. It never gets the speaker *count* wrong and is ~23× more faithful
//! than argmax; the "0.1% parity" claim was only ever tested on 1-2 speaker
//! audio. Both limitations are pinned in `coremlit-parity`'s `tests/speaker/parity_e2e.rs`, which
//! fires if behavior moves in either direction (a fix must be a deliberate
//! re-baseline, not a silent pass).
//!
//! **What "DER" means here:** the reference RTTMs are pyannote.audio 4.0.4's
//! own OUTPUT (captured by `dia`), *not* human annotation — every DER number is
//! a distance to the reference implementation, never to ground truth.
//!
//! # Compute units, gates, and licensing
//!
//! Both sources default to `ComputeUnits::All`; every tensor-fidelity gate pins
//! `CpuOnly` for determinism, and the end-to-end DER gate runs on the shipping
//! `All` and asserts the placement changes no *decision* (pairwise `der_std ==
//! 0`) on the 1-2 speaker clips. For bit-reproducibility, pin `CpuOnly`
//! explicitly and record the placement. Run the model-gated suites:
//!
//! ```text
//! SPEAKERKIT_TEST_MODELS=Models/speakerkit \
//! cargo test -p coremlit --features speaker -- --ignored # seg/embed/argmax
//! cargo test -p coremlit-parity --features speaker-oracle --test speaker_parity_e2e -- --ignored
//! coremlit-parity/tests/speaker/der_gate_inventory.sh # DER binaries compiled + hermetic suite
//! ```
//!
//! The DER binaries live in `coremlit-parity` and are
//! `#![cfg(feature = "speaker-oracle")]` (dia's own ort path as the oracle);
//! without that feature they compile to nothing. They still share this crate's
//! `tests/speaker/{common,der_calc}` modules — one copy, `#[path]`-included. Model attribution
//! (pyannote community-1 **CC-BY-4.0 — attribution required**, segmentation-3.0
//! MIT, WeSpeaker, and the argmax repo's **undeclared** license) is recorded in
//! the crate `NOTICE` (§4).
//!
//! # Clustering
//!
//! An [`extract::Extraction`] is not the end of the road: as of the clustering
//! phase this crate drives `diaric`'s clustering directly, turning the extracted
//! tensors into speaker-labelled RTTM spans. Select a backend with
//! [`ClusterBackend`] — the offline pyannote-community-1 pipeline (the default,
//! DER-gated) or the online FluidAudio-semantics matcher (streaming,
//! order-dependent, and NOT pyannote-parity) — and run it with
//! [`diarize`](extract::Extraction::diarize) /
//! [`diarize_with`](extract::Extraction::diarize_with) /
//! [`diarize_online`](extract::Extraction::diarize_online). See the [`cluster`]
//! module for the full two-engine surface and its honesty boundaries.
//!
//! ```no_run
//! use coremlit::audio::speaker::extract::Options;
//! use coremlit::audio::speaker::source::{AnySource, ModelSource, Source};
//! use coremlit::audio::speaker::{ClusterBackend, OfflineOptions, OnlineOptions};
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let audio: Vec<f32> = vec![0.0; 16_000]; // 16 kHz mono; this crate does no I/O.
//! let source =
//! AnySource::load("Models/speakerkit", Options::new().with_source(Source::FluidAudio))?;
//! let extraction = source.extract(&audio)?;
//!
//! // Offline (the default backend): diaric's pyannote-community-1 AHC→VBx pipeline,
//! // through the frozen community-1 PLDA projection it clusters in.
//! let plda = diaric::plda::PldaTransform::new()?;
//! let offline = extraction.diarize(&plda)?;
//! println!("offline: {} spans", offline.spans_slice().len());
//!
//! // The same engine, tuned (every default already equals dia's = pyannote's).
//! let tuned = ClusterBackend::Offline(OfflineOptions::default().with_threshold(0.7));
//! let _ = extraction.diarize_with(&plda, tuned)?;
//!
//! // Online: FluidAudio's streaming greedy matcher — raw cosine, NO plda.
//! let online = extraction.diarize_online(OnlineOptions::default())?;
//! println!("online: {} spans", online.spans_slice().len());
//! # Ok(())
//! # }
//! ```
//!
//! # Across recordings
//!
//! Everything above is *within* one recording. [`calibrate`] is the other
//! direction — the AS-Norm1 door for scoring voice profiles at
//! cluster-centroid grain against a library-wide cohort, so the same person
//! found in two different recordings can be bound to one identity
//! ([`findit-studio/coremlit#123`][coremlit-123]). Raw cosine scores are not
//! comparable across speakers, so a single global threshold over-merges some
//! identities and under-merges others; AS-Norm rescales each trial against the
//! cohort distribution of the two speakers involved.
//!
//! It is a SCORING surface and nothing else: the caller owns the library, the
//! identities and the cohort. It is deliberately NOT wired into the clustering
//! above — that is a within-recording question with a parity oracle of its own.
//!
//! Two things to know before reading that module. The first: the two sides of
//! a trial are not symmetric. An **enrolled** speaker has an identity the caller can
//! name, so its own entries can be dropped from a library-sampled cohort. An
//! unidentified **probe** does not — its identity is what identification is
//! trying to discover — so it scores against a
//! [`HeldOutCohort`](calibrate::HeldOutCohort) instead, and no entrypoint
//! there lets a candidate's key reach a probe's statistics.
//!
//! The second: a calibrated trial is the product of ONE
//! [`Calibration`](calibrate::Calibration) — a cohort and an
//! [`AsNormOptions`](calibrate::AsNormOptions) fixed together, which produces
//! each side bound to its own profile and then computes the trial from those
//! two profiles. So the metric, the impostor population, the trial's endpoints
//! and the top-N configuration are one value's rather than four the caller
//! carries between calls. Sides taken over different impostor populations can
//! invert which candidate ranks first while every value involved stays finite,
//! in range and computed in the same metric, so mixing two calibrations is a
//! typed refusal rather than a caveat.
//!
//! [coremlit-123]: https://github.com/findit-studio/coremlit/issues/123
/// The runtime clustering config surface (design spec §Architecture): select a
/// backend with [`ClusterBackend`] and tune the offline engine with
/// [`OfflineOptions`] or the online engine with [`OnlineOptions`];
/// [`extract::Extraction::diarize_with`] runs it (or
/// [`extract::Extraction::diarize_online`] for the plda-free online engine). See
/// [`cluster`] for the full surface.
///
/// The speaker module deliberately does NOT re-export diaric's batch-clusterer
/// vocabulary (`OfflineClusterOptions`/`OfflineMethod`/`Linkage`) that T1 briefly
/// exposed: [`ClusterBackend::Offline`] wraps diaric's pyannote-parity *pipeline*
/// ([`extract::Extraction::diarize`] → `diaric::offline::diarize_offline`), not the
/// batch clusterer those types configure. The [`cluster`] module documents which
/// diaric entry point `Offline` wraps and why the batch vocabulary was removed
/// (design spec AMENDMENT 2026-07-16).
pub use ;