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. Over 65 minutes of Common Voice, 0.99 woke her three times and // 0.999 once, and the difference in recall was one render out of 126. So the // default is the strict one. `docs/evals/2026-08-09-wake-word.md` has both // tables. const defaultWakeThreshold = 0.999 // 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:]...) }