// 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. // // usage: // mavwaked # default ALSA device, 127.0.0.1:9100 // 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)") 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() return captureLoop(ctx, src, vad, vc, *lang) } // captureLoop reads PCM from src, runs VAD, and sends complete utterances to // the voice server. Returns when ctx is done or src is exhausted. func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client, lang string) error { br := bufio.NewReaderSize(src, defaultReadSize) frameBytes := 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 := processFrame(partial[:frameBytes], vad, vc, lang); 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 := processFrame(full, vad, vc, lang); err != nil { log.Printf("mavwaked: process frame: %v", err) } } } // processFrame feeds one 30ms PCM frame to the VAD and sends any completed // utterance to the voice server. func processFrame(frame []byte, vad *VAD, vc *voice.Client, lang string) error { samples := PCMToI16(frame) utt, state := vad.Feed(samples) if state == StateSpeech { // Speech is in progress; nothing to send yet. return nil } if utt.Bytes == nil { // Still in silence, or short speech that didn't trigger. return nil } // We have a complete utterance — send it to the voice server. return sendUtterance(context.Background(), utt, vc, lang) } // sendUtterance sends audio to the voice server and plays the reply. func sendUtterance(ctx context.Context, utt audio.Audio, vc *voice.Client, lang string) error { dur := utt.Duration() log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...", dur, len(utt.Bytes)) // Use SendRequest directly so we can set SurfaceVoice instead of the // default SurfacePCClient that c.PushToTalk uses. var resp voice.PushToTalkResp err := vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{ Audio: utt, Lang: lang, Surface: voice.SurfaceVoice, }, &resp) if err != nil { return fmt.Errorf("push-to-talk: %w", err) } log.Printf("mavwaked: reply: %q (%.2fs audio)", resp.ReplyText, resp.ReplyAudio.Duration()) // Play the reply audio. if len(resp.ReplyAudio.Bytes) > 0 { go playAudio(resp.ReplyAudio) } else { log.Printf("mavwaked: empty reply audio (text only)") } if len(resp.RoutedChannels) > 0 { log.Printf("mavwaked: also routed to: %v", resp.RoutedChannels) } return nil } // playAudio pipes PCM audio to aplay(1) for playback. Runs in a goroutine. func playAudio(a audio.Audio) { // Build WAV header for aplay (or pipe raw PCM with the right format flags). cmd := exec.Command("aplay", "-f", "S16_LE", "-r", fmt.Sprintf("%d", a.Format.SampleRate), "-c", fmt.Sprintf("%d", a.Format.Channels), "-t", "raw", ) stdin, err := cmd.StdinPipe() if err != nil { log.Printf("mavwaked: aplay stdin pipe: %v", err) return } if err := cmd.Start(); err != nil { log.Printf("mavwaked: start aplay: %v", err) return } // Write audio to aplay's stdin. if _, err := stdin.Write(a.Bytes); err != nil { log.Printf("mavwaked: write to aplay: %v", err) } _ = stdin.Close() // Wait for playback to finish (with a timeout). done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case err := <-done: if err != nil { log.Printf("mavwaked: aplay: %v", err) } case <-time.After(30 * time.Second): log.Printf("mavwaked: aplay timeout, killing") _ = cmd.Process.Kill() <-done } }