From 21a42cb3e64800c3c88e93883baccb740b1a56bf Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 9 Aug 2026 14:18:46 +0400 Subject: [PATCH 1/4] Load openWakeWord's three models and run their tensors (V-487) The two feature models are frozen and pretrained; only the 100KB head was trained here. The shapes were measured rather than assumed: 2.0s of 16kHz audio gives 197 mel frames, and 76-frame windows at stride 8 give exactly the 16 embeddings the head was fitted on. This file knows tensors and nothing about the 80ms cadence, which is why the scaling openWakeWord applies between the two feature models lives here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN --- cmd/mavwaked/wakefeatures.go | 187 +++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 cmd/mavwaked/wakefeatures.go diff --git a/cmd/mavwaked/wakefeatures.go b/cmd/mavwaked/wakefeatures.go new file mode 100644 index 0000000..1ba69ae --- /dev/null +++ b/cmd/mavwaked/wakefeatures.go @@ -0,0 +1,187 @@ +package main + +// The three models behind the wake word (V-487 stage two). +// +// openWakeWord's pipeline, run in a row: +// +// audio -> melspectrogram.onnx -> 32-bin mel frames, one per 10ms +// 76 frames -> embedding_model.onnx -> one 96-dim embedding per 80ms +// 16 embeds -> maven_wakeword.onnx -> one score +// +// The first two are frozen and pretrained. Only the last was trained here, +// which is why it is 100KB and the other two are megabytes. The shapes are +// not guesses: 2.0s of 16kHz audio measures 197 mel frames, and 76-frame +// windows at stride 8 give exactly the 16 embeddings the head was fitted on. +// +// This file knows ONNX and nothing about the 80ms cadence. wakeword.go knows +// the cadence and nothing about tensors. + +import ( + "fmt" + + ort "github.com/yalue/onnxruntime_go" +) + +const ( + // melHop — samples per mel frame. 10ms at 16kHz. + melHop = 160 + // melBins — mel bins per frame, fixed by melspectrogram.onnx. + melBins = 32 + // embedFrames — mel frames one embedding is computed over, 760ms. + embedFrames = 76 + // embedStride — mel frames between embeddings, 80ms. + embedStride = 8 + // embedDim — the embedding width. + embedDim = 96 + // headWindow — embeddings the head scores at once, 1.28s of audio. + headWindow = 16 + + // melContext — samples of history prepended to each incremental mel + // call, chosen so the eight frames this call yields continue exactly + // where the previous call's eight stopped. + // + // melspectrogram.onnx returns N/160-3 frames for N samples, and frame i + // covers [i*160, i*160+400). With 480 samples of history the buffer is + // 1760 samples, which is 8 frames, and the oldest of them starts one hop + // after the newest of the previous call. Less history leaves a gap: the + // first frames of a bare chunk would be computed against silence. + melContext = 480 + + // chunkSamples — audio per embedding step, 80ms. + chunkSamples = embedStride * melHop +) + +// wakeModels holds the three ONNX sessions. It runs on CPU threads beside +// silero and never touches the GPU. That is a rule, not a result: a wake word +// that waits on card admission is not a wake word. +type wakeModels struct { + mel *ort.DynamicAdvancedSession + emb *ort.DynamicAdvancedSession + head *ort.DynamicAdvancedSession +} + +// newWakeModels loads all three. melPath and embedPath are openWakeWord's +// frozen feature models; headPath is the keyword head trained for "Мэйвен". +func newWakeModels(melPath, embedPath, headPath, libPath string) (*wakeModels, error) { + if !ort.IsInitialized() { + if libPath != "" { + ort.SetSharedLibraryPath(libPath) + } + if err := ort.InitializeEnvironment(); err != nil { + return nil, fmt.Errorf("wake word: onnx runtime: %w", err) + } + } + open := func(p string, in, out []string) (*ort.DynamicAdvancedSession, error) { + s, err := ort.NewDynamicAdvancedSession(p, in, out, nil) + if err != nil { + return nil, fmt.Errorf("wake word: load %s: %w", p, err) + } + return s, nil + } + m := &wakeModels{} + var err error + if m.mel, err = open(melPath, []string{"input"}, []string{"output"}); err != nil { + return nil, err + } + if m.emb, err = open(embedPath, []string{"input_1"}, []string{"conv2d_19"}); err != nil { + m.Close() + return nil, err + } + if m.head, err = open(headPath, []string{"embeddings"}, []string{"score"}); err != nil { + m.Close() + return nil, err + } + return m, nil +} + +// Close releases the three sessions. +func (m *wakeModels) Close() { + if m == nil { + return + } + for _, s := range []*ort.DynamicAdvancedSession{m.mel, m.emb, m.head} { + if s != nil { + s.Destroy() + } + } + m.mel, m.emb, m.head = nil, nil, nil +} + +// melFrames runs one buffer of samples and returns the mel frames it yielded. +func (m *wakeModels) melFrames(buf []float32) ([][melBins]float32, error) { + in, err := ort.NewTensor(ort.NewShape(1, int64(len(buf))), buf) + if err != nil { + return nil, err + } + defer in.Destroy() + + n := int64(len(buf)/melHop - 3) + if n < 1 { + return nil, fmt.Errorf("wake word: %d samples yield no mel frames", len(buf)) + } + out, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 1, n, melBins)) + if err != nil { + return nil, err + } + defer out.Destroy() + + if err := m.mel.Run([]ort.Value{in}, []ort.Value{out}); err != nil { + return nil, err + } + data := out.GetData() + frames := make([][melBins]float32, n) + for i := range frames { + for j := 0; j < melBins; j++ { + // The scaling openWakeWord applies between the two feature + // models, and the head was fitted on its output. + frames[i][j] = data[i*melBins+j]/10.0 + 2.0 + } + } + return frames, nil +} + +// embedding runs embedFrames mel frames through the frozen embedder. +func (m *wakeModels) embedding(mels [][melBins]float32) ([embedDim]float32, error) { + var e [embedDim]float32 + flat := make([]float32, 0, embedFrames*melBins) + for _, f := range mels { + flat = append(flat, f[:]...) + } + in, err := ort.NewTensor(ort.NewShape(1, embedFrames, melBins, 1), flat) + if err != nil { + return e, err + } + defer in.Destroy() + out, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 1, 1, embedDim)) + if err != nil { + return e, err + } + defer out.Destroy() + if err := m.emb.Run([]ort.Value{in}, []ort.Value{out}); err != nil { + return e, err + } + copy(e[:], out.GetData()) + return e, nil +} + +// score runs the trained head over headWindow embeddings. +func (m *wakeModels) score(embeds [][embedDim]float32) (float64, error) { + flat := make([]float32, 0, headWindow*embedDim) + for _, e := range embeds { + flat = append(flat, e[:]...) + } + in, err := ort.NewTensor(ort.NewShape(1, headWindow, embedDim), flat) + if err != nil { + return 0, err + } + defer in.Destroy() + out, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 1)) + if err != nil { + return 0, err + } + defer out.Destroy() + if err := m.head.Run([]ort.Value{in}, []ort.Value{out}); err != nil { + return 0, err + } + return float64(out.GetData()[0]), nil +} From 877b1fd4f8f7abe5c5963e48fdabde4f35e369f2 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 9 Aug 2026 14:18:46 +0400 Subject: [PATCH 2/4] Score the keyword every 80ms without re-reading old audio (V-487) melContext is 480 because melspectrogram.onnx returns N/160-3 frames and frame i covers [i*160, i*160+400). With 480 samples of history the buffer is 8 frames and the oldest continues exactly one hop after the previous call's newest. Less history leaves a gap. Feed reports the threshold 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. Nil is the CLOSED gate rather than the open one. A nil that answers "yes, keyword" reads as a working wake word in every log line it produces. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN --- cmd/mavwaked/wakeword.go | 191 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 cmd/mavwaked/wakeword.go 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:]...) +} From 479b0c447567c2f2a5b0dbc0451226e74f9f7e08 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 9 Aug 2026 14:19:01 +0400 Subject: [PATCH 3/4] Speech without the keyword no longer reaches STT (V-487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now every utterance near the microphone became a turn. SurfaceVoice caps acts at L0, which made that safe rather than expensive, but L0 does not cap reading: the room could still hear his facts read back. The gate sits at dispatch, not at the VAD. The keyword opens a window, the VAD closes the utterance when he stops, and dispatch asks whether the window was open. That ordering is what lets him say "Мэйвен" and then a sentence: the window has to outlive the word by the length of what follows it. One keyword buys one turn. A window that renewed itself on every reply would leave the microphone open for as long as he kept talking, which is the state this exists to end. Her own voice cannot wake her. Every path above the gate returns while the player is running, so no frame of her reply is ever scored, and the streaming state is cleared when playback ends. Nil is a working value. Without -wake-model the gate is open and this is yesterday's mavwaked, which is what an operator with a missing file should get rather than a daemon that refuses to listen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN --- cmd/mavwaked/main.go | 36 ++++++++++++++++--- cmd/mavwaked/session.go | 76 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/cmd/mavwaked/main.go b/cmd/mavwaked/main.go index 4f0c5a4..f346705 100644 --- a/cmd/mavwaked/main.go +++ b/cmd/mavwaked/main.go @@ -12,10 +12,12 @@ // samples and the capture frame is 480, so silero.go re-chunks. This comment // used to say the two matched, which was true of silero v4. // -// There is still no wake-word model, so anything spoken near the microphone -// becomes a turn (V-487 stage two). The SurfaceVoice auth layer caps all -// commands at L0 (no destructive acts), which is what makes an accidental -// trigger safe rather than expensive. +// The keyword is "Мэйвен" and it is required, when -wake-model points at the +// head (V-487 stage two). Without it anything spoken near the microphone +// becomes a turn, which the SurfaceVoice auth layer makes safe rather than +// expensive: it caps all commands at L0, no destructive acts. It does not cap +// reading, so an open gate still lets the room hear his facts read back. +// wakeword.go holds the cadence and wakefeatures.go the three models. // // The conn carries both directions. mavwaked sends utterances and receives // proactive nudges on it, and it is opened at startup rather than at the first @@ -62,6 +64,12 @@ const ( defaultAddr = "127.0.0.1:9100" defaultLang = "ru" defaultReadSize = 4096 // max PCM bytes per read from arecord (fits multiple frames) + + // defaultWakeWindowMs — how long the keyword stays good for. He says + // "Мэйвен" and then a sentence, and the VAD does not close the utterance + // until he stops, so this has to outlive the word by the length of what + // follows it. It is spent on dispatch: one keyword, one turn. + defaultWakeWindowMs = 8000 ) func main() { @@ -86,6 +94,11 @@ func run(args []string) error { vadModel := flag.String("vad-model", "", "silero-vad onnx file; empty runs the energy threshold instead") vadThreshold := flag.Float64("vad-threshold", defaultSileroThreshold, "speech probability a frame must clear") onnxLib := flag.String("onnx-lib", os.Getenv("MAVEN_ONNX_LIB"), "libonnxruntime.so, needed with -vad-model") + wakeModel := flag.String("wake-model", "", "keyword head onnx; empty ships every utterance, as before V-487") + wakeMel := flag.String("wake-mel", "", "melspectrogram.onnx, required with -wake-model") + wakeEmbed := flag.String("wake-embed", "", "embedding_model.onnx, required with -wake-model") + wakeThreshold := flag.Float64("wake-threshold", defaultWakeThreshold, "score the keyword must clear") + wakeWindowMs := flag.Int("wake-window-ms", defaultWakeWindowMs, "ms an utterance may still start after the keyword") flag.CommandLine.Parse(args) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) @@ -171,6 +184,21 @@ func run(args []string) error { } sess := newSession(vad, newAplayPlayer(), &voiceSender{vc: vc}, *lang, barge) + // Keyword gate. A model that will not load is logged and not fatal, for + // the same reason silero's is not: an open gate is the daemon he had + // yesterday, and a daemon that refuses to start is not. + if *wakeModel != "" { + w, err := newWakeWord(*wakeMel, *wakeEmbed, *wakeModel, *onnxLib, *wakeThreshold) + if err != nil { + log.Printf("mavwaked: wake word unavailable, every utterance is a turn: %v", err) + } else { + defer w.Close() + sess.UseWakeWord(w, time.Duration(*wakeWindowMs)*time.Millisecond) + log.Printf("mavwaked: wake word from %s, threshold %.3f, window %dms", + *wakeModel, *wakeThreshold, *wakeWindowMs) + } + } + // Listen for nudges alongside capture. Connect eagerly so mavend has a // voice session before he has said anything: without one, a nudge routed // to voice finds nobody home and goes to the away channels instead. diff --git a/cmd/mavwaked/session.go b/cmd/mavwaked/session.go index 8c0d600..b99ae0e 100644 --- a/cmd/mavwaked/session.go +++ b/cmd/mavwaked/session.go @@ -20,6 +20,15 @@ type utteranceSender interface { Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) } +// keywordGate answers whether the keyword has just been spoken. The +// production one is wakeWord; tests substitute a recorder, because a gate that +// can only be exercised with three ONNX files is a gate nobody tests. +type keywordGate interface { + Feed(frame []int16) bool + Reset() + Score() float64 +} + // bargeInConfig holds the two numbers barge-in needs. Zero Frames disables // barge-in entirely — the half-duplex gate still runs. type bargeInConfig struct { @@ -63,6 +72,14 @@ type session struct { // whenever playback ends. loudFrames int + // wake is the keyword gate, or nil when no model was loaded. wakeUntil is + // how long a keyword stays good for: he says "Мэйвен" and then a sentence, + // and the VAD does not close the utterance until he stops, so the window + // has to outlive the word by the length of what follows it. + wake keywordGate + wakeWindow time.Duration + wakeUntil time.Time + // pending holds a nudge the push receiver handed over, waiting for the // capture loop to speak it. It is the one field written from another // goroutine, hence the mutex; everything else in this struct belongs to @@ -76,6 +93,8 @@ type session struct { bargeIns int // times playback was cut because he spoke over her sent int // utterances shipped to the daemon nudges int // proactive pushes spoken through the speaker + wakes int // times the keyword opened the gate + ignored int // complete utterances dropped because the keyword was absent // loudSum and loudSeen accumulate the energy of suppressed frames, so // the operator can read what the room actually measures and set @@ -88,6 +107,12 @@ func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeI return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge, now: time.Now} } +// UseWakeWord puts the keyword gate in front of dispatch. Without it every +// utterance is shipped, which is what mavwaked did before V-487 stage two. +func (s *session) UseWakeWord(w keywordGate, window time.Duration) { + s.wake, s.wakeWindow = w, window +} + // frameDuration is the wall time one captured frame represents. const frameDuration = defaultFrameMs * time.Millisecond @@ -147,6 +172,7 @@ func (s *session) feed(ctx context.Context, frame []byte) error { s.bargeIns++ s.loudFrames = 0 s.vad.Reset() + s.resetWake() log.Printf("mavwaked: barge-in — stopped playback") s.replayRecent() return nil @@ -157,13 +183,28 @@ func (s *session) feed(ctx context.Context, frame []byte) error { if s.loudFrames != 0 { s.loudFrames = 0 s.vad.Reset() + // The wake word saw nothing during playback, so what it holds is from + // before she spoke. Judging what he says next on it would score a + // sentence that ended a reply ago. + s.resetWake() } if s.startPendingNudge() { return nil } - utt, state := s.vad.Feed(PCMToI16(frame)) + // The keyword is scored on the same frames the VAD sees, and only on the + // ones that reach here: every path above returns while she is speaking, so + // her own voice saying "Мэйвен" cannot wake her. + pcm := PCMToI16(frame) + if s.wake != nil && s.wake.Feed(pcm) { + s.wakes++ + s.wakeUntil = s.now().Add(s.wakeWindow) + log.Printf("mavwaked: keyword heard (score %.3f), listening for %s", + s.wake.Score(), s.wakeWindow) + } + + utt, state := s.vad.Feed(pcm) if state == StateSpeech || utt.Bytes == nil { return nil } @@ -218,6 +259,25 @@ func (s *session) startPendingNudge() bool { return true } +// awake reports whether an utterance ending now was addressed to her. +// +// With no wake word loaded every utterance is, which is exactly what mavwaked +// did before this gate existed. An operator with no model file gets the old +// daemon rather than a daemon that refuses to hear anything. +func (s *session) awake() bool { + if s.wake == nil { + return true + } + return s.now().Before(s.wakeUntil) +} + +// resetWake drops the gate's streaming state when there is a gate. +func (s *session) resetWake() { + if s.wake != nil { + s.wake.Reset() + } +} + // keepRecent stores a copy of one barge-in trigger frame, keeping at most // barge.Frames of them. func (s *session) keepRecent(frame []byte) { @@ -258,6 +318,19 @@ func (s *session) replayRecent() { // whole backlog straight into the VAD, and a Send error did the same on every // failed turn, so a dead socket drove a retry loop off nothing but backlog. func (s *session) dispatch(ctx context.Context, utt audio.Audio) error { + if !s.awake() { + s.ignored++ + log.Printf("mavwaked: utterance ignored, keyword not heard (%.2fs, %d ignored so far)", + utt.Duration(), s.ignored) + s.vad.Reset() + s.resetWake() + return nil + } + // One keyword, one turn. A window that renewed itself on every reply would + // leave the microphone open for as long as he kept talking, which is the + // state this gate exists to end. + s.wakeUntil = time.Time{} + log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...", utt.Duration(), len(utt.Bytes)) start := s.now() reply, err := s.sender.Send(ctx, utt, s.lang) @@ -286,6 +359,7 @@ func (s *session) dispatch(ctx context.Context, utt audio.Audio) error { // recorded before she started speaking. func (s *session) dropBacklog(start time.Time) { s.vad.Reset() + s.resetWake() s.loudFrames = 0 s.recent = s.recent[:0] if elapsed := s.now().Sub(start); elapsed > 0 { From ce6a6821a9bdcfc16ea75188cb627f598874846c Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 9 Aug 2026 14:19:01 +0400 Subject: [PATCH 4/4] Test the gate without three ONNX files (V-487) keywordGate is an interface so the decision that ships an utterance can be exercised with a fake that fires on demand. A gate that can only be tested with a model file is a gate nobody tests. The three that carry the fixed-when criterion: keywordless speech never reaches STT, the keyword does, and barge-in still cuts her off mid-sentence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN --- cmd/mavwaked/wakeword_test.go | 174 ++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 cmd/mavwaked/wakeword_test.go diff --git a/cmd/mavwaked/wakeword_test.go b/cmd/mavwaked/wakeword_test.go new file mode 100644 index 0000000..ffe0b47 --- /dev/null +++ b/cmd/mavwaked/wakeword_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "context" + "testing" + "time" +) + +// fakeGate fires on demand instead of running three ONNX models. The gate's +// own arithmetic is measured on real audio in docs/evals; what these tests +// cover is the thing that decides whether an utterance is shipped. +type fakeGate struct { + fireOn int // fire when this many frames have been fed, 0 never fires + fed int + resets int +} + +func (g *fakeGate) Feed(_ []int16) bool { + g.fed++ + return g.fireOn > 0 && g.fed == g.fireOn +} +func (g *fakeGate) Reset() { g.resets++ } +func (g *fakeGate) Score() float64 { return 1 } + +// wakingSession wires a session whose gate fires on the first frame it sees. +func wakingSession(fireOn int, window time.Duration) (*session, *fakePlayer, *fakeSender, *fakeGate) { + sess, p, snd := newTestSession(bargeInConfig{}) + g := &fakeGate{fireOn: fireOn} + sess.UseWakeWord(g, window) + return sess, p, snd, g +} + +func TestKeywordlessSpeechNeverReachesSTT(t *testing.T) { + sess, p, snd, g := wakingSession(0, 8*time.Second) + speakThenPause(t, sess) + + if len(snd.sent) != 0 { + t.Fatalf("sent %d utterances, want 0 — this is the whole point of V-487", len(snd.sent)) + } + if sess.ignored != 1 { + t.Errorf("ignored = %d, want 1", sess.ignored) + } + if p.plays != 0 { + t.Errorf("plays = %d, want 0", p.plays) + } + if g.fed == 0 { + t.Error("the gate was never fed a frame") + } +} + +func TestKeywordOpensTheGate(t *testing.T) { + sess, p, snd, _ := wakingSession(1, 8*time.Second) + speakThenPause(t, sess) + + if len(snd.sent) != 1 { + t.Fatalf("sent %d utterances, want 1", len(snd.sent)) + } + if sess.wakes != 1 { + t.Errorf("wakes = %d, want 1", sess.wakes) + } + if sess.ignored != 0 { + t.Errorf("ignored = %d, want 0", sess.ignored) + } + if p.plays != 1 { + t.Errorf("plays = %d, want 1", p.plays) + } +} + +// One keyword buys one turn. Without this the microphone stays open for as +// long as he keeps talking, which is the state the gate exists to end. +func TestOneKeywordBuysOneTurn(t *testing.T) { + sess, p, snd, _ := wakingSession(1, 8*time.Second) + speakThenPause(t, sess) + p.Stop() // she finished her reply + sess.discard = 0 // the backlog drain is not what this measures + speakThenPause(t, sess) + + if len(snd.sent) != 1 { + t.Fatalf("sent %d utterances, want 1: the second had no keyword", len(snd.sent)) + } + if sess.ignored != 1 { + t.Errorf("ignored = %d, want 1", sess.ignored) + } +} + +// The keyword is heard, then he says nothing for longer than the window. What +// he says after that is not addressed to her. +func TestTheKeywordExpires(t *testing.T) { + sess, _, snd, _ := wakingSession(1, 500*time.Millisecond) + now := time.Unix(1750000000, 0) + sess.now = func() time.Time { return now } + + if err := sess.feed(context.Background(), silentBytes()); err != nil { + t.Fatalf("feed: %v", err) + } + if sess.wakes != 1 { + t.Fatalf("wakes = %d, want 1", sess.wakes) + } + now = now.Add(2 * time.Second) + speakThenPause(t, sess) + + if len(snd.sent) != 0 { + t.Fatalf("sent %d utterances, want 0 — the keyword had expired", len(snd.sent)) + } +} + +// Barge-in cuts her off whether or not the keyword was heard. What he says +// after cutting her off still has to carry it. +func TestBargeInStillInterruptsHer(t *testing.T) { + sess, p, _, g := wakingSession(0, 8*time.Second) + sess.barge = bargeInConfig{RMS: 0.2, Frames: 3} + p.playing = true + loud := frameAt(0.35) + for i := 0; i < 4; i++ { + if err := sess.feed(context.Background(), loud); err != nil { + t.Fatalf("feed %d: %v", i, err) + } + } + if sess.bargeIns != 1 { + t.Fatalf("bargeIns = %d, want 1", sess.bargeIns) + } + if p.stops != 1 { + t.Errorf("stops = %d, want 1", p.stops) + } + if g.resets == 0 { + t.Error("barge-in left pre-playback audio in the gate") + } +} + +// Her own reply must not wake her. Frames captured while the player runs never +// reach the gate, and the gate is cleared when playback ends. +func TestHerOwnVoiceNeverReachesTheGate(t *testing.T) { + sess, p, _, g := wakingSession(1, 8*time.Second) + p.playing = true + for i := 0; i < 10; i++ { + if err := sess.feed(context.Background(), frameAt(0.35)); err != nil { + t.Fatalf("feed: %v", err) + } + } + if g.fed != 0 { + t.Fatalf("gate was fed %d frames while she was speaking, want 0", g.fed) + } + if sess.wakes != 0 { + t.Errorf("wakes = %d, want 0", sess.wakes) + } +} + +// No model, no gate: the daemon behaves exactly as it did before V-487 stage +// two. An operator with a missing file gets yesterday's mavwaked, not one that +// refuses to hear anything. +func TestNoGateShipsEveryUtterance(t *testing.T) { + sess, _, snd := newTestSession(bargeInConfig{}) + speakThenPause(t, sess) + + if len(snd.sent) != 1 { + t.Fatalf("sent %d utterances, want 1", len(snd.sent)) + } + if sess.ignored != 0 { + t.Errorf("ignored = %d, want 0", sess.ignored) + } +} + +// A nil *wakeWord is the closed gate, not a crash and not an open one. +func TestNilWakeWordHearsNothing(t *testing.T) { + var w *wakeWord + if w.Feed([]int16{0, 0, 0}) { + t.Error("a nil wake word reported the keyword") + } + if w.Score() != 0 { + t.Error("a nil wake word reported a score") + } + w.Reset() + w.Close() +}