// Package main is mavenclient โ€” maven's reference client. // // Per DESIGN.md ยง Voice pipeline (STT / TTS): capture lives on the client; // the server transcribes + synthesises on demand. The PC client runs the // wake-word / VAD gate (cmd/mavwaked) and ships ONE clean audio blob per // utterance on activation. The server never owns a mic. // // This binary is the floor reference: there is NO wake-word / VAD here // (production PC client libraries); it ships ONE wav file from disk per // invocation, posts it via voice.PushToTalk, and writes the reply audio to // a wav file (or stdout). The point is to round-trip the daemon's reactive // path end-to-end with the real TCP wire, not to be the production client. // // usage: // mavenclient -in audio.wav -out reply.wav // mavenclient -in audio.wav # reply written to ./reply.wav // mavenclient -addr 127.0.0.1:9100 # default; production = wg-tunnel addr // // -listen mode keeps the conn open and writes incoming Push frames // (proactive nudge audio) to disk sequentially โ€” exercise proactive // delivery end-to-end. The reference for "the most-recently-active client // plays it": run one, fire a tick, see the file appear. // // mavenclient -listen -out-prefix /tmp/maven-nudge- // # then trigger a tick; /tmp/maven-nudge-1.wav, -2.wav ... appear package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "log" "net" "os" "os/signal" "path/filepath" "strconv" "syscall" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/voice" ) func main() { if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, "mavenclient:", err) os.Exit(1) } } func run(args []string) error { addr := flag.String("addr", "127.0.0.1:9100", "mavend voice address (TCP; inside wg tunnel in prod)") inPath := flag.String("in", "", "input WAV (canonical 16k mono int16); required for one-shot") outPath := flag.String("out", "", "output WAV (default ./reply.wav or nudge-N.wav for -listen)") lang := flag.String("lang", "ru", "BCP-47 lang hint for stt ('ru' | 'en' | 'mixed')") listen := flag.Bool("listen", false, "stay open + write incoming Push frames to disk") outPrefix := flag.String("out-prefix", "", "-listen: prefix for received-nudge wav files (default ./nudge-)") flag.CommandLine.Parse(args) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer stop() c := voice.Dial(*addr) defer c.Close() if *listen { return runListen(ctx, c, *outPrefix) } if *inPath == "" { flag.Usage() return errors.New("-in is required for one-shot mode (or use -listen)") } return runOneShot(ctx, c, *inPath, *outPath, *lang) } func runOneShot(ctx context.Context, c *voice.Client, inPath, outPath, lang string) error { wav, err := os.ReadFile(inPath) if err != nil { return fmt.Errorf("read input: %w", err) } format, pcm, err := audio.PCMFromWAV(wav) if err != nil { return err } log.Printf("mavenclient: loaded %s (%.2fs, %+v)", inPath, float64(len(pcm))/float64(format.SampleRate)/float64(format.SampleBits/8), format) resp, err := c.PushToTalk(ctx, audio.Audio{Format: format, Bytes: pcm}, lang) if err != nil { return fmt.Errorf("voice round-trip: %w", err) } if outPath == "" { outPath = "reply.wav" } if err := writeWAV(outPath, resp.ReplyAudio); err != nil { return err } log.Printf("mavenclient: reply (%.2fs, %q) -> %s", resp.ReplyAudio.Duration(), resp.ReplyText, outPath) if len(resp.RoutedChannels) > 0 { log.Printf("mavenclient: also routed to: %v", resp.RoutedChannels) } return nil } func runListen(ctx context.Context, c *voice.Client, prefix string) error { if prefix == "" { prefix = "nudge-" } h := &filePushHandler{prefix: prefix, counter: 0} log.Printf("mavenclient: listening for server pushes; writing to %s*.wav", prefix) return c.RunPushReceiver(ctx, h) } type filePushHandler struct { prefix string counter int } func (h *filePushHandler) OnPush(p voice.Push) { switch p.Kind { case voice.PushKindAudioNudge: var ap voice.AudioNudgePush if err := jsonUnmarshal(p.Params, &ap); err != nil { log.Printf("mavenclient: bad audio_nudge push: %v", err) return } h.counter++ name := fmt.Sprintf("%s%d.wav", h.prefix, h.counter) if err := writeWAV(name, ap.Audio); err != nil { log.Printf("mavenclient: write %s: %v", name, err) return } log.Printf("mavenclient: nudge %q (sev %d) -> %s (%.2fs) %q", ap.RuleName, ap.Severity, name, ap.Audio.Duration(), ap.Text) case voice.PushKindPing: // liveness; ignore. default: log.Printf("mavenclient: unknown push kind %q", p.Kind) } } func writeWAV(path string, a audio.Audio) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); filepath.Dir(path) != "" && err != nil { return err } wav, err := audio.WAVFromPCM(a.Format, a.Bytes) if err != nil { return err } return os.WriteFile(path, wav, 0o644) } // jsonUnmarshal โ€” kept local rather than pulling encoding/json into main.go // top-level space. func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) } // keep strconv + io + net + time alive for future duration/size helpers. var _ = strconv.Atoi var _ io.Reader = (io.Reader)(nil) var _ = net.IPv4 var _ = time.Second