Files
Maven/cmd/mavwaked/wakeword.go
T
claude 02d96e611d Default the keyword threshold to 0.999, from the measurement (V-487)
Over 65.1 minutes of held-out Common Voice the built binary woke three times
at 0.99 and once at 0.999. The recall difference was one render out of 126.
One render is worth two thirds of the false wakes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 15:42:09 +04:00

196 lines
5.7 KiB
Go

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:]...)
}