Files
Maven/cmd/mavenclient/main.go
T
kami e0d0244fa9 Fold SPEC/maven/ROADMAP into DESIGN.md and drop the stale session logs
15 root markdown files, ~4,900 lines against ~33,000 lines of Go, with at least
three pairs contradicting each other. When five documents describe the
architecture, the code becomes the only trustworthy one — which defeats the
point of having them. That drift is why the resident-model question had four
incompatible answers.

SPEC.md, maven.md and ROADMAP.md are deduped into DESIGN.md rather than
concatenated, with a "Superseded" section carrying eight retired decisions and
what replaced each: classifier-owns-the-route (the cascade is still the live
path, but as a stopgap, not a design to extend), faster-whisper/vosk/silero,
the small-model phrasing claim, sqlcipher, the Kotlin/Spring sketches,
obsidian->chroma, script deployment, and FloorEnrollment. Superseded material
is kept and marked rather than deleted, so it cannot read as current.

SESSION-05/06-07-2026.md and PLANS.md are removed outright — git history holds
them, and both were verified tracked before deletion.

Go doc comments citing the deleted files are repointed to the equivalent
DESIGN.md sections. Several asserted designs that were already retired, so the
claims are corrected and not just relinked: stt.go named faster-whisper as
production (it is whisper.cpp), tts.go named silero (it is piper), intent.go
still described the classifier as owning the route, and stale vosk/chroma
vocabulary is replaced. ECOSYSTEM-SPEC.md references are deliberately
untouched — that is a different document, and a naive grep for SPEC.md matches
it.

Root markdown drops from 4,880 to ~3,700 lines. The review's ~1,500 target is
not reachable while keeping the files it also said to keep — those alone are
2,553 lines — so trimming further needs a separate decision on
MAVEN_ECOSYSTEM_ARCHITECTURE.md and PROGRESS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
2026-07-30 23:39:56 +04:00

164 lines
5.2 KiB
Go

// 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