Files
kami 7c7bd8ceeb Ship voice enrolment, and report recognition as blocked (#255)
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
2026-08-01 05:23:03 +04:00

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
}