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 }