From 2c73493bf8dbb1f8f04dc6642a2085b6244c0d80 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 9 Aug 2026 15:56:07 +0400 Subject: [PATCH] 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. --- cmd/mavwaked/wakefeatures.go | 19 +++++++++++++++++-- deploy/mavwaked.service | 26 ++++++++++++++++++++------ docs/deployment.md | 14 +++++++++++++- docs/evals/2026-08-09-wake-word.md | 8 ++++++++ 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/cmd/mavwaked/wakefeatures.go b/cmd/mavwaked/wakefeatures.go index 1ba69ae..c27d6aa 100644 --- a/cmd/mavwaked/wakefeatures.go +++ b/cmd/mavwaked/wakefeatures.go @@ -71,15 +71,30 @@ func newWakeModels(melPath, embedPath, headPath, libPath string) (*wakeModels, e 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, nil) + 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{} - var err error if m.mel, err = open(melPath, []string{"input"}, []string{"output"}); err != nil { return nil, err } diff --git a/deploy/mavwaked.service b/deploy/mavwaked.service index c569e2b..c5eca2c 100644 --- a/deploy/mavwaked.service +++ b/deploy/mavwaked.service @@ -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. +# "Мэйвен, " 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 diff --git a/docs/deployment.md b/docs/deployment.md index 5bf76d1..67de18f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. +"Мэйвен, " 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 diff --git a/docs/evals/2026-08-09-wake-word.md b/docs/evals/2026-08-09-wake-word.md index 5ee84c5..cf2d181 100644 --- a/docs/evals/2026-08-09-wake-word.md +++ b/docs/evals/2026-08-09-wake-word.md @@ -94,6 +94,14 @@ the head the whole request to peak during. A bare "Мэйвен" with nothing af 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