936c6d71db
The WAV parser now walks chunk headers to find the data chunk instead of scanning for the four bytes "data". A LIST chunk between fmt and data is common, arecord and ffmpeg both write one, and its payload is free text that can spell the word. A byte scan took that text for a chunk header and read the comment as samples. WAVHeader named WAVFromPCM in its error, so a caller of WAVHeader read the wrong function. internal/capture calls it twice. PCMFromWAV returns PCM that aliases the buffer it was given. That is the right trade for a long recording and it was undocumented, so the doc comment now says so and names the two ways a caller gets it wrong. The TTS stub wrote 16000 three times. It reads the rate and the sample width off audio.PCM16kMono now, so the tone stays in tune with the shape the seam declares, and the sample write goes through binary.LittleEndian. Identify computed the clip length twice to report it once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
6.7 KiB
Go
220 lines
6.7 KiB
Go
package speaker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/audio"
|
|
"github.com/kami/maven/internal/memory"
|
|
)
|
|
|
|
// Recognizer holds the embedder and the enrolled profiles.
|
|
//
|
|
// The profiles live in the shared vector table under the "speaker:" id prefix,
|
|
// which is what the plan asked for and what keeps them inside the encrypted
|
|
// store rather than in a sidecar file. They are read through memory.Catalog
|
|
// (ByPrefix / Delete) rather than Search, because "who is enrolled" is not a
|
|
// similarity question and note recall must never rank a voiceprint.
|
|
type Recognizer struct {
|
|
emb Embedder
|
|
cat memory.Catalog
|
|
threshold float64
|
|
minSec float64
|
|
now func() time.Time
|
|
}
|
|
|
|
// Config — the recognizer's knobs, built from config.SpeakerConfig.
|
|
type Config struct {
|
|
// Threshold — cosine similarity a match must beat. 0 ⇒ DefaultThreshold.
|
|
Threshold float64
|
|
// MinSeconds — least speech an identification will look at. 0 ⇒
|
|
// DefaultMinSeconds.
|
|
MinSeconds float64
|
|
}
|
|
|
|
// New builds a Recognizer. emb nil ⇒ Disabled, which is this box's state and
|
|
// makes every Identify answer ErrDisabled while enrolment and listing still
|
|
// behave sensibly (they refuse for the same reason, with the same error).
|
|
func New(emb Embedder, cat memory.Catalog, cfg Config) (*Recognizer, error) {
|
|
if cat == nil {
|
|
return nil, fmt.Errorf("speaker: no profile store")
|
|
}
|
|
if emb == nil {
|
|
emb = Disabled{}
|
|
}
|
|
th := cfg.Threshold
|
|
if th <= 0 {
|
|
th = DefaultThreshold
|
|
}
|
|
min := cfg.MinSeconds
|
|
if min <= 0 {
|
|
min = DefaultMinSeconds
|
|
}
|
|
return &Recognizer{emb: emb, cat: cat, threshold: th, minSec: min, now: time.Now}, nil
|
|
}
|
|
|
|
// Enabled reports whether an embedding model is actually wired. Surfaces use it
|
|
// to say "recognition is off" once instead of failing every turn.
|
|
func (r *Recognizer) Enabled() bool {
|
|
_, disabled := r.emb.(Disabled)
|
|
return !disabled
|
|
}
|
|
|
|
// Threshold is the configured match floor, for a status line.
|
|
func (r *Recognizer) Threshold() float64 { return r.threshold }
|
|
|
|
// Identify names the voice in a. ErrUnknown when nothing is close enough, which
|
|
// is a normal answer and not a failure: a guest is a guest, and the caller
|
|
// carries on with no speaker attached rather than guessing.
|
|
//
|
|
// Identification never decides whether Maven listens. It annotates the turn.
|
|
func (r *Recognizer) Identify(ctx context.Context, a audio.Audio) (Match, error) {
|
|
if !a.Format.IsValid() {
|
|
return Match{}, fmt.Errorf("%w: %+v", ErrBadFormat, a.Format)
|
|
}
|
|
if sec := seconds(a); sec < r.minSec {
|
|
return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, sec, r.minSec)
|
|
}
|
|
vec, err := r.embed(ctx, a)
|
|
if err != nil {
|
|
return Match{}, err
|
|
}
|
|
profiles, err := r.List(ctx)
|
|
if err != nil {
|
|
return Match{}, err
|
|
}
|
|
if len(profiles) == 0 {
|
|
return Match{}, ErrNoProfiles
|
|
}
|
|
|
|
best := Match{Score: -2}
|
|
for _, p := range profiles {
|
|
if s := Similarity(vec, p.Vec); s > best.Score {
|
|
best = Match{Profile: p, Score: s}
|
|
}
|
|
}
|
|
if best.Score < r.threshold {
|
|
// The closest profile is reported in the error for a log line, because
|
|
// "не узнала, ближе всего Ками на 0.61" is what makes a threshold
|
|
// tunable. The caller must not use it as an identification.
|
|
return Match{}, fmt.Errorf("%w (closest %s at %.2f, need %.2f)",
|
|
ErrUnknown, best.Profile.ID, best.Score, r.threshold)
|
|
}
|
|
return best, nil
|
|
}
|
|
|
|
// List returns every enrolled profile, sorted by id so a listing is stable.
|
|
func (r *Recognizer) List(ctx context.Context) ([]Profile, error) {
|
|
recs, err := r.cat.ByPrefix(ctx, Prefix)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("speaker: list: %w", err)
|
|
}
|
|
out := make([]Profile, 0, len(recs))
|
|
for _, rec := range recs {
|
|
out = append(out, profileFromRecord(rec))
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out, nil
|
|
}
|
|
|
|
// Get returns one profile by id.
|
|
func (r *Recognizer) Get(ctx context.Context, id string) (Profile, error) {
|
|
id = NormalizeID(id)
|
|
if !ValidID(id) {
|
|
return Profile{}, fmt.Errorf("%w: %q", ErrBadID, id)
|
|
}
|
|
recs, err := r.cat.ByPrefix(ctx, Prefix+id)
|
|
if err != nil {
|
|
return Profile{}, fmt.Errorf("speaker: get: %w", err)
|
|
}
|
|
for _, rec := range recs {
|
|
if rec.ID == Prefix+id {
|
|
return profileFromRecord(rec), nil
|
|
}
|
|
}
|
|
return Profile{}, fmt.Errorf("%w: %q", ErrNotFound, id)
|
|
}
|
|
|
|
// Forget deletes a profile. This is the one operation that must always work:
|
|
// a voiceprint is data about a person, and "перестань узнавать её" has to
|
|
// actually remove it, not mark it inactive.
|
|
//
|
|
// Forgetting a profile that is not there is not an error, matching
|
|
// memory.Catalog.Delete. It used to read the row first and answer ErrNotFound,
|
|
// which meant the layer documented as the one that must always work was the
|
|
// layer reintroducing a failure: a surface retrying a forget after a partial
|
|
// failure got an error on the second try, for a voiceprint that was already
|
|
// gone. The caller asked for it to be gone and it is gone.
|
|
func (r *Recognizer) Forget(ctx context.Context, id string) error {
|
|
id = NormalizeID(id)
|
|
if !ValidID(id) {
|
|
return fmt.Errorf("%w: %q", ErrBadID, id)
|
|
}
|
|
if err := r.cat.Delete(ctx, Prefix+id); err != nil {
|
|
return fmt.Errorf("speaker: forget %q: %w", id, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// embed runs the model and normalises the result.
|
|
func (r *Recognizer) embed(ctx context.Context, a audio.Audio) ([]float32, error) {
|
|
raw, err := r.emb.Embed(ctx, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
vec, err := Normalize(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return vec, nil
|
|
}
|
|
|
|
// profileFromRecord reads a stored row back into a Profile. A row with
|
|
// unreadable metadata still yields a usable voiceprint — the vector is the part
|
|
// that matters, and losing a name should not lose the enrolment.
|
|
//
|
|
// It also says when the metadata did not read cleanly. Without that, a row
|
|
// whose samples count is "12x" and whose name is missing came back as a
|
|
// plausible profile called by its own id with 0 samples, which is exactly what
|
|
// a real minimal enrolment looks like. Damaged is the difference between "he
|
|
// enrolled badly" and "this row is broken".
|
|
func profileFromRecord(rec memory.Record) Profile {
|
|
p := Profile{
|
|
ID: trimPrefix(rec.ID),
|
|
Vec: rec.Vec,
|
|
Dim: len(rec.Vec),
|
|
Name: rec.Meta["name"],
|
|
}
|
|
if s := rec.Meta["samples"]; s != "" {
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n < 0 {
|
|
p.Damaged = true
|
|
} else {
|
|
p.Samples = n
|
|
}
|
|
}
|
|
if ts := rec.Meta["enrolled"]; ts != "" {
|
|
t, err := time.Parse(time.RFC3339, ts)
|
|
if err != nil {
|
|
p.Damaged = true
|
|
} else {
|
|
p.Enrolled = t
|
|
}
|
|
}
|
|
if p.Name == "" {
|
|
p.Name = p.ID
|
|
p.Damaged = true
|
|
}
|
|
return p
|
|
}
|
|
|
|
func trimPrefix(id string) string {
|
|
if len(id) > len(Prefix) && id[:len(Prefix)] == Prefix {
|
|
return id[len(Prefix):]
|
|
}
|
|
return id
|
|
}
|