diff --git a/cmd/mavwaked/wakeword.go b/cmd/mavwaked/wakeword.go new file mode 100644 index 0000000..5f83902 --- /dev/null +++ b/cmd/mavwaked/wakeword.go @@ -0,0 +1,191 @@ +package main + +// The wake word, "Мэйвен" (V-487 stage two). +// +// Silero answers "is this frame speech". It does not answer "was this said to +// her", and until this file existed nothing did: every utterance near the +// microphone became a turn. What made that safe rather than expensive was +// SurfaceVoice capping acts at L0, and L0 does not cap reading, so the room +// could still hear his facts read back. +// +// This file owns the 80ms cadence and the three rings of state between the +// models. wakefeatures.go owns the tensors. +// +// Nil is a working value, and it is the CLOSED gate rather than the open one. +// Feed on a nil receiver reports no keyword; session.go asks separately +// whether a gate exists at all. That split is deliberate: a nil that answers +// "yes, keyword" reads as a working wake word in every log line it produces. + +import ( + "log" + "sync" +) + +// defaultWakeThreshold — score above which the keyword was said. Picked from +// the false-accept rate on held-out Russian speech, not from accuracy: a miss +// costs him a repeat, a false accept costs a turn nobody asked for. See +// docs/evals for the wakes-per-hour this buys. +const defaultWakeThreshold = 0.99 + +// wakeWord is the streaming state around wakeModels. It is fed the same +// capture frames the VAD sees and answers whether the keyword has just been +// spoken. +type wakeWord struct { + mu sync.Mutex + m *wakeModels + + threshold float64 + + // pending holds captured samples not yet part of a full 80ms chunk, and + // history holds the melContext samples before them. + pending []float32 + history []float32 + + // mels is the newest embedFrames mel frames, oldest first. + mels [][melBins]float32 + // embeds is the newest headWindow embeddings, oldest first. + embeds [][embedDim]float32 + + last float64 // most recent score, held between chunks +} + +// newWakeWord loads the models and wraps them in the streaming gate. +func newWakeWord(melPath, embedPath, headPath, libPath string, threshold float64) (*wakeWord, error) { + m, err := newWakeModels(melPath, embedPath, headPath, libPath) + if err != nil { + return nil, err + } + if threshold <= 0 { + threshold = defaultWakeThreshold + } + return &wakeWord{m: m, threshold: threshold}, nil +} + +// Close releases the models. +func (w *wakeWord) Close() { + if w == nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + w.m.Close() + w.m = nil +} + +// Feed takes one capture frame and reports whether the keyword was heard on +// it. A nil wakeWord hears nothing. +func (w *wakeWord) Feed(frame []int16) bool { + if w == nil { + return false + } + w.mu.Lock() + defer w.mu.Unlock() + + for _, v := range frame { + w.pending = append(w.pending, float32(v)/32768.0) + } + fired := false + for len(w.pending) >= chunkSamples { + chunk := w.pending[:chunkSamples] + if w.step(chunk) { + fired = true + } + w.history = append(w.history[:0], tailFloat32(append(w.history, chunk...), melContext)...) + // Slide the remainder to the front rather than reslicing. This runs + // every 80ms for as long as the daemon lives. + w.pending = append(w.pending[:0], w.pending[chunkSamples:]...) + } + return fired +} + +// Reset drops the streaming state, so a fresh utterance is not judged on audio +// from before it. Called after every dispatch and after barge-in, for the same +// reason silero is: echo-era history must not score the next sentence, and her +// own voice saying the keyword must not wake her. +func (w *wakeWord) Reset() { + if w == nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + w.pending, w.history = w.pending[:0], w.history[:0] + w.mels, w.embeds = nil, nil + w.last = 0 +} + +// Score returns the most recent score, for the operator to read out of the +// journal when picking a threshold for his room. +func (w *wakeWord) Score() float64 { + if w == nil { + return 0 + } + w.mu.Lock() + defer w.mu.Unlock() + return w.last +} + +// step runs one 80ms chunk through all three models. It returns true when the +// score crosses the threshold on this chunk. +func (w *wakeWord) step(chunk []float32) bool { + buf := make([]float32, 0, melContext+len(chunk)) + if pad := melContext - len(w.history); pad > 0 { + buf = append(buf, make([]float32, pad)...) + } + buf = append(buf, tailFloat32(w.history, melContext)...) + buf = append(buf, chunk...) + + frames, err := w.m.melFrames(buf) + if err != nil { + // A failed inference must not silence the microphone. Hold the last + // score and let the next chunk try again. + log.Printf("mavwaked: wake word: mel: %v", err) + return false + } + w.mels = tailMel(append(w.mels, frames...), embedFrames) + if len(w.mels) < embedFrames { + return false + } + e, err := w.m.embedding(w.mels) + if err != nil { + log.Printf("mavwaked: wake word: embedding: %v", err) + return false + } + w.embeds = tailEmbed(append(w.embeds, e), headWindow) + if len(w.embeds) < headWindow { + return false + } + score, err := w.m.score(w.embeds) + if err != nil { + log.Printf("mavwaked: wake word: head: %v", err) + return false + } + // Report the crossing, not the state. A keyword held above the threshold + // for a second is one wake, and firing on every chunk of it would make the + // gate look open when it is merely slow to fall. + crossed := score >= w.threshold && w.last < w.threshold + w.last = score + return crossed +} + +// The three rings. Each keeps the newest n entries and nothing older. + +func tailFloat32(s []float32, n int) []float32 { + if len(s) <= n { + return s + } + return s[len(s)-n:] +} + +func tailMel(s [][melBins]float32, n int) [][melBins]float32 { + if len(s) <= n { + return s + } + return append(s[:0], s[len(s)-n:]...) +} + +func tailEmbed(s [][embedDim]float32, n int) [][embedDim]float32 { + if len(s) <= n { + return s + } + return append(s[:0], s[len(s)-n:]...) +}