// Package main — mavwaked: always-on voice listening client. // // Spawns arecord(1) as a subprocess, reads 16kHz mono PCM from its stdout, // runs an energy-based VAD over 30ms windows, and when a complete utterance // is detected sends it as a PushToTalk frame to the voice server. The reply // audio is played back through aplay(1). // // Voice activity is silero-vad when -vad-model points at the graph, and an // energy threshold when it does not. Silero declines noise the threshold // accepts: 0 frames against 68 to 99 on the four fixtures, measured in // docs/evals/2026-08-09-silero-vad.md. Note that the model window is 512 // 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. // // 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 // utterance, because mavend registers a voice session on accept. See nudge.go // for why a nudge that is not heard is worse than one that is not delivered. // // While a reply is playing the capture side is muted (half-duplex): without // it, Maven's own voice comes back in through the mic and she answers // herself. -barge-in punches one hole in that gate — sustained energy above // -barge-in-rms cuts playback so he can talk over her. It is off by default // because the threshold is room-specific; see playback.go. The threshold is a // raw frame RMS and has no reference to what the speaker actually leaks, so // the daemon logs the mean energy of the frames it suppressed while speaking. // Set -barge-in-rms from those numbers rather than by guessing. // // usage: // mavwaked # default ALSA device, 127.0.0.1:9100 // mavwaked -barge-in # let him interrupt her mid-reply // mavwaked -device hw:1,0 -addr 10.42.0.1:9100 // mavwaked -test file.wav # read from file, no arecord package main import ( "bufio" "context" "errors" "flag" "fmt" "io" "log" "os" "os/exec" "os/signal" "syscall" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/voice" ) // Defaults. const ( defaultDevice = "default" 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() { if err := run(os.Args[1:]); err != nil && !errors.Is(err, context.Canceled) { fmt.Fprintln(os.Stderr, "mavwaked:", err) os.Exit(1) } } func run(args []string) error { device := flag.String("device", defaultDevice, "ALSA capture device") addr := flag.String("addr", defaultAddr, "voice server TCP address") lang := flag.String("lang", defaultLang, "STT language hint (ru/en/mixed)") minRMS := flag.Int("min-rms", 100, "RMS floor ×10000 (e.g. 100 = 0.01)") speechMs := flag.Int("speech-ms", defaultSpeechMs, "min speech ms before trigger") silenceMs := flag.Int("silence-ms", defaultSilenceMs, "silence ms to end utterance") maxMs := flag.Int("max-ms", defaultMaxMs, "max utterance ms") testFile := flag.String("test", "", "read PCM from file instead of arecord (testing only)") bargeIn := flag.Bool("barge-in", false, "cut Maven off when he talks over her (needs a room-tuned -barge-in-rms)") bargeRMS := flag.Int("barge-in-rms", defaultBargeRMS, "RMS x10000 a frame must clear to count as barge-in") bargeFrames := flag.Int("barge-in-frames", defaultBargeFrames, "consecutive frames over -barge-in-rms before playback is cut") 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) defer stop() // Voice client — one conn carrying both directions. SendRequest reconnects // on error, and the push receiver redials on its own clock. vc := voice.Dial(*addr) defer vc.Close() // VAD engine. A model that will not load is logged and not fatal: the // energy threshold is worse, and it is a great deal better than a // listening client that refuses to start. vad := NewVAD(*minRMS, *speechMs, *silenceMs, *maxMs) if *vadModel != "" { s, err := newSileroVAD(*vadModel, *onnxLib) if err != nil { log.Printf("mavwaked: silero unavailable, energy threshold unchanged: %v", err) } else { defer s.Close() vad.UseSilero(s, *vadThreshold) log.Printf("mavwaked: silero-vad from %s, threshold %.2f", *vadModel, *vadThreshold) } } // Audio source. var src io.ReadCloser if *testFile != "" { f, err := os.Open(*testFile) if err != nil { return fmt.Errorf("open test file: %w", err) } defer f.Close() src = f log.Printf("mavwaked: reading from test file %s", *testFile) } else { arec := exec.CommandContext(ctx, "arecord", "-D", *device, "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw", ) arec.Stderr = os.Stderr pipe, err := arec.StdoutPipe() if err != nil { return fmt.Errorf("arecord stdout pipe: %w", err) } if err := arec.Start(); err != nil { return fmt.Errorf("start arecord: %w", err) } src = pipe log.Printf("mavwaked: listening on device %s → %s", *device, *addr) // Ensure arecord is killed when we exit. go func() { <-ctx.Done() if p := arec.Process; p != nil { _ = p.Signal(syscall.SIGTERM) // Give it a moment, then force-kill. go func() { time.Sleep(2 * time.Second) _ = p.Kill() }() } }() } defer src.Close() var barge bargeInConfig if *bargeIn { barge = bargeInConfig{RMS: float64(*bargeRMS) / 10000.0, Frames: *bargeFrames} if barge.Enabled() { log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames) } else { // The log used to say "barge-in on (rms 0.0000 x 5)" here and then // nothing happened, because Enabled needs a positive threshold. log.Printf("mavwaked: -barge-in was passed but rms %.4f x %d frames disables it; "+ "both must be above zero, so barge-in is OFF", barge.RMS, barge.Frames) } } 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. if err := vc.Connect(ctx); err != nil { log.Printf("mavwaked: voice server not reachable yet, retrying in background: %v", err) } go runNudgeReceiver(ctx, vc, sess) return captureLoop(ctx, src, sess) } // captureLoop reads PCM from src and hands whole frames to the session. // Returns when ctx is done or src is exhausted. func captureLoop(ctx context.Context, src io.Reader, sess *session) error { br := bufio.NewReaderSize(src, defaultReadSize) frameBytes := sess.vad.FrameSamples() * 2 // 480 samples × 2 bytes = 960 bytes per 30ms log.Printf("mavwaked: capture loop starting (frame=%d bytes, %dms)", frameBytes, defaultFrameMs) var partial []byte for { select { case <-ctx.Done(): log.Printf("mavwaked: context done, stopping capture") return ctx.Err() default: } // Read exactly one frame (or wait for more data). buf := make([]byte, frameBytes) n, err := io.ReadFull(br, buf) if err != nil { if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { if n > 0 { // Flush partial frame. partial = append(partial, buf[:n]...) if len(partial) >= frameBytes { if err := sess.feed(ctx, partial[:frameBytes]); err != nil { log.Printf("mavwaked: process frame: %v", err) } partial = partial[frameBytes:] } } return nil } return fmt.Errorf("read audio: %w", err) } // Include any leftover from previous partial read. full := buf if len(partial) > 0 { full = append(partial, buf...) partial = nil } if err := sess.feed(ctx, full); err != nil { log.Printf("mavwaked: process frame: %v", err) } } } // voiceSender is the production utteranceSender: one PushToTalk round-trip // over the voice wire. SurfaceVoice (not the default SurfacePCClient that // c.PushToTalk uses) caps everything at L0, which is what makes an accidental // VAD trigger safe. type voiceSender struct{ vc *voice.Client } func (s *voiceSender) Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) { var resp voice.PushToTalkResp err := s.vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{ Audio: utt, Lang: lang, Surface: voice.SurfaceVoice, }, &resp) if err != nil { return audio.Audio{}, fmt.Errorf("push-to-talk: %w", err) } log.Printf("mavwaked: reply: %q (%.2fs audio)", resp.ReplyText, resp.ReplyAudio.Duration()) if len(resp.RoutedChannels) > 0 { log.Printf("mavwaked: also routed to: %v", resp.RoutedChannels) } return resp.ReplyAudio, nil }