// 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). // // No wake-word model yet (MVP uses voice-activity-only trigger). The // SurfaceVoice auth layer caps all commands at L0 (no destructive acts), // making accidental triggers safe by design. A proper wake-word engine // (openWakeWord / Silero VAD ONNX) is the planned upgrade — the VAD shape // (30ms frames, 16kHz PCM) matches silero-vad's input interface exactly, so // swapping energy-threshold for ONNX-inference is a local change in vad.go. // // 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 well // above the speaker's leak level cuts playback so he can talk over her. It is // off by default because the threshold is room-specific; see playback.go. // // 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") 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. vad := NewVAD(*minRMS, *speechMs, *silenceMs, *maxMs) // 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} log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", 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 }