package speaker import ( "context" "fmt" "sort" "strconv" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/memory" ) // Recognizer holds the embedder and the enrolled profiles. // // The profiles live in the shared vector table under the "speaker:" id prefix, // which is what the plan asked for and what keeps them inside the encrypted // store rather than in a sidecar file. They are read through memory.Catalog // (ByPrefix / Delete) rather than Search, because "who is enrolled" is not a // similarity question and note recall must never rank a voiceprint. type Recognizer struct { emb Embedder cat memory.Catalog threshold float64 minSec float64 now func() time.Time } // Config — the recognizer's knobs, built from config.SpeakerConfig. type Config struct { // Threshold — cosine similarity a match must beat. 0 ⇒ DefaultThreshold. Threshold float64 // MinSeconds — least speech an identification will look at. 0 ⇒ // DefaultMinSeconds. MinSeconds float64 } // New builds a Recognizer. emb nil ⇒ Disabled, which is this box's state and // makes every Identify answer ErrDisabled while enrolment and listing still // behave sensibly (they refuse for the same reason, with the same error). func New(emb Embedder, cat memory.Catalog, cfg Config) (*Recognizer, error) { if cat == nil { return nil, fmt.Errorf("speaker: no profile store") } if emb == nil { emb = Disabled{} } th := cfg.Threshold if th <= 0 { th = DefaultThreshold } min := cfg.MinSeconds if min <= 0 { min = DefaultMinSeconds } return &Recognizer{emb: emb, cat: cat, threshold: th, minSec: min, now: time.Now}, nil } // Enabled reports whether an embedding model is actually wired. Surfaces use it // to say "recognition is off" once instead of failing every turn. func (r *Recognizer) Enabled() bool { _, disabled := r.emb.(Disabled) return !disabled } // Threshold is the configured match floor, for a status line. func (r *Recognizer) Threshold() float64 { return r.threshold } // Identify names the voice in a. ErrUnknown when nothing is close enough, which // is a normal answer and not a failure: a guest is a guest, and the caller // carries on with no speaker attached rather than guessing. // // Identification never decides whether Maven listens. It annotates the turn. func (r *Recognizer) Identify(ctx context.Context, a audio.Audio) (Match, error) { if !a.Format.IsValid() { return Match{}, fmt.Errorf("%w: %+v", ErrBadFormat, a.Format) } if sec := seconds(a); sec < r.minSec { return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, sec, r.minSec) } vec, err := r.embed(ctx, a) if err != nil { return Match{}, err } profiles, err := r.List(ctx) if err != nil { return Match{}, err } if len(profiles) == 0 { return Match{}, ErrNoProfiles } best := Match{Score: -2} for _, p := range profiles { if s := Similarity(vec, p.Vec); s > best.Score { best = Match{Profile: p, Score: s} } } if best.Score < r.threshold { // The closest profile is reported in the error for a log line, because // "не узнала, ближе всего Ками на 0.61" is what makes a threshold // tunable. The caller must not use it as an identification. return Match{}, fmt.Errorf("%w (closest %s at %.2f, need %.2f)", ErrUnknown, best.Profile.ID, best.Score, r.threshold) } return best, nil } // List returns every enrolled profile, sorted by id so a listing is stable. func (r *Recognizer) List(ctx context.Context) ([]Profile, error) { recs, err := r.cat.ByPrefix(ctx, Prefix) if err != nil { return nil, fmt.Errorf("speaker: list: %w", err) } out := make([]Profile, 0, len(recs)) for _, rec := range recs { out = append(out, profileFromRecord(rec)) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } // Get returns one profile by id. func (r *Recognizer) Get(ctx context.Context, id string) (Profile, error) { id = NormalizeID(id) if !ValidID(id) { return Profile{}, fmt.Errorf("%w: %q", ErrBadID, id) } recs, err := r.cat.ByPrefix(ctx, Prefix+id) if err != nil { return Profile{}, fmt.Errorf("speaker: get: %w", err) } for _, rec := range recs { if rec.ID == Prefix+id { return profileFromRecord(rec), nil } } return Profile{}, fmt.Errorf("%w: %q", ErrNotFound, id) } // Forget deletes a profile. This is the one operation that must always work: // a voiceprint is data about a person, and "перестань узнавать её" has to // actually remove it, not mark it inactive. // // Forgetting a profile that is not there is not an error, matching // memory.Catalog.Delete. It used to read the row first and answer ErrNotFound, // which meant the layer documented as the one that must always work was the // layer reintroducing a failure: a surface retrying a forget after a partial // failure got an error on the second try, for a voiceprint that was already // gone. The caller asked for it to be gone and it is gone. func (r *Recognizer) Forget(ctx context.Context, id string) error { id = NormalizeID(id) if !ValidID(id) { return fmt.Errorf("%w: %q", ErrBadID, id) } if err := r.cat.Delete(ctx, Prefix+id); err != nil { return fmt.Errorf("speaker: forget %q: %w", id, err) } return nil } // embed runs the model and normalises the result. func (r *Recognizer) embed(ctx context.Context, a audio.Audio) ([]float32, error) { raw, err := r.emb.Embed(ctx, a) if err != nil { return nil, err } vec, err := Normalize(raw) if err != nil { return nil, err } return vec, nil } // profileFromRecord reads a stored row back into a Profile. A row with // unreadable metadata still yields a usable voiceprint — the vector is the part // that matters, and losing a name should not lose the enrolment. // // It also says when the metadata did not read cleanly. Without that, a row // whose samples count is "12x" and whose name is missing came back as a // plausible profile called by its own id with 0 samples, which is exactly what // a real minimal enrolment looks like. Damaged is the difference between "he // enrolled badly" and "this row is broken". func profileFromRecord(rec memory.Record) Profile { p := Profile{ ID: trimPrefix(rec.ID), Vec: rec.Vec, Dim: len(rec.Vec), Name: rec.Meta["name"], } if s := rec.Meta["samples"]; s != "" { n, err := strconv.Atoi(s) if err != nil || n < 0 { p.Damaged = true } else { p.Samples = n } } if ts := rec.Meta["enrolled"]; ts != "" { t, err := time.Parse(time.RFC3339, ts) if err != nil { p.Damaged = true } else { p.Enrolled = t } } if p.Name == "" { p.Name = p.ID p.Damaged = true } return p } func trimPrefix(id string) string { if len(id) > len(Prefix) && id[:len(Prefix)] == Prefix { return id[len(Prefix):] } return id }