Repository navigation
feat: recognise named speakers across recordings by voice (#128) - #129
raphaelm22 wants to merge 3 commits into
Conversation
…tSecure#128) Speaker names were per-recording and pyannote labels are reassigned on every run, so recurring participants had to be renamed by hand on every recording. pyannote 4 already returns one centroid per speaker (DiarizeOutput. speaker_embeddings) and DiarizationWorker discarded it. Keep it, and when voice profiles are enabled: - save each recording's centroids (speaker_embeddings.json) - names the user types become contributions to a profile of that name (~/.talktrack/voice_profiles.json) - after diarizing a new recording, unnamed speakers whose centroid is close enough to a profile (cosine >= 0.6, one-to-one, best match first) get that name, recorded in voice_matches.json and flagged "recognized by voice" in the Speakers panel until edited Only names the user typed train profiles: an untouched auto-fill never does, so a wrong match cannot reinforce itself, including across re-transcriptions. The store keeps per-(recording, speaker) contributions instead of running averages, which keeps per-keystroke saves idempotent and makes renames and deletions exact. Vectors carry the embedding model id and are never mixed across models. Off by default (voice embeddings are biometric data). Settings > Transcription gets the toggle and a dialog to list and delete profiles. Matching runs before the transcript is displayed and never raises, so it cannot cost the user a finished transcript. Threshold calibrated on a real 5-person pt-BR Teams call diarized as two independent halves: same-person pairs 0.88/0.92, different people <= 0.44. Closes ObscureAintSecure#128
First cross-meeting resultI ran the feature in the app on a second real pt-BR meeting the next day: a 16 min call with 5 speakers, CPU, community-1. The profiles came from the 7.5 min, 5-speaker call used for calibration, where all 5 speakers were named.
Caveat: the returning speaker was the local user, on the same mic both days, which is the easy case. No remote participant has returned yet. I'm keeping this a draft until I see a returning remote speaker. |
Across real meetings on three days, the same person scored 0.849-0.950 against their profile and different people up to 0.631. At 0.6, a 0.627 impostor auto-filled the name of someone who was not in the meeting. 0.75 sits in the middle of the gap; TestDefaultThreshold pins the observed scores.
|
I've now seen returning remote speakers, and the data led me to raise the threshold, in Cross-meeting results. Four meetings over three days, Teams through the speakers' loopback plus my mic:
So the first calibration was too optimistic about impostors: same-meeting different-person pairs had peaked at 0.44.
Still keeping this as a draft while it gets a few more meetings. |
|
Ready for review. Used daily since 15/10 on a Core Ultra 7 265U (Intel GPU selected, live transcription on): 7 real Teams meetings, 11 to 115 min, pt-BR, 4 to 6 speakers. No fallbacks or errors in the log. Voice matching after the threshold fix (d8c0a8e, 0.75): 18 auto-filled names across the six meetings since, scores 0.772-0.945, all kept by me as correct; no wrong match. Before the fix, one 0.627 impostor had filled a name (the reason for d8c0a8e). |
Summary
Implements #128. Names given to diarized speakers become local voice profiles, and later recordings fill in known voices automatically.
DiarizeOutput.speaker_embeddings, rowi=labels()[i]).DiarizationWorkernow keeps it instead of discarding it.speaker_embeddings.json.~/.talktrack/voice_profiles.json.voice_matches.json, and the Speakers panel shows "recognized by voice" next to them (similarity in the tooltip) until the name is edited.Design choices worth reviewing
SpeakerNamePanelemits on every keystroke, so this keeps saves idempotent and makes renames and deletes exact.pyannote/speaker-diarization-community-1), and vectors from another model are never compared._on_diarization_finishedbefore display, writingspeaker_names.jsonso both the displayed and the not-displayed (Transcription/diarization results written to wrong recording after session switch #19) paths pick it up. It never raises: a matching failure cannot cost the user a finished transcript.app/transcription/voice_profiles.py(numpy + JSON only, no Qt or torch) so it is unit-testable. MainWindow changes are thin.Threshold calibration
I calibrated on a real 5-person pt-BR Teams call (CPU, community-1), diarizing its two halves independently as if they were two meetings and using the names given to the full recording as ground truth:
End to end through the module, with half A named and half B diarized, the result was 2/2 returning speakers filled in correctly, 2/2 new speakers left unnamed, and no false matches. This is still a single meeting. Cross-meeting scores (another day, mic or codec) will run lower, which is why the threshold sits well below the genuine pairs. The real-meeting runs are what this draft is waiting on.
Tests
tests/test_voice_profiles.pycovers matching, training rules, the store (idempotency, rename, delete, two instances, corrupt file, model mismatch) and the per-recording files.tests/test_voice_profiles_integration.pycoversDiarizationWorkerkeeping centroids (including when reading them fails), the panel hint, the config default, andMainWindow._apply_voice_profiles/_save_speaker_namesrun against a stub self.Not in this PR
Closes #128
🤖 Generated with Claude Code