package speaker import ( "context" "errors" "math" "strings" "testing" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/memory" ) // fakeEmbedder returns a fixed vector per "voice", so a test can enrol one // person and present another without a model. Wobble adds a small perturbation // so repeated samples of one voice are close but not identical, which is what a // real embedder produces. type fakeEmbedder struct { vec []float32 err error calls int wobble float32 } func (f *fakeEmbedder) Embed(_ context.Context, _ audio.Audio) ([]float32, error) { f.calls++ if f.err != nil { return nil, f.err } out := append([]float32(nil), f.vec...) if f.wobble != 0 && len(out) > 1 { out[0] += f.wobble * float32(f.calls) out[1] -= f.wobble * float32(f.calls) } return out, nil } func (f *fakeEmbedder) Dim() int { return len(f.vec) } // speech builds n seconds of the canonical audio shape. func speech(sec float64) audio.Audio { return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, int(sec*16000)*2)} } func newRec(t *testing.T, emb Embedder) (*Recognizer, memory.Catalog) { t.Helper() cat := memory.NewInMemoryStore() r, err := New(emb, cat, Config{}) if err != nil { t.Fatal(err) } return r, cat } func enrolSamples(n int, sec float64) []audio.Audio { out := make([]audio.Audio, n) for i := range out { out[i] = speech(sec) } return out } // The state of this box: no model on disk. Every identification refuses rather // than guessing, and it says why. func TestDisabledRefusesEverything(t *testing.T) { r, _ := newRec(t, nil) if r.Enabled() { t.Error("a recognizer with no model reports itself enabled") } if _, err := r.Identify(context.Background(), speech(5)); !errors.Is(err, ErrDisabled) { t.Errorf("Identify = %v, want ErrDisabled", err) } if _, err := r.Enroll(context.Background(), "kami", "Ками", enrolSamples(3, 4)); !errors.Is(err, ErrDisabled) { t.Errorf("Enroll = %v, want ErrDisabled", err) } // Listing still works: knowing that nobody is enrolled needs no model. got, err := r.List(context.Background()) if err != nil || len(got) != 0 { t.Errorf("List = %v, %v", got, err) } } func TestNewRequiresAProfileStore(t *testing.T) { if _, err := New(nil, nil, Config{}); err == nil { t.Error("built a recognizer with nowhere to keep profiles") } } func TestEnrollThenIdentify(t *testing.T) { emb := &fakeEmbedder{vec: []float32{1, 0, 0, 0}, wobble: 0.01} r, _ := newRec(t, emb) ctx := context.Background() p, err := r.Enroll(ctx, "Kami ", "Ками", enrolSamples(3, 4)) if err != nil { t.Fatalf("enroll: %v", err) } if p.ID != "kami" { t.Errorf("id = %q, want the normalised %q", p.ID, "kami") } if p.Name != "Ками" || p.Samples != 3 || p.Dim != 4 { t.Errorf("profile = %+v", p) } m, err := r.Identify(ctx, speech(5)) if err != nil { t.Fatalf("identify: %v", err) } if m.Profile.ID != "kami" || m.Profile.Name != "Ками" { t.Errorf("match = %+v", m) } if m.Score < r.Threshold() { t.Errorf("score %.3f is below the threshold it supposedly passed", m.Score) } } // The error direction that matters. Naming the wrong person writes a false // memory about them, so a voice that is not close enough gets no name at all. func TestUnfamiliarVoiceIsNotGuessed(t *testing.T) { emb := &fakeEmbedder{vec: []float32{1, 0, 0, 0}} r, _ := newRec(t, emb) ctx := context.Background() if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil { t.Fatal(err) } // A different voice: orthogonal voiceprint, similarity 0. emb.vec = []float32{0, 1, 0, 0} m, err := r.Identify(ctx, speech(5)) if !errors.Is(err, ErrUnknown) { t.Fatalf("Identify = %v, want ErrUnknown", err) } if m.Profile.ID != "" { t.Errorf("a refused identification still handed back %q", m.Profile.ID) } // The log line needs the near miss to make the threshold tunable. if !contains(err.Error(), "kami") { t.Errorf("error does not name the closest profile: %v", err) } } // Just under the threshold is still unknown. A boundary this important gets its // own test rather than being implied. func TestThresholdIsAFloorNotASuggestion(t *testing.T) { cat := memory.NewInMemoryStore() emb := &fakeEmbedder{vec: []float32{1, 0}} r, err := New(emb, cat, Config{Threshold: 0.9}) if err != nil { t.Fatal(err) } ctx := context.Background() if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil { t.Fatal(err) } // cos ≈ 0.866, comfortably similar and still not similar enough. emb.vec = []float32{0.866, 0.5} if _, err := r.Identify(ctx, speech(5)); !errors.Is(err, ErrUnknown) { t.Fatalf("0.866 against a 0.9 threshold = %v, want ErrUnknown", err) } } func TestShortAudioIsRefusedBeforeTheModelRuns(t *testing.T) { emb := &fakeEmbedder{vec: []float32{1, 0}} r, _ := newRec(t, emb) if _, err := r.Identify(context.Background(), speech(0.5)); !errors.Is(err, ErrTooShort) { t.Fatalf("got %v, want ErrTooShort", err) } if emb.calls != 0 { t.Error("a half-second of audio was sent to the model anyway") } } func TestWrongAudioFormatIsRefused(t *testing.T) { r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}}) bad := audio.Audio{ Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}, Bytes: make([]byte, 44100*4*5), } if _, err := r.Identify(context.Background(), bad); !errors.Is(err, ErrBadFormat) { t.Fatalf("got %v, want ErrBadFormat", err) } } func TestIdentifyWithNobodyEnrolled(t *testing.T) { r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}}) if _, err := r.Identify(context.Background(), speech(5)); !errors.Is(err, ErrNoProfiles) { t.Fatalf("got %v, want ErrNoProfiles", err) } } // Enrolment is an explicit act with real samples behind it, not a byproduct of // someone speaking once. func TestEnrollmentRequiresSeveralRealSamples(t *testing.T) { r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}}) ctx := context.Background() cases := []struct { name string samples []audio.Audio }{ {"one long sample", enrolSamples(1, 30)}, {"two samples", enrolSamples(2, 10)}, {"three samples but seconds of audio", enrolSamples(3, 1)}, {"none at all", nil}, } for _, c := range cases { if _, err := r.Enroll(ctx, "kami", "Ками", c.samples); !errors.Is(err, ErrTooShort) { t.Errorf("%s: %v, want ErrTooShort", c.name, err) } } } func TestEnrollRejectsBadIDs(t *testing.T) { r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}}) for _, id := range []string{"", " ", "../etc/passwd", "speaker:kami", "имя", "a/b", "x y"} { if _, err := r.Enroll(context.Background(), id, "n", enrolSamples(3, 4)); !errors.Is(err, ErrBadID) { t.Errorf("id %q accepted or wrong error: %v", id, err) } } } // Re-enrolling replaces the voiceprint. Leaving the old one searchable would // mean a person's rejected profile keeps matching them. func TestReEnrollReplaces(t *testing.T) { emb := &fakeEmbedder{vec: []float32{1, 0, 0}} r, _ := newRec(t, emb) ctx := context.Background() if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil { t.Fatal(err) } emb.vec = []float32{0, 1, 0} if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(4, 4)); err != nil { t.Fatal(err) } list, err := r.List(ctx) if err != nil { t.Fatal(err) } if len(list) != 1 { t.Fatalf("%d profiles after re-enrolling one person", len(list)) } if list[0].Samples != 4 { t.Errorf("sample count = %d, want the new 4", list[0].Samples) } // The new voiceprint is the one that matches. if m, err := r.Identify(ctx, speech(5)); err != nil || m.Score < 0.99 { t.Errorf("identify after re-enrol: %v (score %.3f)", err, m.Score) } } // "Перестань узнавать её" has to actually delete the biometric. func TestForgetRemovesTheVoiceprint(t *testing.T) { emb := &fakeEmbedder{vec: []float32{1, 0}} r, cat := newRec(t, emb) ctx := context.Background() if _, err := r.Enroll(ctx, "guest", "Гостья", enrolSamples(3, 4)); err != nil { t.Fatal(err) } if err := r.Forget(ctx, "Guest "); err != nil { t.Fatalf("forget: %v", err) } recs, err := cat.ByPrefix(ctx, Prefix) if err != nil { t.Fatal(err) } if len(recs) != 0 { t.Errorf("%d row(s) survived Forget", len(recs)) } if _, err := r.Get(ctx, "guest"); !errors.Is(err, ErrNotFound) { t.Errorf("Get after Forget = %v, want ErrNotFound", err) } if err := r.Forget(ctx, "guest"); !errors.Is(err, ErrNotFound) { t.Errorf("second Forget = %v, want ErrNotFound", err) } } // Voiceprints share the vector table with note and fact embeddings, so the // prefix has to actually partition it. func TestProfilesDoNotCollideWithNoteVectors(t *testing.T) { emb := &fakeEmbedder{vec: []float32{1, 0}} r, cat := newRec(t, emb) ctx := context.Background() if err := cat.Insert(ctx, "note:1", []float32{1, 0}, map[string]string{"text": "заметка"}); err != nil { t.Fatal(err) } if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil { t.Fatal(err) } list, err := r.List(ctx) if err != nil { t.Fatal(err) } if len(list) != 1 || list[0].ID != "kami" { t.Errorf("listing picked up a non-speaker row: %+v", list) } // And an identical note vector is never returned as a match. m, err := r.Identify(ctx, speech(5)) if err != nil { t.Fatal(err) } if m.Profile.ID != "kami" { t.Errorf("matched %q", m.Profile.ID) } } func TestEmbedderFailurePropagates(t *testing.T) { emb := &fakeEmbedder{vec: []float32{1, 0}, err: errors.New("onnx fell over")} r, _ := newRec(t, emb) if _, err := r.Identify(context.Background(), speech(5)); err == nil { t.Error("a model failure was reported as a successful identification") } if _, err := r.Enroll(context.Background(), "kami", "К", enrolSamples(3, 4)); err == nil { t.Error("a model failure produced a profile") } } // A zero vector scores 0 against everything, which reads as "no match" for the // wrong reason and would hide a broken model. func TestUnusableVectorsAreRefused(t *testing.T) { r, _ := newRec(t, &fakeEmbedder{vec: []float32{0, 0, 0}}) if _, err := r.Enroll(context.Background(), "kami", "К", enrolSamples(3, 4)); !errors.Is(err, ErrBadVector) { t.Errorf("zero vector: %v, want ErrBadVector", err) } if _, err := Normalize(nil); !errors.Is(err, ErrBadVector) { t.Errorf("empty: %v", err) } if _, err := Normalize([]float32{float32(nan())}); !errors.Is(err, ErrBadVector) { t.Errorf("NaN: %v", err) } } func TestNormalizeProducesAUnitVector(t *testing.T) { v, err := Normalize([]float32{3, 4}) if err != nil { t.Fatal(err) } if got := Similarity(v, v); got < 0.999 || got > 1.001 { t.Errorf("self-similarity = %f, want 1", got) } } // A profile enrolled with another model must not accidentally match. func TestDifferentWidthsScoreZero(t *testing.T) { if got := Similarity([]float32{1, 0}, []float32{1, 0, 0}); got != 0 { t.Errorf("mismatched widths scored %f", got) } } // Attribution belongs in the source, so it can be corrected without rewriting // what was said. func TestProfileSource(t *testing.T) { p := Profile{ID: "kami"} if got := p.Source("tap:voice"); got != "tap:voice:speaker:kami" { t.Errorf("source = %q", got) } var anon Profile if got := anon.Source("tap:voice"); got != "tap:voice" { t.Errorf("unattributed source = %q, want the base unchanged", got) } } func TestValidID(t *testing.T) { for _, ok := range []string{"kami", "guest-2", "a_b", "x"} { if !ValidID(ok) { t.Errorf("%q rejected", ok) } } for _, bad := range []string{"", "Kami", "имя", "a b", "a/b", "a:b", "..", strings.Repeat("a", 65)} { if ValidID(bad) { t.Errorf("%q accepted", bad) } } } func TestProfileMetadataSurvivesARoundTrip(t *testing.T) { emb := &fakeEmbedder{vec: []float32{1, 0}} r, _ := newRec(t, emb) r.now = func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) } ctx := context.Background() if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil { t.Fatal(err) } got, err := r.Get(ctx, "kami") if err != nil { t.Fatal(err) } if got.Name != "Ками" || got.Samples != 3 { t.Errorf("profile = %+v", got) } if !got.Enrolled.Equal(time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)) { t.Errorf("enrolled = %v", got.Enrolled) } } func contains(s, sub string) bool { return strings.Contains(s, sub) } func nan() float64 { return math.NaN() }