Skip to content

feat: speaker diarization with voice-print identification - #3

Merged
Pinous merged 5 commits into
feat/models-command-and-cifrom
feat/speaker-diarization
Jul 31, 2026
Merged

feat: speaker diarization with voice-print identification#3
Pinous merged 5 commits into
feat/models-command-and-cifrom
feat/speaker-diarization

Conversation

@Pinous

@Pinous Pinous commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Adds optional speaker diarization — who said what — plus voice-print enrolment so SPEAKER_00 labels become real names.

Usage

vox transcribe live.mp4 --diarize --speakers 2   # who spoke when
vox speakers add Coco --from live.mp4 --at 120-140   # enrol a voice, once
vox transcribe other.mp4 --diarize --identify        # Coco recognised from now on

Outputs carry the speaker: [Coco] in SRT, per-speaker blocks in TXT, "speaker" field in JSON.

Engine choice

sherpa-onnx (2 MB + 16 MB native lib, one transitive dependency) rather than pyannote.audio (22 direct dependencies, including torch, Lightning, matplotlib and three OpenTelemetry packages). It runs the same models as pyannote 3.1 — pyannote 3.0 segmentation and wespeaker resnet34-LM embeddings — but through ONNX Runtime.

The deciding factor is that this project is Apple Silicon only: there is no CUDA, and MPS is unreliable for pyannote, so torch would run on CPU anyway — all of its weight, none of its benefit. Everything here is local and free: no API, no key, no per-hour cost.

Architecture

sherpa_onnx is imported in adapters/ only. models/, ports/ and use_cases/ contain zero references to it, so swapping the engine means writing one adapter and changing one line in _build_use_case.

The interesting logic is pure domain, in plain Python with no numpy: assign_speakers (overlap-weighted turn assignment), rename_speakers, cosine_similarity/match_speakers, longest_turn_per_speaker.

Two non-obvious fixes

  • --diarize disables silenceremove and afftdn/dynaudnorm. Silence removal physically shifts the audio timeline out of sync with the transcript, and dynamic normalisation flattens exactly the cues that separate voices.
  • Diarization always runs on the same file that was transcribed, which a test locks down.

Verification

Two-voice dialogue with known ground truth: turns alternate correctly (A-B-A-B), and after enrolling both voices, identification maps them to the right names. Speaker separation measured at 0.94/0.92 within a speaker versus 0.71/0.65 across speakers, hence the 0.8 match threshold.

256 tests green, ruff clean, written test-first throughout.

Known limits

  • Automatic speaker-count detection still fuses very similar voices; pass --speakers N when you know it. A silhouette-based automatic estimate is the planned follow-up.
  • The 0.8 threshold was calibrated on synthesised voices and needs confirming on real ones.
  • vox channel (batch) does not expose --diarize yet.
  • SherpaVoicePrintExtractor.extract decodes the whole file per call — fine for enrolment, needs a single decode before batch use.

Based on feat/models-command-and-ci so the diff shows only this work.

🤖 Generated with Claude Code

Pinous and others added 5 commits July 31, 2026 16:02
Add optional speaker diarization via sherpa-onnx (pyannote segmentation
+ wespeaker embeddings, ONNX on CPU, fully local) behind a Diarizer port,
plus voice-print enrolment so SPEAKER_00 labels become real names.

- domain stays pure: SpeakerTurn, VoicePrint, assign_speakers,
  rename_speakers, cosine_similarity/match_speakers, longest_turn_per_speaker
  are plain Python with no numpy and no sherpa
- sherpa_onnx is imported in adapters/ only, so the engine stays swappable
  through the port
- --diarize turns off silence removal and denoising: silenceremove shifts
  the timeline away from the transcript, and dynaudnorm/afftdn degrade the
  speaker embeddings
- diarization always runs on the exact file that was transcribed
- vox speakers add/list stores voice prints in ~/.vox/voiceprints.json,
  --identify then renames labels on every later video
- outputs carry the speaker: [NAME] in SRT, per-speaker blocks in TXT,
  "speaker" field in JSON
- worker threads default to cpu_count - 2, floored at 2

Verified end to end on a two-voice dialogue: turns alternate correctly,
enrolment then identification maps both speakers to their real names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uz3rnGtYHs6YcBNr6MkY1z
--identify was pointless: when no voice is enrolled the use case returns
early without extracting a single embedding, so running identification
unconditionally costs nothing. Whenever voices are known, matching
speakers are now renamed automatically.

Replaced by --no-identify for the rare case of wanting raw SPEAKER_xx
labels back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uz3rnGtYHs6YcBNr6MkY1z
--speakers is no longer needed. The diarizer now over-segments on
purpose (clustering threshold 0.05), embeds the longest turn of each
resulting label, and merges the excess labels by picking the count with
the best silhouette score.

Measured on two-voice dialogues: both are resolved to exactly 2 speakers
with the right alternation, including the pair of near-identical
synthesised voices that a single fixed threshold could never separate.

- clustering, silhouette, estimate_speaker_count, merge_labels and
  relabel_turns are pure domain, plain Python, no numpy and no sherpa
- AutoSpeakerCountDiarizer implements the Diarizer port by composition,
  so it is unit-tested entirely with fakes
- cost stays low: one embedding per label (a handful), not per turn
  (628 on a one-hour file)
- --speakers still forces a count and then skips estimation entirely

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uz3rnGtYHs6YcBNr6MkY1z
On a real 36-minute recording the over-segmenting pass produced 290
labels, not the handful the design assumed. Embedding all of them and
running the cubic clustering once per candidate count took ~45 minutes.

The probe now samples the 24 longest turns regardless of how many labels
came out, estimates the count from those, and re-runs diarization with
it. Cost no longer depends on the label count.

Measured on that file: 45 min -> 7.3 min (4.9x real time). The phone
conversation, previously collapsed onto a single speaker, is now split
across two voices.

Still open: the estimate returns 2 speakers where the recording holds
3-4, so distinct people still share a label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uz3rnGtYHs6YcBNr6MkY1z
Sampling the globally longest turns concentrated on whichever speaker
talks most: on a file where one voice dominates, all 24 sampled turns
belonged to it and the count collapsed to 1. The probe now takes the
longest turn of each label first, then keeps the longest 24 of those.

Also adds pick_speaker_count, which can accept a finer split whose
silhouette stays within a tolerance of the best score. It defaults to
1.0, i.e. the plain maximum, because lowering it is not justified yet:
at 0.70 a real 3-speaker recording did report 3, but the third label
held 2 turns out of 609 while the two genuinely distinct people stayed
merged, and 2-speaker files drifted to 3-4. Right number, wrong reason.

Verified: every 2-speaker file now returns 2, including the one that
previously returned 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uz3rnGtYHs6YcBNr6MkY1z
@Pinous
Pinous merged commit 53299a1 into feat/models-command-and-ci Jul 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant