67decc42f0
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.
154 lines
5.0 KiB
Go
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) }
|