Compare commits

..

10 Commits

Author SHA1 Message Date
claude 2c73493bf8 Pin the keyword models to one thread each and ship them (V-487)
The gate loaded and worked on workpc and took mavwaked from 68% of one core
to 335%. onnxruntime sizes its intra-op pool to every core and spins between
runs, which an always-on gate scoring three graphs twelve times a second
provokes for the whole day. One thread per session brings it to 81%, so the
keyword costs about 13% of a core, and each graph still finishes well inside
its 80ms.

The unit now passes the three -wake- flags and the models sit beside
silero_vad.onnx in ~/.local/share/maven/models. The threshold is left at the
binary's default so there is one place to change it.
2026-08-09 15:56:07 +04:00
claude ff202c0c35 Merge pull request 'mavwaked has no wake word, only an energy VAD — add silero-vad and a keyword gate' (#220) from task/487-wake-word-threshold into master 2026-08-09 13:46:42 +02:00
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
claude 62eef01c18 Record what the wake word invents, not just what it hears (V-487)
The first head woke 22 times per hour of continuous Russian speech. Two rounds
of hard negative mining over 40000 unseen Common Voice clips took that to 3.4,
and the second round recovered the recall the first had cost.

The number is crossings per hour, not accuracy per window. A 1.7% false-accept
rate on a gate that scores twelve times a second reads as small and is a wake
every few seconds.

Two things are stated rather than buried: Golos scores 2 wakes in 14 minutes at
every threshold, so a handful of real utterances sit above 0.999 and no
threshold moves them; and no negative in any table is a room recording.

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
claude 1a8aed35b8 Merge pull request 'mavwaked has no wake word, only an energy VAD — add silero-vad and a keyword gate' (#219) from task/487-wake-word-stage-two into master 2026-08-09 13:02:07 +02:00
claude ce6a6821a9 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 14:19:01 +04:00
claude 479b0c4475 Speech without the keyword no longer reaches STT (V-487)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 14:19:01 +04:00
claude 877b1fd4f8 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 14:18:46 +04:00
claude 21a42cb3e6 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 14:18:46 +04:00
claude b8279f6a22 Merge pull request 'mavwaked registers as a voice consumer it cannot honor, so a spoken turn silences every nudge' (#218) from task/671-mavwaked-registers-as-a-voice-consumer-i into master 2026-08-09 11:45:35 +02:00
8 changed files with 823 additions and 12 deletions
+32 -4
View File
@@ -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.
+75 -1
View File
@@ -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 {
+202
View File
@@ -0,0 +1,202 @@
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)
}
}
// One thread per session, not the default of every core. Measured on
// workpc: the default took mavwaked from 68% of one core to 335% of
// three, for three graphs that each run in well under 80ms single
// threaded. An always-on gate that eats a quarter of the workstation is
// not a gate he will leave running.
opts, err := ort.NewSessionOptions()
if err != nil {
return nil, fmt.Errorf("wake word: session options: %w", err)
}
defer opts.Destroy()
if err := opts.SetIntraOpNumThreads(1); err != nil {
return nil, fmt.Errorf("wake word: intra-op threads: %w", err)
}
if err := opts.SetInterOpNumThreads(1); err != nil {
return nil, fmt.Errorf("wake word: inter-op threads: %w", err)
}
open := func(p string, in, out []string) (*ort.DynamicAdvancedSession, error) {
s, err := ort.NewDynamicAdvancedSession(p, in, out, opts)
if err != nil {
return nil, fmt.Errorf("wake word: load %s: %w", p, err)
}
return s, nil
}
m := &wakeModels{}
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
}
+195
View File
@@ -0,0 +1,195 @@
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:]...)
}
+174
View File
@@ -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()
}
+20 -6
View File
@@ -4,11 +4,22 @@
# user unit because it needs his ALSA session and his ssh agent, and because
# it should stop when he logs out.
#
# THERE IS NO WAKE WORD YET (V-487 stage two). Anything spoken near the fifine
# becomes a turn. What makes that safe rather than expensive is voiceSender:
# it sends Surface=SurfaceVoice, which caps every command at L0, so no
# accidental trigger runs a destructive act. It does not stop her answering
# out loud, so this unit is his to stop when the room is not his alone.
# The keyword is "Мэйвен" and the three -wake- flags are what require it
# (V-487 stage two). Without them anything spoken near the fifine becomes a
# turn, which voiceSender makes safe rather than expensive: it sends
# Surface=SurfaceVoice, capping every command at L0. That does not stop her
# answering out loud, which is the whole reason the keyword exists.
#
# The threshold is 0.999 and it is the binary's default, so it is not passed.
# It came from 65 minutes of held-out Russian speech through this same binary:
# 0.9 false wakes an hour against 2.8 at 0.99, for one lost render out of 126
# (docs/evals/2026-08-09-wake-word.md). If the room proves noisier than the
# corpus, read the scores out of this unit's journal and pass -wake-threshold.
# Do not lower it by guessing.
#
# A keyword shorter than 1.32s can be heard too late to be used, because the
# head scores 1.28s of audio and the VAD has closed the utterance by then.
# "Мэйвен, <request>" is unaffected. A bare "Мэйвен" is the case that fails.
#
# -vad-model is passed on purpose. Silero answers "is this frame speech" where
# the energy floor answers "is this frame loud". It declines white noise at
@@ -24,7 +35,7 @@
# systemctl --user enable --now mavwaked.service
[Unit]
Description=Maven always-on listening (VAD, no wake word yet)
Description=Maven always-on listening (silero VAD, "Мэйвен" keyword)
# The tunnel is the only path to mavend and the only thing authenticating it.
Requires=maven-voice-tunnel.service
After=maven-voice-tunnel.service
@@ -45,6 +56,9 @@ ExecStart=%h/.local/bin/mavwaked \
-addr 127.0.0.1:9100 \
-lang ru \
-vad-model %h/.local/share/maven/models/silero_vad.onnx \
-wake-model %h/.local/share/maven/models/maven_wakeword.onnx \
-wake-mel %h/.local/share/maven/models/melspectrogram.onnx \
-wake-embed %h/.local/share/maven/models/embedding_model.onnx \
-onnx-lib %h/.local/lib/libonnxruntime.so
Restart=on-failure
RestartSec=5
+13 -1
View File
@@ -170,7 +170,19 @@ The device is `plughw:0,0` and not `hw:0,0`. The fifine offers 2 channels at
44100 or 48000 and nothing else, and mavwaked asks arecord for 16kHz mono. Bare
`hw` dies on "Channels count non available" before a frame is read.
There is no wake word yet (V-487 stage two), so the loop runs open.
The three `-wake-` flags require the keyword "Мэйвен" (V-487 stage two). Drop
them and the loop runs open, which is what it did before. The threshold is the
binary's default of 0.999 and is not passed. Over 65 minutes of held-out
Russian speech it woke her 0.9 times an hour against 2.8 at 0.99. That cost one
lost render out of 126 (`docs/evals/2026-08-09-wake-word.md`).
The three sessions are pinned to one thread each. onnxruntime otherwise sizes
its pool to every core and spins between runs, which took mavwaked from 68% of
one core to 335%. With the cap it sits at 81%, so the gate costs about 13%.
A keyword shorter than 1.32s can be heard too late to be used. The head scores
1.28s of audio, and the VAD has closed the utterance by then.
"Мэйвен, <request>" is unaffected. A bare "Мэйвен" is the case that fails.
mavwaked connects at startup and holds the conn, so a nudge routed to voice
reaches the speaker before he has said anything (V-671). It used to connect
+112
View File
@@ -0,0 +1,112 @@
# The "Мэйвен" wake word: what it hears and what it invents
*Measured 2026-08-09 on workpc and homesrv. V-487, stage two of two.*
Stage one gave mavwaked silero-vad, which answers "is this frame speech".
Nothing answered "was this said to her", so every utterance near the
microphone became a turn. SurfaceVoice caps acts at L0, which made that safe
rather than expensive. L0 does not cap reading, so the room could still hear
his facts read back.
The keyword is "Мэйвен". openWakeWord's two frozen feature models do the
hearing and a 100KB head trained here draws the boundary. It runs on CPU
beside silero and never touches the GPU.
## Why a per-window accuracy is not a number anyone can act on
The gate scores every 80ms. A 1.7% false-accept rate per window sounds small
and means a wake every few seconds. The useful question is how many times an hour
it wakes on speech that was not the keyword. So every table below counts
threshold crossings over whole clips and divides by the audio duration.
A crossing, not a window above the threshold. A keyword held high for half a
second is one wake, not six.
## The data
Positives are 600 silero TTS renders of three stressings of the keyword, six
speakers, ten trailing phrases, augmented eight ways each. Hard negatives are
560 renders of confusable Russian words. Real speech is Common Voice ru and Golos.
The 74257 Common Voice clips were already on workpc from the CrisperWhisper
work. The 200 Golos clips came from the CW2 WER eval.
Splits are by source file. Augmented copies of one render on both sides of a
split would measure memorisation.
Golos was never trained on at any stage, so it answers the harder question:
does this survive a change of speakers and rooms.
## Three heads
Each row is a full retrain. The false-accept column is 8.89 hours of Common
Voice that no stage of training had seen.
| trained on | recall (window) | false wakes/hour @0.99 |
|---|---|---|
| TTS + 13.7 min of Golos | 0.869 | not measurable |
| + 4000 Common Voice clips | 0.836 | 21.9 |
| + 3837 mined hard negatives | 0.784 | 4.2 |
| + 753 more mined | 0.810 | 3.4 |
The first row is why the second exists. Thirteen minutes of held-out speech
cannot measure a rate for a gate that scores twelve times a second. A head
trained only against TTS learns to tell TTS from not-TTS.
Mining is the whole story after that. Random negatives teach the head what
most speech sounds like. They do not teach it the few syllable sequences that
score high, because 4000 clips barely contain them. So the current head was
run over 20000 fresh clips, keeping every window it scored above 0.05. That
found 3837 windows in 855512. Repeating those ten times in the next training
run cut the rate five-fold.
The second round found 753 in 852240, a fifth of the yield, and bought a
further 20%. It also recovered recall, which the first round had cost. Whether
a third round is worth 25 minutes of workpc is untested.
## Where the threshold came from
Both columns are held out. Positives are the 126 renders in the test split.
Speech is 65.1 minutes of Common Voice, disjoint from every training and
mining pool. Both were run through the built `mavwaked` binary reading PCM from a
file, not through the python that trained the head.
| threshold | renders shipped | false wakes/hour |
|---|---|---|
| 0.99 | 116 / 126 | 2.8 |
| 0.999 | 115 / 126 | 0.9 |
One render against a third of the false wakes. `defaultWakeThreshold` is
0.999.
Golos disagrees. It gave 2 wakes in 14 minutes at every threshold, which is
8.7 per hour. Two events is not a rate. What it does say is that a handful of real utterances score above 0.999
and no threshold will move them.
## What it costs him
Ten of the 126 held-out renders were heard and still dropped, and every one
was an utterance shorter than 1.32s. The head scores 16 embeddings, or 1.28s of
audio. The score therefore peaks up to a second after a short keyword ends.
By then the VAD has closed the utterance and dispatch has already asked.
Real commands are "Мэйвен, <request>" and run past two seconds, which gives
the head the whole request to peak during. A bare "Мэйвен" with nothing after
it is the case that fails. One fix would hold an ignored utterance for a grace
period and ship it if the keyword lands late. It is not built.
## What it costs the workstation
Under systemd on workpc, mavwaked sat at 335% of a core with the gate on and
68% with only silero. onnxruntime sizes its thread pool to every core and spins
between runs, and this gate runs three graphs twelve times a second. Pinning
all three sessions to one thread brought it to 81%, so the keyword costs about
13% of one core. The three graphs each finish in well under 80ms that way.
## What was not measured
No room recordings. Every negative above is a clean corpus clip. This gate
will live among a television, a fan and the far side of a kitchen. None of
those are in these numbers.
No measurement of him. Training on his voice means copying his transcripts off
homesrv, which is his call and has not been asked.