// 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. // // 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. // // 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) ) 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") flag.CommandLine.Parse(args) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer stop() // Voice client — reused across utterances; SendRequest reconnects on error. 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) 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 }