7c7bd8ceeb
Maven can now be told who someone is. She cannot yet tell who is speaking, and this commit is careful to say so rather than pretend otherwise. What works: profiles are enrolled from several deliberately recorded samples, listed, and deleted. They live in the existing memory_vectors table under a "speaker:" id prefix, so there is no migration; what that needed was a wider interface than memory.Store, hence memory.Catalog with ByPrefix and Delete. Delete is the load-bearing half — a voiceprint someone asked to be rid of has to actually go, and a search-only store cannot do that. InMemoryStore.Insert became an upsert by id to match what the persistent store already did. What does not work, and why it is not faked: there is no speaker-embedding model on this box. Sixteen ggufs in /mnt/hdd1/llms, all text; no ECAPA, no x-vector, no titanet, no wespeaker, no .onnx anywhere under /mnt/hdd1. So newSpeakerEmbedder returns nil, internal/speaker falls back to speaker.Disabled, Identify answers ErrDisabled, and the daemon logs which half is off at startup. The plan's "simple MFCC + GMM" floor is refused in the package comment: MFCC cosine distance detects channel and loudness as much as voice, and a biometric that is confidently wrong writes false claims about named people into his memory. A bad floor is worse than none here. Refused as well, and the reason is in enroll.go's doc comment: the plan asked for unknown speakers to be enrolled on first interaction with a TTS "кто это?". There is no request shape in the protocol that could express that. Taking a biometric of 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 answers. Authority: enrolment is AuthStepUp, because it is a deliberate sit-down act that writes a biometric of a named person and never something done by voice mid-conversation. Deletion is one rung lower at AuthWrite, deliberately inverting the usual pattern — getting rid of a biometric must never be the harder half. Listing is AuthRead and never returns the vectors themselves. Off unless configured: no speaker block means the three methods answer ErrUnknownMethod, so a default box has no wire path that takes a voiceprint. make build and make test pass. Vikunja #255 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
213 lines
6.1 KiB
Go
213 lines
6.1 KiB
Go
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
|
|
}
|