Skip to content

feat: recognise named speakers across recordings by voice (#128) - #129

Open
raphaelm22 wants to merge 3 commits into
ObscureAintSecure:masterfrom
raphaelm22:feat/voice-profiles
Open

raphaelm22 wants to merge 3 commits into
ObscureAintSecure:masterfrom
raphaelm22:feat/voice-profiles

Conversation

@raphaelm22

Copy link
Copy Markdown

Draft: I'm running this on real recurring meetings before marking it ready. Design feedback is welcome now.

Summary

Implements #128. Names given to diarized speakers become local voice profiles, and later recordings fill in known voices automatically.

  • No extra inference. pyannote 4 already returns one centroid per speaker (DiarizeOutput.speaker_embeddings, row i = labels()[i]). DiarizationWorker now keeps it instead of discarding it.
  • Per recording: with the feature on, the centroids are saved to speaker_embeddings.json.
  • Training: names the user types become contributions to a profile of that name, stored in ~/.talktrack/voice_profiles.json.
  • Matching: after diarization, unnamed speakers whose centroid has cosine similarity ≥ 0.6 to a profile get that name. Assignment is one-to-one, best match first, and never reuses a name already in the recording. Matches are recorded in voice_matches.json, and the Speakers panel shows "recognized by voice" next to them (similarity in the tooltip) until the name is edited.
  • Settings > Transcription: a Recognize returning speakers by voice toggle (off by default, since voice embeddings are biometric data) and a Manage Voice Profiles… dialog to list and delete profiles.

Design choices worth reviewing

  • Only typed names train. An auto-filled name the user leaves untouched never trains a profile, so a wrong match cannot reinforce itself. This also holds across re-transcriptions: last run's untouched auto-fills are dropped and matched afresh rather than promoted to "user names".
  • Contributions, not running averages. The store keeps one entry per (recording dir, speaker label), and a profile is the mean of its unit vectors. SpeakerNamePanel emits on every keystroke, so this keeps saves idempotent and makes renames and deletes exact.
  • No caching. The store re-reads its file on every call, so the Settings dialog's deletes are never overwritten by MainWindow.
  • Model id. Every vector carries the embedding model id (pyannote/speaker-diarization-community-1), and vectors from another model are never compared.
  • Failure handling. Matching runs in _on_diarization_finished before display, writing speaker_names.json so 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.
  • The logic lives in 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:

cosine similarity
same person (2 pairs) 0.88, 0.92
different people (10 pairs) 0.12–0.44, mean 0.29

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.py covers matching, training rules, the store (idempotency, rename, delete, two instances, corrupt file, model mismatch) and the per-recording files.
  • tests/test_voice_profiles_integration.py covers DiarizationWorker keeping centroids (including when reading them fails), the panel hint, the config default, and MainWindow._apply_voice_profiles / _save_speaker_names run against a stub self.
  • 49 new tests. Full suite: 457 passed. The CI import smoke modules pass. The panel, settings and dialog were also exercised with real widgets offscreen.

Not in this PR

Closes #128

🤖 Generated with Claude Code

…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
@raphaelm22

Copy link
Copy Markdown
Author

First cross-meeting result

I 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.

cosine similarity
returning speaker (1, the local user) 0.849
different people (24 pairs) -0.12 to 0.45, mean 0.19
  • The returning speaker was filled in automatically. voice_matches.json was written and the Speakers panel showed the "recognized by voice" hint.
  • The 4 new speakers stayed unnamed, so there were no false matches. The closest wrong pair scored 0.45, against the 0.6 threshold.
  • After I named the 4 new speakers, they became profiles of their own (9 in total), as designed.
  • Deleting a recording leaves its contributions in voice_profiles.json, since the vectors are copied into the store. Profiles therefore outlive the recordings they came from, and removing one is an explicit action in Manage Voice Profiles….

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.
@raphaelm22

Copy link
Copy Markdown
Author

I've now seen returning remote speakers, and the data led me to raise the threshold, in d8c0a8e.

Cross-meeting results. Four meetings over three days, Teams through the speakers' loopback plus my mic:

Pair Cosine vs saved profile
Same person, another meeting: 3 remote participants plus me 0.849, 0.857, 0.908, 0.924, 0.927, 0.950
Different people, highest seen 0.605, 0.627, 0.631
  • 0.631 and 0.605: these did not mislabel anyone, but only because the real owners of those profiles were in the same meeting. They claimed their names first, and the one-to-one assignment kept the impostors out.
  • 0.627: today this one did mislabel. A participant with no saved profile, who spoke for 36 s, was auto-filled with the name of someone who wasn't in the meeting.

So the first calibration was too optimistic about impostors: same-meeting different-person pairs had peaked at 0.44. MATCH_THRESHOLD goes from 0.6 to 0.75, the middle of the 0.63–0.85 gap. A missed match only leaves a name to type; a wrong one puts the wrong person in the minutes.

TestDefaultThreshold pins the observed scores: 0.605, 0.627, 0.631 and 0.7 must stay unnamed, and 0.849–0.95 must still match. Suite: 459 passed.

Still keeping this as a draft while it gets a few more meetings.

@raphaelm22

raphaelm22 commented Oct 22, 2026 •

Copy link
Copy Markdown
Author

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).

@raphaelm22
raphaelm22 marked this pull request as ready for review October 22, 2026 13:27
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.

Feature: remember named speakers across recordings (voice profiles from pyannote speaker embeddings)

1 participant