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
124 lines
3.9 KiB
Go
124 lines
3.9 KiB
Go
package ipc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/audio"
|
|
)
|
|
|
|
// The default that matters most for a biometric: on a core that was never
|
|
// configured with a speaker block, there is no wire path that takes a
|
|
// voiceprint, and none that lists the ones that might exist.
|
|
func TestSpeaker_OffUnlessConfigured(t *testing.T) {
|
|
_, _, cli, _ := newServerWithStore(t)
|
|
ctx := context.Background()
|
|
|
|
if _, err := cli.EnrollSpeaker(ctx, EnrollSpeakerReq{ID: "kami"}); !errors.Is(err, ErrUnknownMethod) {
|
|
t.Errorf("EnrollSpeaker error = %v, want ErrUnknownMethod", err)
|
|
}
|
|
if _, err := cli.ListSpeakers(ctx); !errors.Is(err, ErrUnknownMethod) {
|
|
t.Errorf("ListSpeakers error = %v, want ErrUnknownMethod", err)
|
|
}
|
|
if err := cli.ForgetSpeaker(ctx, "kami"); !errors.Is(err, ErrUnknownMethod) {
|
|
t.Errorf("ForgetSpeaker error = %v, want ErrUnknownMethod", err)
|
|
}
|
|
}
|
|
|
|
// Enrolment carries several samples across the boundary byte for byte — a
|
|
// profile averaged over the wrong bytes is a profile of nobody.
|
|
func TestSpeaker_EnrollCrossesTheWire(t *testing.T) {
|
|
_, srv, cli, _ := newServerWithStore(t)
|
|
ctx := context.Background()
|
|
|
|
enrolled := time.Now().UTC().Truncate(time.Second)
|
|
var gotID, gotName string
|
|
var gotSamples [][]byte
|
|
|
|
srv.EnrollSpeakerFn = func(_ context.Context, req EnrollSpeakerReq) (EnrollSpeakerResp, error) {
|
|
gotID, gotName = req.ID, req.Name
|
|
for _, s := range req.Samples {
|
|
gotSamples = append(gotSamples, s.Bytes)
|
|
}
|
|
return EnrollSpeakerResp{Speaker: Speaker{
|
|
ID: req.ID, Name: req.Name, Enrolled: enrolled, Samples: len(req.Samples),
|
|
}}, nil
|
|
}
|
|
|
|
mk := func(b byte, n int) audio.Audio {
|
|
buf := make([]byte, n)
|
|
for i := range buf {
|
|
buf[i] = b
|
|
}
|
|
return audio.Audio{Format: audio.PCM16kMono, Bytes: buf}
|
|
}
|
|
samples := []audio.Audio{mk(1, 64), mk(2, 96), mk(3, 128)}
|
|
|
|
resp, err := cli.EnrollSpeaker(ctx, EnrollSpeakerReq{ID: "kami", Name: "Ками", Samples: samples})
|
|
if err != nil {
|
|
t.Fatalf("EnrollSpeaker: %v", err)
|
|
}
|
|
if gotID != "kami" || gotName != "Ками" {
|
|
t.Errorf("server saw id=%q name=%q", gotID, gotName)
|
|
}
|
|
if len(gotSamples) != 3 {
|
|
t.Fatalf("server saw %d samples, want 3", len(gotSamples))
|
|
}
|
|
for i, want := range samples {
|
|
if string(gotSamples[i]) != string(want.Bytes) {
|
|
t.Errorf("sample %d altered in transit", i)
|
|
}
|
|
}
|
|
if resp.Speaker.Samples != 3 || !resp.Speaker.Enrolled.Equal(enrolled) {
|
|
t.Errorf("profile came back wrong: %+v", resp.Speaker)
|
|
}
|
|
}
|
|
|
|
// A listing says who is enrolled and whether recognition actually works. On
|
|
// this box the honest answer is "enrolled, not recognising", and the response
|
|
// has to be able to say so — otherwise a surface implies Maven knows who is
|
|
// talking when nothing on disk can tell.
|
|
func TestSpeaker_ListReportsDisabledRecognition(t *testing.T) {
|
|
_, srv, cli, _ := newServerWithStore(t)
|
|
ctx := context.Background()
|
|
|
|
srv.ListSpeakersFn = func(context.Context) (ListSpeakersResp, error) {
|
|
return ListSpeakersResp{
|
|
Speakers: []Speaker{{ID: "kami", Name: "Ками", Samples: 3}},
|
|
Enabled: false,
|
|
}, nil
|
|
}
|
|
|
|
resp, err := cli.ListSpeakers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListSpeakers: %v", err)
|
|
}
|
|
if len(resp.Speakers) != 1 || resp.Speakers[0].ID != "kami" {
|
|
t.Fatalf("speakers = %+v", resp.Speakers)
|
|
}
|
|
if resp.Enabled {
|
|
t.Error("Enabled = true; the seam must be able to report that nothing recognises")
|
|
}
|
|
}
|
|
|
|
// Deletion reaches core with the id intact and reports success. This is the
|
|
// request that must always work.
|
|
func TestSpeaker_ForgetReachesCore(t *testing.T) {
|
|
_, srv, cli, _ := newServerWithStore(t)
|
|
ctx := context.Background()
|
|
|
|
var forgot string
|
|
srv.ForgetSpeakerFn = func(_ context.Context, req ForgetSpeakerReq) error {
|
|
forgot = req.ID
|
|
return nil
|
|
}
|
|
if err := cli.ForgetSpeaker(ctx, "гость"); err != nil {
|
|
t.Fatalf("ForgetSpeaker: %v", err)
|
|
}
|
|
if forgot != "гость" {
|
|
t.Errorf("core forgot %q, want %q", forgot, "гость")
|
|
}
|
|
}
|