// mavend/speaker.go — core's half of voice identification (Vikunja #255, // docs/plans/10-speaker-recognition.md). // // # What is actually wired here, and what is not // // Nothing is, on this box. There is no speaker-embedding model on disk — no // ECAPA, no x-vector, no titanet, no wespeaker, nothing in /mnt/hdd1/llms but // text ggufs. Until one is downloaded, newSpeakerEmbedder returns nil. // // Without an embedder the capability has no runnable half. This comment used to // say enrolment was real and only recognition was blocked, and the startup log // said the same. Both were wrong: Recognizer.Enroll embeds every sample before // it stores anything, so with no model it fails on the first sample and nothing // is ever stored, which leaves List empty forever and Forget with nothing to // delete. So the gate is cfg.Speaker.Recognizes() — enabled AND a model path — // and a box without one gets no speaker methods, not three no-ops. // // This is deliberately not papered over with a hand-rolled MFCC floor. A // biometric that is confidently wrong writes false claims about named people // into his memory, and that is worse than a capability that is honestly absent. // // # Off unless configured // // No speaker block, or one without enabled, ⇒ the three methods do not exist and // answer ErrUnknownMethod. On an unconfigured box there is no wire path that // takes a voiceprint at all. // // # The refused design step // // The plan asks for unknown speakers to be enrolled on first interaction. That // is refused in internal/speaker/enroll.go and there is no handler for it here: // no request shape in the protocol enrols whoever just spoke. Taking a biometric // of a guest who walked past the microphone is not something this daemon does. package main import ( "context" "errors" "log" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/speaker" "github.com/kami/maven/internal/store" ) // speakerWiring holds the recognizer behind the three IPC handlers. type speakerWiring struct { rec *speaker.Recognizer } // newSpeakerEmbedder loads the speaker-embedding model named by the config. // // It always returns nil today. The seam exists so that wiring a real model is a // change to this one function and nothing else: give it a loader, and Identify // starts working with no change to the store, the protocol, the auth table or // the handlers. See the plan document for what to download. func newSpeakerEmbedder(cfg *config.SpeakerConfig) speaker.Embedder { _ = cfg return nil } // newSpeakerWiring builds the recognizer, or nil when the capability is off. func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring { if cfg == nil || cfg.Speaker == nil { return nil } if !cfg.Speaker.Recognizes() { // Recognizes() was written as the gate and documented as one, and then // never called. "enabled": true with no model_path used to wire all // three methods and log "enrolment on", which is the one config shape // where the operator most needs to be told otherwise. if cfg.Speaker.Enabled { log.Print("speaker: enabled but no model_path, so there is nothing to embed with; " + "enrol, list and forget would all be no-ops, staying off " + "(see docs/plans/10-speaker-recognition.md)") } return nil } if st == nil { log.Print("speaker: enabled but there is no store to keep profiles in; staying off") return nil } rec, err := speaker.New(newSpeakerEmbedder(cfg.Speaker), st.VectorMemory(), speaker.Config{ Threshold: cfg.Speaker.Threshold, MinSeconds: cfg.Speaker.MinSeconds, }) if err != nil { log.Printf("speaker: %v; staying off", err) return nil } if rec.Enabled() { log.Printf("speaker: recognition on, threshold %.2f", rec.Threshold()) } else { log.Printf("speaker: model_path %q is configured but no embedding backend is built yet, "+ "so enrol, list and forget are all no-ops (Vikunja #255)", cfg.Speaker.ModelPath) } return &speakerWiring{rec: rec} } func (w *speakerWiring) enroll(ctx context.Context, req ipc.EnrollSpeakerReq) (ipc.EnrollSpeakerResp, error) { p, err := w.rec.Enroll(ctx, req.ID, req.Name, req.Samples) if err != nil { return ipc.EnrollSpeakerResp{}, speakerErr(err) } return ipc.EnrollSpeakerResp{Speaker: toWireSpeaker(p)}, nil } func (w *speakerWiring) list(ctx context.Context) (ipc.ListSpeakersResp, error) { ps, err := w.rec.List(ctx) if err != nil { return ipc.ListSpeakersResp{}, speakerErr(err) } out := make([]ipc.Speaker, 0, len(ps)) for _, p := range ps { out = append(out, toWireSpeaker(p)) } return ipc.ListSpeakersResp{Speakers: out, Enabled: w.rec.Enabled()}, nil } func (w *speakerWiring) forget(ctx context.Context, req ipc.ForgetSpeakerReq) error { return speakerErr(w.rec.Forget(ctx, req.ID)) } // toWireSpeaker drops the voiceprint. A listing says who is enrolled; it does // not hand the biometric back out over the socket. func toWireSpeaker(p speaker.Profile) ipc.Speaker { return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples, Damaged: p.Damaged} } // speakerErr maps the package sentinels onto the wire vocabulary so a surface // can tell "you asked wrong" from "core broke". func speakerErr(err error) error { switch { case err == nil: return nil case errors.Is(err, speaker.ErrDisabled): // Not a core failure. The capability is present on the wire but has no // embedding model behind it, which is the same thing an unconfigured // method says, so say it the same way. return ipc.ErrUnknownMethod case errors.Is(err, speaker.ErrNotFound): return ipc.ErrNoFact case errors.Is(err, speaker.ErrBadID), errors.Is(err, speaker.ErrBadFormat), errors.Is(err, speaker.ErrTooShort): return errors.Join(ipc.ErrBadParams, err) default: return err } } // wireSpeaker attaches the three handlers when the capability is configured. func wireSpeaker(srv *ipc.Server, st *store.Store, cfg *config.Config) { w := newSpeakerWiring(st, cfg) if w == nil { return } srv.EnrollSpeakerFn = w.enroll srv.ListSpeakersFn = w.list srv.ForgetSpeakerFn = w.forget }