feat(identity): swap Resemblyzer → WeSpeaker CAM++ ONNX speaker embedder - #12
Merged
Conversation
Resemblyzer's cosine scores separate too weakly on far-field Jabra audio (~0.36-0.80, mostly ~0.60, below the 0.75 accept threshold), so every turn failed closed to 'unknown' and the context boundary wiped the conversation — Larry treated a known speaker as a new voice every turn. Replace it with WeSpeaker CAM++ (voxceleb_CAM++_LM.onnx, 512-d, ~0.71% EER — ~6x lower error), run via onnxruntime with 80-dim Kaldi fbank from kaldi-native-fbank. Featurization is pinned to WeSpeaker's reference (hamming window, dither 0, CMN) and gated by a golden test that reproduces the torchaudio-reference embedding (cosine >= 0.9995). - Fail-closed default: CAM++'s uncalibrated SPEAKER_MATCH_THRESHOLD is 1.0 (matches nobody) until on-device genuine+impostor calibration — a mismatched threshold could false-confirm a guest, and with one speaker enrolled the margin gate is waived. Embedder-aware defaults (resemblyzer keeps 0.75). - Cap per-turn identify audio to 8s so a long turn can't overrun the tagger's 3s await while holding the encoder lock. - Idempotent, checksum-verified model fetch (model_fetch.py) + `larry fetch-models` CLI for provisioning; model cached under data/models (gitignored). - resemblyzer remains selectable via SPEAKER_EMBEDDER=resemblyzer. Claude-Session: https://claude.ai/code/session_01DWvqr8x1neAxrAfJ9NafMy
… in CI Address the codex diff-audit concerns on the CAM++ embedder swap: - .env.example / README / RESEARCH_larry_stack.md still described Resemblyzer + the 0.75 threshold. Document the CAM++ default, its fail-closed 1.0 threshold, the on-device calibration step, and that upgrading from a resemblyzer install requires re-enrollment (voiceprints are namespaced by embedder). - CI only ran `uv sync` + pytest, so the model-gated golden/discrimination tests silently skipped (the ~28 MB model is gitignored). Fetch it (cached) before pytest so the featurization golden gate runs in CI. Claude-Session: https://claude.ai/code/session_01DWvqr8x1neAxrAfJ9NafMy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Tonight's Pi logs showed Larry treating a known speaker as a new voice every turn. Resemblyzer's cosine scores separate too weakly on far-field Jabra audio (~0.36–0.80, mostly ~0.60, below the 0.75 accept threshold), so identity failed closed to
unknownalmost every turn and the context boundary wiped the conversation each time:The identity/wake code from #11 is working exactly as designed — the embedder underneath it was the ceiling.
What
Replace Resemblyzer (256-d, ~4.5% EER) with WeSpeaker CAM++ (
voxceleb_CAM++_LM.onnx, 512-d, ~0.71% EER — ~6× lower error), run via onnxruntime with 80-dim Kaldi fbank fromkaldi-native-fbank(the only new runtime dep; onnxruntime was already transitive via fastembed). Resemblyzer stays selectable viaSPEAKER_EMBEDDER=resemblyzer.wespeaker/bin/infer_onnx.py): Hamming window (kaldi-native-fbank defaults topovey), dither 0, per-utterance CMN, ×32768 scaling. A golden test asserts our pipeline reproduces the torchaudio-reference embedding within cosine ≥ 0.9995 — this catches a window/CMN/scaling regression that a loose separation test would miss.SPEAKER_MATCH_THRESHOLDis1.0(matches nobody) until on-device genuine+impostor calibration. A mismatched threshold could false-confirm a guest, and with one speaker enrolled the margin gate is waived — so the threshold is the sole guard. Defaults are embedder-aware (resemblyzer keeps 0.75).uv run larry fetch-modelsCLI for provisioning; the ~28 MB model is cached underdata/models/(gitignored). CI fetches+caches it so the golden gate runs there too.Testing
253 passed, ruff clean, pyright 0 errors. New: offline embedder tier (monkeypatched session), downloader unit tests (file:// + checksum-reject), and the model-gated golden + discrimination gates (same-speaker halves 0.81 vs speaker-vs-noise 0.06).Review
Cross-provider (Codex/Terra): plan review R1 REVISE → R2 APPROVED, plus a fresh-session audit — all findings folded (fail-closed default, CLI-reload gap, golden provenance). Diff review on
main...HEADAPPROVED with a diff-audit whose 2 concerns (stale config docs, golden gate skipping in CI) are folded into the second commit.After merge — required on the Pi
Not plug-and-play:
git pull→uv sync --extra pi→larry fetch-models→ restart. Then re-enroll each speaker through the Jabra (CLI enroll needs a restart to reload; in-conversation enroll reloads live) and calibrateSPEAKER_MATCH_THRESHOLDfrom theTurn matchlogs (a value between your genuine cosine and a guest's). Until then Larry recognizes nobody — the safe state.🤖 Generated with Claude Code