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
398 lines
12 KiB
Go
398 lines
12 KiB
Go
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() }
|