Speaker diarization, stage by stage
Who is talking,
and when?
speakrs answers one question: at each point in an audio file, which global speaker or speakers are talking?
The difficulty sits in the middle of the pipeline. The segmentation model reads short windows and reports local speaker slots, and a slot carries no identity. Slot A in window 12 and slot A in window 13 may be different people.
Sixteen stages follow, and most of them exist to repair that. Five run on the accelerator, and the two neural networks among those five hold nearly all the arithmetic. The other eleven are small enough to leave on the CPU, where they cost almost nothing.
The networks get the hardware. The cheap stages get the arguments: every point of difference between this pipeline and the reference implementation shows up as speaker confusion, never as missed speech.
01 — The map
Sixteen stages, three devices
Pick a stage to see what it computes, the shape it hands on, and where it runs. Stages 1 to 9 turn sound into vectors and carry enough parallel arithmetic to reward a GPU. Stages 10 to 16 turn vectors into people, using small matrices and sequential decisions, and they stay on the CPU on purpose.
02 — Act one
Sound becomes local speakers
A mono 16 kHz file is a list of air-pressure numbers, 16,000 per second. Amplitude over time is all it holds. Nothing in those numbers separates one voice from another, and nothing marks where a turn begins.
Windows: the model cannot see the whole file
The segmentation model takes a fixed 10-second window. speakrs slides that window across the file with a step that depends on the execution mode. Smaller step, more overlap, more model calls. A neural window is least reliable at its edges, so the overlap buys accuracy back there. It pays a second time in stage 5, where speakrs averages what several windows say about how many people are talking.
Window step against work done
The window length is fixed at 10 s. Only the step changes. These are the four real step values in pipeline/config.rs.
Segmentation: seven classes, not three speakers
For one window the model returns [589, 7]: 589 output frames, each with seven class scores. Three blocks reduce 160,000 samples to those 589 frames, four layers walk them in order, and three produce the classes. The recurrence is why this stage does not saturate a GPU as cleanly as the embedding network does — h[t] depends on h[t-1], so time cannot be parallelized the way channels can.
The exported graph ends in , so those seven numbers are log-probabilities rather than raw . The next stage takes an and LogSoftmax is monotonic, so the decode is identical either way, and the Rust signature calls them logits.
fixtures/models/segmentation-3.0.onnx. The convolutions do the local acoustics and downsample; a filter spanning 30 ms cannot tell that a second voice started, so four layers carry state across the whole window. The split is also the GPU story: the left half is one big matmul in disguise, the right half is a loop.The seven follows from the slot count. speakrs builds the class list at run time from every subset of three local speakers with at most two active at once: one empty set, three singles, three pairs.
Powerset decode
Drag the scores. argmax picks one class, and a lookup table turns that class into binary activity for three local slots. This is the whole decoder — there is no threshold and no per-speaker sigmoid.
A local slot is a seat, not a person. The model may seat Alice in slot A in one window and slot B in the next.
The speaker-count track
Because windows overlap, each output frame is covered by several of them. speakrs sums the decoded activity across windows and rounds, giving one integer per frame: how many people are talking right now. That number is used at the very end, to decide how many speakers a frame is allowed to have.
This averaging is also why the segmentation model is safe in practice. A small error in one window is outvoted by its neighbors. Measured on this pipeline, W8A16 segmentation ran 41 % faster with no measured DER change, while the same treatment applied to the embedding model regressed DER.
The asymmetry is the interesting half. Segmentation output gets voted on. Embedding output does not: a speaker vector is consumed once, by a distance, and nothing downstream can outvote a bad one. That is the working explanation for why the same quantization is free in one model and expensive in the other. It is a hypothesis the project is still testing, not a result.
03 — Act two
A voice becomes 256 numbers
To decide that two anonymous slots are the same person, speakrs needs a representation of the voice, not the words. That means leaving the waveform behind.
The result is [998, 80] per chunk: 998 feature frames, 80 . Read it as a small grayscale image, time across, frequency up, energy as brightness.
CUDA has a sharp edge here. The exported embedding graph carries one DFT node, and in ONNX Runtime 1.24.2 that node is the only operator in the graph with no CUDA registration, so it lands on the CPU. The filterbank is cheap; the break it puts in the GPU graph is not. The CoreML export already sidesteps this by using the matrix form.
Why the frame gets multiplied by a bell curve
A DFT does not see a slice of audio. It sees a signal that repeats that slice forever, so the last sample sits next to the first. Cut a raw frame and you create a step there, and a step contains every frequency. The fake energy that results is called . Move the tone off an exact bin and watch what the raw slice reports.
Set the tone to exactly 8.00 and the raw slice is clean too: a tone completing a whole number of cycles inside the frame joins back onto itself with no step. Real audio never does that, which is why the window is not optional. Note the trade at 8.00 — the windowed peak is two bins wide where the raw one is a single spike. A window buys a 30 dB drop in far-off junk at the price of slightly blurring the peak, and for a filterbank that pools into 80 wide bands anyway, that is a trade worth making. It costs one elementwise multiply against a constant array built at startup.
The window destroys the edges of each frame — w[0] is exactly zero, so sample 0 contributes nothing. Frames therefore overlap: at a 25 ms frame and a 16.875 ms hop, what gets attenuated at one frame's edge sits near the centre of the next.
Masks: one image, three voices
Each of the three local slots gets its own activity mask over time. The mask multiplies the filterbank row-wise: if slot A is silent at frame t, that whole 80-band row contributes nothing to A's embedding. One filterbank, three masks, three different inputs to the same network.
Not every mask is worth embedding. If Alice and Bob overlap, their spectra are mixed and the resulting vector is a blend of two people. speakrs therefore keeps an embedding for clustering only when at least 20 % of the chunk's frames have exactly one active speaker. Slots whose total activity falls under MIN_SPEAKER_ACTIVITY = 10.0 are skipped before inference runs at all.
ResNet34, and the reuse trick
The embedding network is a WeSpeaker . Small kernels slide across the time–frequency image; early layers find frequency edges and harmonics, later layers combine them into something speaker-specific. Each block computes y = x + F(x), the same shape a transformer uses around attention and MLP, and for the same reason: each block learns a correction, not a replacement.
After the convolutions, masked turns a variable amount of speech into a fixed vector: a mask-weighted mean and standard deviation per channel, concatenated, then one matrix multiply down to 256 numbers.
Finally the vector is . Direction carries the identity; length does not. After normalization, is a plain dot product.
04 — Act three
Vectors become people
Now there is a E of [N, 256]: N reliable local voice observations, each from one slot in one window. Nothing in it says who is who. Clustering decides that.
AHC: the first, blunt grouping
Every embedding starts as its own . speakrs L2-normalizes, computes all pairwise Euclidean distances, then merges the closest pair repeatedly using , cutting the at a distance of 0.6. On unit vectors that Euclidean threshold has an exact cosine reading, shown live below.
The merge threshold
A two-dimensional stand-in for the real 256-dimensional space: 21 unit-length observations from three people, clustered with the same rule speakrs uses: centroid linkage on Euclidean distance, cut at a fixed height.
The pairwise distance matrix is E · Eᵀ and would love a GPU. The merge loop would not: each step depends on the last, and with a few thousand observations the whole stage is a rounding error in the total runtime.
PLDA: change the coordinate system
Before refining, speakrs projects the 256-dimensional down to 128 dimensions: a learned rotation and scaling that stretches the directions which historically separate speakers and shrinks the ones that mostly encode channel and noise.
Loading those parameters involves matrix products, an inversion, and a generalized . Those amplify precision error, so speakrs does the whole thing in f64. This is the opposite of the convolution case, where FP16 error averages out across thousands of accumulations.
VBx: hard labels become probabilities
AHC says "observation 2 is Alice". VBx says "observation 2 is 75 % Alice, 25 % Bob", and iterates: build a soft speaker model from the current responsibilities, re-score every observation against every model, the scores, repeat. speakrs runs a GMM variant with no HMM transitions, fa 0.07, fb 0.8, and a maximum of 20 iterations, stopping early when the ELBO improves by less than 1e-4.
What iterating does to gamma
A simplified soft-assignment loop on the hard set of three similar voices. Each column is one observation; the stacked bar is its probability over speakers. Step off zero and the hard AHC labels relax into probabilities: most observations stay confident, and the ones near a boundary do not.
This widget is an illustration of the update, not a reproduction of the shipped algorithm. The measured effect of the same knob is real, though, and it does not point one way. On Earnings-21 the 3-iteration Fast mode scores 8.9 DER against 10.6 for the 20-iteration standard mode, and on CUDA the entire gap between the two is speaker confusion, with missed and false-alarm rates within 0.1 of each other. On VoxConverse the ordering reverses. So the value is tied to the execution mode, which is the wrong axis: three iterations regressed VoxConverse in standard mode, mode-awareness was the cheapest patch available at the time, and the per-dataset sweep that would answer the question properly has not been run.
Back to the timeline
The last three stages are bookkeeping, and all of it is CPU work. Weighted averages of the original 256-dimensional embeddings give one centroid per surviving speaker. Every local slot is then scored by cosine against every centroid, with a constraint: two slots that are active at the same time may not be assigned to the same person. Then the local activity is added back onto a global timeline, and each frame keeps the top k speakers, where k is the count that overlap-averaging produced back in stage 5.
Overlap-add, then top-k
Each chunk votes for the speakers it heard. The votes accumulate per frame; the speaker-count track decides how many of them survive.
What is left is list processing: turn runs of active frames into time ranges, drop flickers shorter than the minimum duration, bridge short gaps, merge adjacent ranges for the same speaker, write . In the default configuration the hysteresis thresholds are onset = offset = 0.5 with no minimum-duration filtering; the Fast modes raise both minimums to 3 frames because a 2 s step produces more single-frame flicker.
05 — The whole thing
One file, told as shapes
Strip the prose and the pipeline is a ladder of tensors. Each rung is a change of representation, and the interesting rungs are where the dimensionality collapses.
06 — Where the arithmetic lives
What is worth putting on a GPU
Almost every stage here is matrix math, so asking whether a stage is matrix math settles nothing. The question that settles it is whether a stage's matrices are large enough for the arithmetic to outweigh the kernel launch and the transfer.
Embedding ResNet
The most naturally parallel stage in the pipeline: convolution, residual addition, normalization, reduction over time, one final matrix multiply. Many channels, many windows, no sequential dependency. This has been one of the largest compute stages.
Segmentation
Large repeated inference over many overlapping windows. The recurrent block limits how much of the time axis can be parallelized, so it never uses a GPU as cleanly as the ResNet does. The volume of work is high, though, and batching keeps the device busy.
Filterbank kept next to embedding
Fusing the two removes CPU waits, tensor copies, repeated allocation, separate submissions, and a graph capture broken between stages that should be one. The FFT itself was never the expensive part.
Powerset decode and reconstruction
An argmax, a table lookup, some additions and a top-k. Real work, but far too little arithmetic per byte moved to justify a device round trip.
PLDA
A small projection, and a sensitive one. The initialization runs an inversion and a generalized eigenvalue problem in f64. Moving a small matrix to a GPU can cost more than the multiply, and here it would also cost precision.
AHC and VBx
The distance matrix is parallel; the merge loop is not. VBx scores are typically [5000, 128] × [128, 5], trivial for a modern GPU, and repeated at most 20 times. Launch overhead dominates arithmetic.
The arithmetic lives in two neural networks. Every point that separates this pipeline from the reference one is a question of who said what.
07 — Measured
Measured against pyannote
is plus plus , as a fraction of reference speech. Lower is better. These are results against the reference pyannote pipeline running the same models, measured on an Apple M4 Pro.
Diarization error rate, eight datasets
Table view
Accuracy is a tie, and the component columns say what kind of tie. Across all eight datasets speakrs and pyannote agree on missed speech and on false alarm to within 0.2 points. Every DER difference between them, in either direction, is speaker confusion. The two agree on how much speech there is and where it sits. They disagree only about who it belongs to.
Which makes the CUDA numbers awkward. On CUDA, speakrs reproduces pyannote exactly on AMI IHM and Earnings-21 — same DER, same missed, same false alarm, same confusion, to the tenth — and comes out 0.2 ahead on VoxConverse dev. The confusion that the CoreML build pays is not in the algorithm, because the same algorithm does not pay it on CUDA. It is somewhere in the backend, and nobody has found it yet.
| Dataset | Implementation | DER | Missed | False alarm | Confusion |
|---|---|---|---|---|---|
| VoxConverse dev | speakrs CUDA | 7.0 | 2.3 | 2.3 | 2.4 |
| pyannote CUDA | 7.2 | 2.3 | 2.3 | 2.6 | |
| VoxConverse test | speakrs CUDA | 11.1 | 3.4 | 4.1 | 3.7 |
| pyannote CUDA | 11.1 | 3.4 | 4.1 | 3.7 | |
| AMI IHM | speakrs CUDA | 17.0 | 8.1 | 4.3 | 4.5 |
| pyannote CUDA | 17.0 | 8.1 | 4.3 | 4.5 | |
| Earnings-21 | speakrs CUDA | 9.7 | 2.6 | 2.4 | 4.7 |
| pyannote CUDA | 9.7 | 2.6 | 2.4 | 4.7 |
AVA-AVD is worth staring at for a different reason. Every implementation in the table scores between 45 and 51. On in-the-wild film audio this whole family of pipelines fails, and arguing about a point and a half between them is not a useful thing to do.
That leaves throughput, which is not a tie at all.
Speed, as multiples of real time
Table view
Same models, same decode, same clustering algorithm, and 21 to 36 times the throughput of the Python pipeline on the same machine — 50 times on ICSI in Fast mode. The gap comes from the choices above: batch the windows, keep the filterbank next to the embedding model, run the backbone once per chunk instead of once per window, and leave the small sequential math on the CPU.
Throughput is finished work. The confusion column is not.
All numbers on this page were read from the speakrs source tree and its benchmark tables on 2026-09-01. Benchmarks used collar 0 ms and pyannote batch size 32.
08 — Vocabulary
Every term on this page
Written for someone who knows how to program and how a matrix works, and who has read enough about language models to recognise a softmax. No machine-learning background assumed. Dotted underlines anywhere on the page open the matching entry.
In CUDA, a kernel is a function you launch on the device. In machine learning, a kernel is the small matrix of weights a convolution slides. Same word, unrelated meanings. This page says filter for the second one.
09 — Provenance
Where each number came from
Every constant, threshold, and measurement above has an anchor in the repository. The shape ladder is the one place with example values, and it marks N and K as data dependent where they appear.