Compare commits

...

9 Commits

Author SHA1 Message Date
claude 9f714b7ae8 Name the device that returns audio, not the one that did not (V-487)
docs/deployment.md still told the next reader the microphone was the fifine on
card 0. Three days of silence started there, so the paragraph now carries the
levels and the check that finds it: stop the unit, arecord five seconds,
measure. A live room floor reads near 0.001.
2026-08-09 17:25:56 +04:00
claude ef3ee1e00a Merge pull request 'mavwaked has no wake word, only an energy VAD — add silero-vad and a keyword gate' (#222) from task/487-capture-device into master 2026-08-09 15:25:05 +02:00
claude 99e73ea653 Listen on the Scarlett, because the fifine returns silence (V-487)
mavwaked has logged zero completed utterances in three days of journal, and
the wake word is not why: the count was zero before it existed too. The fifine
returns RMS 0.00004 over five seconds with its capture switch on and its ALSA
volume at the full 496 of 496, so the silence is in the hardware and no flag
reaches it.

Measured over eight seconds of the same speech: fifine 0.00004, onboard ALC897
0.142 clipping at peak 1.0, USB camera 0.289 clipping, Scarlett Solo 0.003
clean. The two loud ones clip, so the quiet clean one wins.

Named CARD=Gen rather than card 4, because a USB card number moves when
something else is replugged and this daemon must not change ears quietly.

Verified in the room: keyword heard at score 0.999, utterance complete in
1.65s, and she answered "сейчас 17 часов 24 минуты".
2026-08-09 17:24:41 +04:00
claude ab1784f5e1 Merge pull request 'mavwaked has no wake word, only an energy VAD — add silero-vad and a keyword gate' (#221) from task/487-wake-word-deploy into master 2026-08-09 13:56:25 +02:00
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
5 changed files with 203 additions and 25 deletions
+17 -2
View File
@@ -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
}
+9 -5
View File
@@ -21,11 +21,15 @@ import (
"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
// 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
+37 -14
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,27 +35,39 @@
# 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
[Service]
# card 0 is the fifine USB microphone. Named, and not "default", because the
# default device follows whatever pipewire last decided and this daemon should
# not change ears when he plugs in a headset.
# The Scarlett Solo 4th Gen, and not the fifine. The fifine was the device
# here for three days and mavwaked never logged one utterance in them, because
# it returns RMS 0.00004 with its capture switch on and its ALSA volume at the
# full 496 of 496. That silence is in the hardware, so no flag reaches it.
#
# Named CARD=Gen and not card 4, because a USB card number moves when
# something else is replugged and this daemon must not change ears quietly.
# Not "default" either: that follows whatever pipewire last decided.
#
# plughw and not hw. mavwaked asks arecord for 16kHz mono, which is what the
# whole pipeline is canonical in. The fifine offers 2 channels at 44100 or
# 48000 and nothing else, so bare hw:0,0 dies on "Channels count non
# available" before a frame is read. plughw puts ALSA's downmix and resampler
# in front. Any replacement microphone wants the same treatment.
# whole pipeline is canonical in. Neither microphone offers it, so bare hw
# dies on "Channels count non available" before a frame is read. plughw puts
# ALSA's downmix and resampler in front. Any replacement wants the same.
#
# The Scarlett measured RMS 0.003 against 0.14 on the onboard input, so its
# front-panel gain is the thing to raise if she mishears. That is a knob, not
# a control ALSA exposes. The two loud devices, the onboard ALC897 and the
# camera, both clip at peak 1.0 and are worse candidates, not better ones.
Environment=LD_LIBRARY_PATH=%h/.local/lib
ExecStart=%h/.local/bin/mavwaked \
-device plughw:0,0 \
-device plughw:CARD=Gen,DEV=0 \
-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
+28 -4
View File
@@ -166,11 +166,35 @@ the GPU. A model that will not load is logged and not fatal.
`-barge-in` is not passed. The threshold is room-specific and this room has no
number yet. Read the "suppressed while speaking" means out of the journal first.
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.
The device is `plughw:CARD=Gen,DEV=0`, the Scarlett Solo. It is `plughw` and
not `hw` because mavwaked asks arecord for 16kHz mono. No microphone here
offers that, so bare `hw` dies on "Channels count non available" before a
frame is read. It is named `CARD=Gen` and not card 4 because a USB card number
moves when something else is replugged.
There is no wake word yet (V-487 stage two), so the loop runs open.
It used to be the fifine on card 0, and that cost three days. mavwaked logged
zero completed utterances across them, before the wake word existed and after.
The fifine returns RMS 0.00004 with its capture switch on and its ALSA volume
at the full 496 of 496. That silence is in the hardware and no flag reaches
it. Over the same eight seconds of speech the onboard ALC897 read 0.142 and
the camera 0.289, both clipping at peak 1.0. The Scarlett read 0.003 clean.
Check the level before blaming the gate. Stop the unit, run `arecord` against
the device for five seconds, and measure. A live room floor reads near 0.001.
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.