Skip to content

feat(identity): swap Resemblyzer → WeSpeaker CAM++ ONNX speaker embedder - #12

Merged
jasonm4130 merged 2 commits into
mainfrom
feat/wespeaker-campplus-embedder
Jul 19, 2026
Merged

feat(identity): swap Resemblyzer → WeSpeaker CAM++ ONNX speaker embedder#12
jasonm4130 merged 2 commits into
mainfrom
feat/wespeaker-campplus-embedder

Conversation

@jasonm4130

Copy link
Copy Markdown
Owner

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 unknown almost every turn and the context boundary wiped the conversation each time:

Turn match: best='jason' score=0.612 ... -> 'unknown'
Turn match: best='jason' score=0.603 ... -> 'unknown'
Turn match: best='jason' score=0.798 ... -> 'jason'   # 1 of 5 cleared; didn't confirm

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 from kaldi-native-fbank (the only new runtime dep; onnxruntime was already transitive via fastembed). Resemblyzer stays selectable via SPEAKER_EMBEDDER=resemblyzer.

  • Featurization is pinned to WeSpeaker's reference (wespeaker/bin/infer_onnx.py): Hamming window (kaldi-native-fbank defaults to povey), 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.
  • 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 — so the threshold is the sole guard. Defaults are embedder-aware (resemblyzer keeps 0.75).
  • Bounded latency: per-turn identify audio is capped to 8 s so a long turn can't overrun the tagger's 3 s await while holding the encoder lock.
  • Model fetch: idempotent, checksum-verified downloader + uv run larry fetch-models CLI for provisioning; the ~28 MB model is cached under data/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...HEAD APPROVED 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 pulluv sync --extra pilarry fetch-models → restart. Then re-enroll each speaker through the Jabra (CLI enroll needs a restart to reload; in-conversation enroll reloads live) and calibrate SPEAKER_MATCH_THRESHOLD from the Turn match logs (a value between your genuine cosine and a guest's). Until then Larry recognizes nobody — the safe state.

🤖 Generated with Claude Code

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
@jasonm4130
jasonm4130 merged commit a6466b5 into main Jul 19, 2026
1 check passed
@jasonm4130
jasonm4130 deleted the feat/wespeaker-campplus-embedder branch July 19, 2026 21:35
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