ec5167de3a
The package comment, the embedder log and the startup line all said enrolment was live and only recognition was blocked. Enroll embeds every sample before it stores anything, so with no model on the box it fails on the first sample with ErrDisabled and nothing is ever stored. List then returns an empty list forever and Forget has nothing to delete. The shipped state was three methods, all no-ops, announced as a working half. SpeakerConfig.Recognizes was written as the gate for this and never called, so a block with enabled and no model_path wired everything and skipped the one warning the operator needed. It is the gate now, and that config shape logs why it stayed off. Three smaller repairs. ErrDisabled had no case in speakerErr and reached the surface as an opaque core failure, when it means the same thing ErrUnknownMethod does. Forget read the row first and answered ErrNotFound on a second call, so the layer documented as the one that must always work reintroduced a failure for a voiceprint that was already gone. And a row with unparsable metadata listed as a plausible profile named after its own id with 0 samples, which is what a real minimal enrolment looks like; it is reported as damaged now. Found in review of #74.
163 lines
6.0 KiB
Go
163 lines
6.0 KiB
Go
// 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
|
|
}
|