7.6 KiB
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:<id> 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:
- 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 inAGENTS.md, put it beside the other models so the bind mount picks it up, and pointspeaker.model_pathat it. - An implementation of one function.
newSpeakerEmbedderincmd/mavend/speaker.gois the entire seam: give it an ONNX session that turnsaudio.Audiointo a[]float32andIdentifystarts working. Nothing else changes — not the store, not the protocol, not the authority table, not the handlers.internal/onnxalready loads the e5 embedder, so the runtime wiring exists to copy. - 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,
IdentifyreturnsErrUnknownnaming 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.gothe 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.
ListSpeakersRespcarries ids, names, dates and sample counts. The vector stays in core. - Off unless configured. No
speakerblock ⇒ the three methods answerErrUnknownMethod. 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
"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 producestap: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
/dashbehind a passkey, with a per-profile forget button next to each row — that button is the reasonlist_speakersexists. - 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.