e0d0244fa9
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
164 lines
5.2 KiB
Go
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
|