# Plan: Speaker Recognition **Goal:** Maven can tell who is speaking on the voice channel, and tag what she writes with who said it. **Status (2026-08-01, V-255):** the enrolment half is shipped. The recognising half is **BLOCKED on a model download** — there is no speaker-embedding model on this box, and one was not invented to fill the gap. See "Blocked, and on what" below. ## What shipped | Piece | Where | State | |---|---|---| | `Recognizer` — identify, list, get, forget | `internal/speaker/recognizer.go` | done; `Identify` answers `ErrDisabled` until a model exists | | Enrolment — several samples, averaged, re-normalised | `internal/speaker/enroll.go` | done | | Profile shape, id validation, cosine similarity | `internal/speaker/speaker.go` | done | | Profile storage as `speaker:` vectors | `internal/memory` `Catalog` + `internal/store/memory.go` | done, no schema migration | | Config block, off by default | `internal/config` `SpeakerConfig` | done | | `enroll_speaker` / `list_speakers` / `forget_speaker` | `internal/ipc` | done, absent unless configured | | Authority rows | `internal/auth/policy.go` | done — enrol step-up, forget write, list read | | Daemon wiring + honest startup log | `cmd/mavend/speaker.go` | done | | Embedding backend | `newSpeakerEmbedder` | **BLOCKED** — returns nil, seam only | | Tagging voice writes with the speaker | `cmd/mavend/voice.go` | not wired; nothing to tag with yet | ## Blocked, and on what A voiceprint needs a speaker-embedding model. The box was searched: `/mnt/hdd1/llms` holds sixteen ggufs across seven families and every one of them is a text model. There is no ECAPA, no x-vector, no titanet, no wespeaker, and no `.onnx` under `/mnt/hdd1` at all. There are also no enrolment samples, because nothing has ever recorded any. To unblock, two things are needed and neither can be done from inside the repo: 1. **A model.** SpeechBrain ECAPA-TDNN exported to ONNX (`speechbrain/spkrec-ecapa-voxceleb`, 192-dim) is the usual choice and runs on CPU in well under a second for a few seconds of audio. Download it per the recipe in `AGENTS.md`, put it beside the other models so the bind mount picks it up, and point `speaker.model_path` at it. 2. **An implementation of one function.** `newSpeakerEmbedder` in `cmd/mavend/speaker.go` is the entire seam: give it an ONNX session that turns `audio.Audio` into a `[]float32` and `Identify` starts working. Nothing else changes — not the store, not the protocol, not the authority table, not the handlers. `internal/onnx` already loads the e5 embedder, so the runtime wiring exists to copy. 3. **Enrolment samples**, three or more per person, recorded deliberately. ### Why there is no fallback The original plan offered "a simple MFCC + GMM" as the floor. That is refused. MFCC cosine distance is a channel and loudness detector as much as a voice detector: it will happily match two different people who sit at the same distance from the same microphone, and it drifts when the room changes. A general classifier that is sometimes wrong is a nuisance; a **biometric** that is confidently wrong writes false claims about named people into his memory, and then those claims get recalled as fact. For this capability a bad floor is worse than none, so the shipped state is honest absence: `speaker.Disabled`, `ErrDisabled`, and a startup line saying so. ## The refusals, and why - **Unknown speakers are NOT enrolled on first interaction.** The plan's fourth "done when" bullet asked for exactly that, with a TTS "кто это?" prompt. It is refused in `enroll.go`'s doc comment and there is no request shape in the protocol that could express it. Enrolling a voice is taking a biometric of a person; doing it automatically to whoever walks past the microphone does it to guests who are not party to the exchange, and a synthesised question into a room is not consent from whoever happens to answer. Enrolment is an explicit act: an id, a name, and samples recorded for the purpose. - **One sample is not enough.** Three separate utterances and nine seconds minimum. A profile built from one sentence encodes that sentence as much as the person, and the threshold then behaves unpredictably against everything else. - **An unknown voice stays unknown.** Below threshold, `Identify` returns `ErrUnknown` naming the closest profile in the error text for diagnosis, never as an answer. Guessing who is in the room is how false memories about people get written. - **Deletion is one authority rung below enrolment.** Everywhere else in `policy.go` the destructive direction is gated at least as hard as the constructive one. Here that would be backwards: getting rid of a biometric must never be the harder half. - **The voiceprint never crosses the socket.** `ListSpeakersResp` carries ids, names, dates and sample counts. The vector stays in core. - **Off unless configured.** No `speaker` block ⇒ the three methods answer `ErrUnknownMethod`. There is no wire path on a default box that takes a voiceprint. ## Storage Profiles live in the existing `memory_vectors` table under the `speaker:` id prefix, as the plan intended, so there is no migration. What that needed was a wider interface than `memory.Store`: `memory.Catalog` adds `ByPrefix` and `Delete`. `Delete` is the load-bearing one — a voiceprint someone asked to be rid of has to actually go, and a search-only store cannot do that. `InMemoryStore.Insert` also became an upsert by id, matching what the persistent store already did, so re-enrolling replaces a profile instead of stacking a second one behind the first. Profiles do not collide with note or fact vectors: they are only ever read through `ByPrefix("speaker:")`, and a note search never returns one because the prefix is not in its query path. ## Config ```json "speaker": { "enabled": true, "model_path": "/opt/maven/models/spk/ecapa-voxceleb.onnx", "lib_path": "/opt/maven/lib", "threshold": 0.7, "min_seconds": 2.0 } ``` `Recognizes()` requires both `enabled` and a `model_path`, so a half-filled block reads as off rather than as a capability that fails every turn. That gate was written, documented, and then never called. It is called now, and the behaviour it describes changed with it. `enabled` with no `model_path` used to attach all three methods and log that enrolment was on. Today `newSpeakerWiring` returns nil, so the methods are absent, and the log says why: there is nothing to embed with, so enrol, list and forget would all be no-ops. That is the one config shape where the operator most needs to be told otherwise, and it was the shape that lied. ## Still open - The embedding backend (above). Everything below waits on it. - **Tagging voice writes.** `Profile.Source("tap:voice")` already produces `tap:voice:speaker:kami`, which is the shape step 6 asked for, but nothing calls it yet: with no recogniser there is no id to tag with. When the model lands, the hook is in the voice path before STT. - **Speaker as router/phraser context.** Same dependency. Note the persona constraint when it arrives: Maven addresses the owner informally and speaks to him, so "ok, " needs care for anyone who is not him. - **An enrolment surface.** The three IPC methods exist; no page drives them. Enrolment is step-up, so it belongs on `/dash` behind a passkey, with a per-profile forget button next to each row — that button is the reason `list_speakers` exists. - **A speaker column on the meeting recorder** (V-253). Attributing lines in a transcript is the obvious pairing, and it is the place where getting attribution wrong is most damaging, so it waits for a real model too.