ec5167de3a
The package comment, the embedder log and the startup line all said enrolment was live and only recognition was blocked. Enroll embeds every sample before it stores anything, so with no model on the box it fails on the first sample with ErrDisabled and nothing is ever stored. List then returns an empty list forever and Forget has nothing to delete. The shipped state was three methods, all no-ops, announced as a working half. SpeakerConfig.Recognizes was written as the gate for this and never called, so a block with enabled and no model_path wired everything and skipped the one warning the operator needed. It is the gate now, and that config shape logs why it stayed off. Three smaller repairs. ErrDisabled had no case in speakerErr and reached the surface as an opaque core failure, when it means the same thing ErrUnknownMethod does. Forget read the row first and answered ErrNotFound on a second call, so the layer documented as the one that must always work reintroduced a failure for a voiceprint that was already gone. And a row with unparsable metadata listed as a plausible profile named after its own id with 0 samples, which is what a real minimal enrolment looks like; it is reported as damaged now. Found in review of #74.
242 lines
9.7 KiB
Go
242 lines
9.7 KiB
Go
// Package speaker is voice identification (Vikunja #255,
|
|
// docs/plans/10-speaker-recognition.md).
|
|
//
|
|
// The shape is the same seam internal/vision uses: an Embedder turns audio into
|
|
// a voiceprint, a Recognizer compares one against the enrolled profiles, and a
|
|
// Disabled floor refuses politely when nothing is wired. On this box nothing is
|
|
// wired, and that is the honest state — see "Blocked" below.
|
|
//
|
|
// # A voiceprint is not like the other vectors
|
|
//
|
|
// Everything else in the vector table is something he wrote or said. A speaker
|
|
// profile is biometric data about a person, quite possibly a person who never
|
|
// asked for Maven to exist. The rules that follow from that are in the code:
|
|
//
|
|
// - Enrolment is explicit and named. There is no "enrol the unknown voice
|
|
// automatically" path; see the refusal in enroll.go.
|
|
// - A profile is deletable, individually, and Forget really removes the row.
|
|
// - Below the threshold the answer is "I do not know", never the closest
|
|
// guess. A misattributed fact is worse than an unattributed one.
|
|
// - Nothing here gates whether Maven listens or answers. Identification
|
|
// annotates a turn; it never authorises one, and an unrecognised voice is
|
|
// not turned away.
|
|
// - Voiceprints never leave the box. They live in the encrypted store with
|
|
// everything else and are never search input to anything external.
|
|
//
|
|
// # Blocked
|
|
//
|
|
// There is no speaker-embedding model on this box: no ECAPA-TDNN, no x-vector,
|
|
// no wespeaker or titanet ONNX anywhere under /mnt/hdd1 or models/ (checked
|
|
// 2026-08-01; the only ONNX files are the e5 text embedder and the piper voice).
|
|
// There are also no enrolment samples. So Recognizer runs against Disabled and
|
|
// every Identify answers ErrDisabled until a model lands.
|
|
//
|
|
// "Recognition is blocked but enrolment works" is not true and this package
|
|
// used to imply it. Enroll embeds every sample before it stores anything
|
|
// (enroll.go, "Embed first, store second"), so with no model it fails on the
|
|
// first sample with ErrDisabled and nothing is ever stored. List then returns
|
|
// an empty list forever and Forget has nothing to delete. Without an embedder
|
|
// all three operations are no-ops, and mavend does not wire the wire methods
|
|
// at all in that state.
|
|
//
|
|
// The MFCC + GMM "simplest floor" in the plan document is refused rather than
|
|
// deferred. A hand-rolled spectral distance would identify people confidently
|
|
// and wrongly, and its output would be written into facts as "Ками said this".
|
|
// For a biometric, a bad floor is worse than none: no answer is honest, and a
|
|
// wrong answer is a false memory about a person.
|
|
package speaker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/audio"
|
|
"github.com/kami/maven/internal/memory"
|
|
)
|
|
|
|
// Prefix — the id prefix speaker profiles carry in the shared vector table.
|
|
// It is what ByPrefix enumerates, and what every Store implementation filters
|
|
// out of Search so note recall cannot rank a voiceprint. The constant lives in
|
|
// internal/memory because the store layer has to know it and cannot import this
|
|
// package.
|
|
const Prefix = memory.NonRecallPrefix
|
|
|
|
// DefaultThreshold — cosine similarity a match must beat to be a match.
|
|
//
|
|
// 0.7 is the usual operating point for ECAPA-style embeddings on clean speech
|
|
// and it is deliberately on the strict side here. The two error directions are
|
|
// not symmetric: refusing to name a voice costs a "не узнала", while naming the
|
|
// wrong person writes his wife's remark into a fact attributed to him.
|
|
const DefaultThreshold = 0.7
|
|
|
|
// DefaultMinSeconds — how much speech an identification needs. Under about two
|
|
// seconds a voiceprint is mostly noise and the similarity score is not worth
|
|
// reading.
|
|
const DefaultMinSeconds = 2.0
|
|
|
|
// MinEnrollSamples / MinEnrollSeconds — what enrolment requires. Several
|
|
// separate utterances, not one long one: a profile built from a single sentence
|
|
// encodes that sentence's prosody as much as the voice.
|
|
const (
|
|
MinEnrollSamples = 3
|
|
MinEnrollSeconds = 9.0
|
|
)
|
|
|
|
// Errors callers distinguish.
|
|
var (
|
|
// ErrDisabled — no embedding model is wired. The state of this box.
|
|
ErrDisabled = errors.New("speaker: recognition is not configured")
|
|
// ErrTooShort — not enough speech to say anything about.
|
|
ErrTooShort = errors.New("speaker: not enough audio")
|
|
// ErrUnknown — audio embedded fine, but no enrolled profile is close
|
|
// enough. Not an error in the sense of something being broken: it is the
|
|
// correct answer for a guest, and the caller should carry on without a
|
|
// speaker rather than treat the turn as failed.
|
|
ErrUnknown = errors.New("speaker: voice not recognised")
|
|
// ErrNoProfiles — nobody is enrolled yet.
|
|
ErrNoProfiles = errors.New("speaker: nobody is enrolled")
|
|
// ErrNotFound — no profile with that id.
|
|
ErrNotFound = errors.New("speaker: no such profile")
|
|
// ErrBadID — an id that is empty or carries characters an id should not.
|
|
ErrBadID = errors.New("speaker: invalid profile id")
|
|
// ErrBadFormat — audio that is not the canonical 16 kHz mono PCM shape.
|
|
ErrBadFormat = errors.New("speaker: audio format not supported")
|
|
// ErrBadVector — an embedder returned something unusable (empty, or all
|
|
// zeroes, which normalises to nothing and would match everything equally).
|
|
ErrBadVector = errors.New("speaker: embedder returned an unusable vector")
|
|
)
|
|
|
|
// Embedder turns speech into a voiceprint. Implementations are expected to
|
|
// return an L2-normalised vector, because the whole store compares by dot
|
|
// product; Normalize is applied anyway rather than trusted.
|
|
//
|
|
// This is the seam a downloaded ECAPA-TDNN ONNX model plugs into. It is an
|
|
// interface rather than a concrete ONNX type so the package is testable with no
|
|
// model on disk, which is the only way it could be tested here at all.
|
|
type Embedder interface {
|
|
Embed(ctx context.Context, a audio.Audio) ([]float32, error)
|
|
// Dim is the vector width, used to reject a profile recorded with a
|
|
// different model rather than silently scoring it as zero.
|
|
Dim() int
|
|
}
|
|
|
|
// Disabled is the floor: no model, no answers, no guesses.
|
|
type Disabled struct{}
|
|
|
|
// Embed always fails with ErrDisabled.
|
|
func (Disabled) Embed(context.Context, audio.Audio) ([]float32, error) { return nil, ErrDisabled }
|
|
|
|
// Dim is 0 for the disabled embedder.
|
|
func (Disabled) Dim() int { return 0 }
|
|
|
|
// Profile — one enrolled voice.
|
|
//
|
|
// Name is what she calls the person out loud ("Ками"). ID is the stable handle
|
|
// used in sources and metadata. Samples records how many utterances the
|
|
// voiceprint was averaged from, so a profile enrolled from the bare minimum is
|
|
// visibly weaker than one built from ten.
|
|
type Profile struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Enrolled time.Time `json:"enrolled"`
|
|
Samples int `json:"samples"`
|
|
Dim int `json:"dim"`
|
|
|
|
// Damaged marks a row whose stored metadata did not read back cleanly —
|
|
// an unparsable sample count or timestamp, or a missing name. The
|
|
// voiceprint is still usable, but the row should be listed as damaged
|
|
// rather than as a plausible profile enrolled from nothing.
|
|
Damaged bool `json:"damaged,omitempty"`
|
|
|
|
// Vec is the voiceprint. Not serialised to any surface: a listing tells him
|
|
// who is enrolled, it does not hand out the biometric itself.
|
|
Vec []float32 `json:"-"`
|
|
}
|
|
|
|
// Source is what a fact or note written during this speaker's turn is tagged
|
|
// with, e.g. "tap:voice:speaker:kami". Attribution belongs in the source rather
|
|
// than in the text, so it can be corrected or dropped later without rewriting
|
|
// what was said.
|
|
func (p Profile) Source(base string) string {
|
|
if p.ID == "" {
|
|
return base
|
|
}
|
|
return base + ":" + Prefix + p.ID
|
|
}
|
|
|
|
// Match — an identification result. Score is cosine similarity in [-1, 1].
|
|
type Match struct {
|
|
Profile Profile
|
|
Score float64
|
|
}
|
|
|
|
// ValidID reports whether an id is usable as a profile handle. Deliberately
|
|
// narrow: lowercase letters, digits, dash and underscore. Ids end up in note
|
|
// sources and in vector-table keys, so a permissive id would be a way to write
|
|
// into a neighbouring key space.
|
|
func ValidID(id string) bool {
|
|
if id == "" || len(id) > 64 {
|
|
return false
|
|
}
|
|
for _, r := range id {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// NormalizeID lowercases and trims a proposed id before validating it, so
|
|
// "Ками" typed as "Kami " does not fail for a reason nobody can see.
|
|
func NormalizeID(id string) string {
|
|
return strings.ToLower(strings.TrimSpace(id))
|
|
}
|
|
|
|
// Normalize returns an L2-normalised copy of v, or ErrBadVector when there is
|
|
// nothing to normalise. A zero vector is refused rather than passed on: it
|
|
// scores 0 against everything, which reads as "no match" but for the wrong
|
|
// reason and would hide a broken embedder.
|
|
func Normalize(v []float32) ([]float32, error) {
|
|
if len(v) == 0 {
|
|
return nil, ErrBadVector
|
|
}
|
|
var sum float64
|
|
for _, f := range v {
|
|
if math.IsNaN(float64(f)) || math.IsInf(float64(f), 0) {
|
|
return nil, ErrBadVector
|
|
}
|
|
sum += float64(f) * float64(f)
|
|
}
|
|
norm := math.Sqrt(sum)
|
|
if norm == 0 {
|
|
return nil, ErrBadVector
|
|
}
|
|
out := make([]float32, len(v))
|
|
for i, f := range v {
|
|
out[i] = float32(float64(f) / norm)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Similarity is the cosine similarity of two L2-normalised vectors. Different
|
|
// widths score 0: a profile enrolled with another model must not accidentally
|
|
// match, and 0 is below every sane threshold.
|
|
func Similarity(a, b []float32) float64 {
|
|
if len(a) != len(b) || len(a) == 0 {
|
|
return 0
|
|
}
|
|
var sum float64
|
|
for i := range a {
|
|
sum += float64(a[i]) * float64(b[i])
|
|
}
|
|
return sum
|
|
}
|
|
|
|
// seconds is the playback length of a frame, for the minimum-audio checks.
|
|
func seconds(a audio.Audio) float64 { return a.Duration() }
|