Files
claude 67decc42f0 sweep: name voice-daemon magic numbers, drop dead keep-alive vars (V-581)
mavsttd/whisper_handler.go: name the no_speech_prob confidence-zeroing
floor (0.9) and the whisper thread count (4), both previously bare
literals with no reason attached.

mavttsd/piper_handler.go: name piper's render rate (22050) and the
canonical wire rate (16000) used by the resampler, instead of repeating
the two numbers inline four times.

mavenclient/main.go: remove the strconv/io/net/time imports and their
`var _ = ...` keep-alive lines — dead weight with no caller, not future
scaffolding.

Behaviour-preserving; no test changed. go test -race ./internal/...
./cmd/... is green.
2026-08-06 03:00:21 +04:00

154 lines
5.0 KiB
Go

// Package main is mavenclient — maven's reference client.
//
// Per docs/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"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"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) }