package speaker import ( "context" "fmt" "sort" "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 seconds(a) < r.minSec { return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, seconds(a), 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. 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.Get(ctx, id); err != nil { return err } 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. 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 != "" { p.Samples = atoi(s) } if ts := rec.Meta["enrolled"]; ts != "" { if t, err := time.Parse(time.RFC3339, ts); err == nil { p.Enrolled = t } } if p.Name == "" { p.Name = p.ID } return p } func trimPrefix(id string) string { if len(id) > len(Prefix) && id[:len(Prefix)] == Prefix { return id[len(Prefix):] } return id } // atoi is a tolerant small-integer parse: metadata that is not a number reads // as 0 rather than failing the whole listing. func atoi(s string) int { n := 0 for _, r := range s { if r < '0' || r > '9' { return 0 } n = n*10 + int(r-'0') } return n }