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
110 lines
3.4 KiB
Go
110 lines
3.4 KiB
Go
package speaker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/audio"
|
|
)
|
|
|
|
// Enroll registers a voice under an id and a spoken name.
|
|
//
|
|
// Several separate samples are required (MinEnrollSamples, MinEnrollSeconds
|
|
// total): a profile built from one sentence encodes that sentence as much as the
|
|
// person, and the resulting threshold behaviour is unpredictable. The samples
|
|
// are embedded individually and the voiceprints averaged, then re-normalised.
|
|
//
|
|
// Re-enrolling an existing id REPLACES the profile. That is the intended way to
|
|
// improve a weak one, and it is why the store upserts by id.
|
|
//
|
|
// # The refused step
|
|
//
|
|
// The plan document's fourth bullet reads "unknown speakers are enrolled on
|
|
// first interaction (prompt: 'кто это?')". That is refused. Enrolling a voice is
|
|
// taking a biometric of a person; doing it automatically the first time someone
|
|
// walks past the microphone is doing it to guests, without them being part of
|
|
// the exchange, and a TTS question into a room is not consent from whoever
|
|
// happens to answer. Enrolment here is an explicit act: an id, a name, and
|
|
// samples deliberately recorded for the purpose. An unknown voice stays unknown,
|
|
// which the rest of the system is built to cope with.
|
|
func (r *Recognizer) Enroll(ctx context.Context, id, name string, samples []audio.Audio) (Profile, error) {
|
|
id = NormalizeID(id)
|
|
if !ValidID(id) {
|
|
return Profile{}, fmt.Errorf("%w: %q", ErrBadID, id)
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
name = id
|
|
}
|
|
if len(samples) < MinEnrollSamples {
|
|
return Profile{}, fmt.Errorf("%w: %d sample(s), need %d separate ones",
|
|
ErrTooShort, len(samples), MinEnrollSamples)
|
|
}
|
|
|
|
var total float64
|
|
for i, s := range samples {
|
|
if !s.Format.IsValid() {
|
|
return Profile{}, fmt.Errorf("%w: sample %d: %+v", ErrBadFormat, i+1, s.Format)
|
|
}
|
|
total += seconds(s)
|
|
}
|
|
if total < MinEnrollSeconds {
|
|
return Profile{}, fmt.Errorf("%w: %.1fs total, need %.1fs",
|
|
ErrTooShort, total, MinEnrollSeconds)
|
|
}
|
|
|
|
// Embed first, store second. A model failure halfway through must not leave
|
|
// a half-built profile that would then be matched against.
|
|
var (
|
|
sum []float32
|
|
dim int
|
|
)
|
|
for i, s := range samples {
|
|
vec, err := r.embed(ctx, s)
|
|
if err != nil {
|
|
return Profile{}, fmt.Errorf("speaker: enroll %q sample %d: %w", id, i+1, err)
|
|
}
|
|
if sum == nil {
|
|
sum = make([]float32, len(vec))
|
|
dim = len(vec)
|
|
} else if len(vec) != dim {
|
|
// One model, one width. A mixed-width average would be nonsense.
|
|
return Profile{}, fmt.Errorf("%w: sample %d is %d wide, expected %d",
|
|
ErrBadVector, i+1, len(vec), dim)
|
|
}
|
|
for j, f := range vec {
|
|
sum[j] += f
|
|
}
|
|
}
|
|
mean, err := Normalize(sum)
|
|
if err != nil {
|
|
// Samples that cancel each other out to zero are not one voice.
|
|
return Profile{}, fmt.Errorf("speaker: enroll %q: %w", id, err)
|
|
}
|
|
|
|
p := Profile{
|
|
ID: id,
|
|
Name: name,
|
|
Enrolled: r.now().UTC(),
|
|
Samples: len(samples),
|
|
Dim: dim,
|
|
Vec: mean,
|
|
}
|
|
meta := map[string]string{
|
|
"name": p.Name,
|
|
"samples": strconv.Itoa(p.Samples),
|
|
"enrolled": p.Enrolled.Format(time.RFC3339),
|
|
// kind marks the row for anything walking the vector table, so a future
|
|
// export or debug page can tell a voiceprint from a note embedding
|
|
// without parsing the id.
|
|
"kind": "speaker",
|
|
}
|
|
if err := r.cat.Insert(ctx, Prefix+id, mean, meta); err != nil {
|
|
return Profile{}, fmt.Errorf("speaker: enroll %q: %w", id, err)
|
|
}
|
|
return p, nil
|
|
}
|