Speakers
Who spoke when, offline, with oriel.speakers: speaker embeddings from voice-detect.cpp, clustering into speaker turns, and matching voices the app knows, on the CPU or the GPU.
oriel.speakers tells voices apart, offline. A speaker encoder (WeSpeaker ResNet34 by default, or ECAPA-TDNN, run by voice-detect.cpp on the shared ggml build) turns a stretch of speech into a short vector, its embedding; two stretches of the same voice give similar vectors. diarize embeds overlapping windows over a recording, clusters them and returns the speaker turns (“speaker 0 from 0.0 to 12.4 s”) with one centroid per speaker, which match compares with voices the app stored before (“that’s Ana”). Pair it with Dictation to label a transcript.
Availability
| Platform | Engine | GPU |
|---|---|---|
| Linux | voice-detect.cpp on the CPU (up to 8 threads) | CUDA, Vulkan (ggml_gpu.load) |
| Windows | same | Vulkan |
| macOS | same | Metal |
| Android, iOS | not yet: oriel.speakers.available is false |
Turned on by .speakers = true, which needs .llama = true or .whisper = true (they supply the shared ggml build; otherwise: speakers needs llama or whisper). voice-detect.cpp is fetched lazily and compiled into the app. No permission; downloading a model needs network access. The GPU flags are the Local AI ones.
Quick start
build.zig:
const dep = b.dependency("oriel", .{
.target = target,
.optimize = optimize,
.speakers = true,
.whisper = true, // or .llama = true
});
Zig, on a worker (a long recording takes a while):
const speakers = oriel.speakers;
// A model from speakers.catalog, downloaded with model_download.fetchVerified.
const enc = try speakers.Encoder.load(model_path);
defer enc.deinit();
// 16 kHz mono samples, and where there is speech (whisper's VAD, or the
// whole recording as one region).
var progress: speakers.Progress = .{};
var d = try speakers.diarize(gpa, enc, samples, regions, .{ .progress = &progress });
defer d.deinit(gpa);
for (d.turns) |t| std.log.info("{d:.1}-{d:.1} s: speaker {d}", .{ t.start, t.end, t.speaker });
// Known voices: centroids saved earlier, with enc.family().
for (d.centroids, 0..) |c, i| {
if (speakers.match(c, known_voices, 0.6)) |who| std.log.info("speaker {d} is {s}", .{ i, names[who] });
}
How it works
- Windows. Each speech region is cut into
window-long windows (3 s) everystep(1.5 s), plus one ending at the region’s end. A region shorter than a window gets one window over all of it if it is at least 0.5 s; shorter ones are skipped. - Embeddings. Each window becomes an L2-normalized vector (192 values for ECAPA-TDNN, 256 for WeSpeaker).
progress.donecounts them up toprogress.total;progress.cancelstops witherror.Canceled. - Clustering. Average-linkage agglomerative clustering on cosine similarity: the two most similar clusters merge until the best pair is below
threshold(or, withspeakersset, until that many are left). Then clusters smaller thanmin_sizewindows fold into their nearest big one: a single window always, a larger one only when its centroid is at leastfold_above(0.3) similar, so a voice heard for two windows in a short meeting stays a speaker of its own. Clusters whose centroids are abovemerge_abovemerge. Past 2048 windows (about 25 minutes of speech) the clustering runs on 2048 evenly spaced ones and every window goes to the nearest resulting centroid, so memory stays at 16 MB. - Turns. Overlapping or touching windows of one speaker become one turn; where two speakers’ windows overlap, the boundary is the middle. Speakers are numbered by first appearance.
Short clips favor speakers when the app knows the count: with only a window or two per voice, clustering has little to go on.
Reference
Build options
| Option | Default | Effect |
|---|---|---|
.speakers / -Dspeakers | false | voice-detect.cpp; oriel.speakers. Needs .llama or .whisper. Desktop only. |
Functions
| Function | What it does |
|---|---|
Encoder.load(path) | Load a GGUF encoder; error.LoadFailed with lastLoadError() explaining. |
enc.embed(gpa, samples) | The L2-normalized embedding of 16 kHz mono samples; the caller frees it. |
enc.dim(), enc.family() | Values per embedding; the embedding space (voicedetect:<arch>:<checkpoint>:<dim>), to store with saved voices. |
diarize(gpa, enc, samples, regions, opts) | Speaker turns and centroids; Diarization.deinit(gpa) frees them. |
cluster(gpa, embeddings, opts) | Labels 0..k-1 for any embeddings, numbered by first appearance. |
match(centroid, known, threshold) | The most similar known voice at or above threshold, or null. |
cosine(a, b) | Cosine similarity. |
An encoder is not thread-safe: voice-detect.cpp keeps one backend for the process, so run one embed or diarize at a time. Its environment variables apply: VOICEDETECT_DEVICE=cpu keeps it on the CPU, VOICEDETECT_THREADS sets its threads.
Models
speakers.catalog, from Hugging Face mudler/voice-detect-gguf, the default first:
| Id | Size | Embedding | Weights’ license |
|---|---|---|---|
wespeaker-resnet34-voxceleb | 25 MB | 256 | CC-BY-4.0 |
ecapa-tdnn-voxceleb | 61 MB | 192 | Apache-2.0 |
Embeddings from different models are not comparable: store enc.family() with a saved voice and compare it before matching.
Similarities measured with Oriel’s tests (one speaker, 3 s halves of a 6 s clip; the second half sped up 1.3x as a stand-in for another voice):
| Model | Same voice | Sped-up copy |
|---|---|---|
| ECAPA-TDNN | 0.90 | 0.46 |
| WeSpeaker ResNet34 | 0.87 | 0.34 |
Real voices tell them apart better (LibriSpeech, 3 s halves):
| Model | One man | Two women | A man and a woman |
|---|---|---|---|
| WeSpeaker ResNet34 | 0.73 | 0.22 | 0.08 |
| ECAPA-TDNN | 0.80 | 0.70 | 0.45 |
ECAPA’s different people stay too alike to cluster: SpeechBrain trained it on its own features, not the Kaldi fbank voice-detect.cpp feeds every model. WeSpeaker is the default. On three one-minute LibriSpeech conversations (GhostPen’s meetings: windows inside whisper’s segments, clustered, a segment taking its windows’ majority) it found 3, 4 and 1 speakers for 3, 3 and 1, with 10/12, 12/14 and 9/9 segments right.
Licenses
voice-detect.cpp is MIT (NOTICE, licenses/speakers). The models are downloaded by the app at run time, not distributed with Oriel; their licenses are upstream’s (Apache-2.0, CC-BY-4.0: attribution).