Files
Maven/cmd/mavwaked/main.go
T
claude 9c7bafd5b1 Let mavwaked hear the nudges it was already being sent (V-671)
It wired no PushHandler, and SendRequest discards a push frame when there
is none. That was not a missing feature but a silent one. mavend routes a
nudge to the voice session that spoke most recently, so once mavwaked had
spoken once it WAS that session. PushToMostRecent succeeded, the
dispatcher counted the nudge delivered and stopped rerouting to the away
channels, and mavwaked threw the audio away. He heard nothing, anywhere.

It now connects at startup rather than at the first utterance, because
the dispatcher has to tell "he is not at the machine" from "he is, and
she has nothing to say". The receiver redials on its own clock, since
mavend restarts on every deploy.

A nudge is queued, not played where it arrives. The capture loop picks it
up on the next frame, so the half-duplex gate and barge-in cover it the
way they cover a reply. It resets the VAD first: playback is about to
suppress every frame, and a half-heard sentence would otherwise splice
onto whatever he says next. A nudge arriving while one still waits
replaces it, which is the contract internal/voice states for PushHandler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 13:45:04 +04:00

258 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package main — mavwaked: always-on voice listening client.
//
// Spawns arecord(1) as a subprocess, reads 16kHz mono PCM from its stdout,
// runs an energy-based VAD over 30ms windows, and when a complete utterance
// is detected sends it as a PushToTalk frame to the voice server. The reply
// audio is played back through aplay(1).
//
// Voice activity is silero-vad when -vad-model points at the graph, and an
// energy threshold when it does not. Silero declines noise the threshold
// accepts: 0 frames against 68 to 99 on the four fixtures, measured in
// docs/evals/2026-08-09-silero-vad.md. Note that the model window is 512
// samples and the capture frame is 480, so silero.go re-chunks. This comment
// used to say the two matched, which was true of silero v4.
//
// There is still no wake-word model, so anything spoken near the microphone
// becomes a turn (V-487 stage two). The SurfaceVoice auth layer caps all
// commands at L0 (no destructive acts), which is what makes an accidental
// trigger safe rather than expensive.
//
// The conn carries both directions. mavwaked sends utterances and receives
// proactive nudges on it, and it is opened at startup rather than at the first
// utterance, because mavend registers a voice session on accept. See nudge.go
// for why a nudge that is not heard is worse than one that is not delivered.
//
// While a reply is playing the capture side is muted (half-duplex): without
// it, Maven's own voice comes back in through the mic and she answers
// herself. -barge-in punches one hole in that gate — sustained energy above
// -barge-in-rms cuts playback so he can talk over her. It is off by default
// because the threshold is room-specific; see playback.go. The threshold is a
// raw frame RMS and has no reference to what the speaker actually leaks, so
// the daemon logs the mean energy of the frames it suppressed while speaking.
// Set -barge-in-rms from those numbers rather than by guessing.
//
// usage:
// mavwaked # default ALSA device, 127.0.0.1:9100
// mavwaked -barge-in # let him interrupt her mid-reply
// mavwaked -device hw:1,0 -addr 10.42.0.1:9100
// mavwaked -test file.wav # read from file, no arecord
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"syscall"
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/voice"
)
// Defaults.
const (
defaultDevice = "default"
defaultAddr = "127.0.0.1:9100"
defaultLang = "ru"
defaultReadSize = 4096 // max PCM bytes per read from arecord (fits multiple frames)
)
func main() {
if err := run(os.Args[1:]); err != nil && !errors.Is(err, context.Canceled) {
fmt.Fprintln(os.Stderr, "mavwaked:", err)
os.Exit(1)
}
}
func run(args []string) error {
device := flag.String("device", defaultDevice, "ALSA capture device")
addr := flag.String("addr", defaultAddr, "voice server TCP address")
lang := flag.String("lang", defaultLang, "STT language hint (ru/en/mixed)")
minRMS := flag.Int("min-rms", 100, "RMS floor ×10000 (e.g. 100 = 0.01)")
speechMs := flag.Int("speech-ms", defaultSpeechMs, "min speech ms before trigger")
silenceMs := flag.Int("silence-ms", defaultSilenceMs, "silence ms to end utterance")
maxMs := flag.Int("max-ms", defaultMaxMs, "max utterance ms")
testFile := flag.String("test", "", "read PCM from file instead of arecord (testing only)")
bargeIn := flag.Bool("barge-in", false, "cut Maven off when he talks over her (needs a room-tuned -barge-in-rms)")
bargeRMS := flag.Int("barge-in-rms", defaultBargeRMS, "RMS x10000 a frame must clear to count as barge-in")
bargeFrames := flag.Int("barge-in-frames", defaultBargeFrames, "consecutive frames over -barge-in-rms before playback is cut")
vadModel := flag.String("vad-model", "", "silero-vad onnx file; empty runs the energy threshold instead")
vadThreshold := flag.Float64("vad-threshold", defaultSileroThreshold, "speech probability a frame must clear")
onnxLib := flag.String("onnx-lib", os.Getenv("MAVEN_ONNX_LIB"), "libonnxruntime.so, needed with -vad-model")
flag.CommandLine.Parse(args)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer stop()
// Voice client — one conn carrying both directions. SendRequest reconnects
// on error, and the push receiver redials on its own clock.
vc := voice.Dial(*addr)
defer vc.Close()
// VAD engine. A model that will not load is logged and not fatal: the
// energy threshold is worse, and it is a great deal better than a
// listening client that refuses to start.
vad := NewVAD(*minRMS, *speechMs, *silenceMs, *maxMs)
if *vadModel != "" {
s, err := newSileroVAD(*vadModel, *onnxLib)
if err != nil {
log.Printf("mavwaked: silero unavailable, energy threshold unchanged: %v", err)
} else {
defer s.Close()
vad.UseSilero(s, *vadThreshold)
log.Printf("mavwaked: silero-vad from %s, threshold %.2f", *vadModel, *vadThreshold)
}
}
// Audio source.
var src io.ReadCloser
if *testFile != "" {
f, err := os.Open(*testFile)
if err != nil {
return fmt.Errorf("open test file: %w", err)
}
defer f.Close()
src = f
log.Printf("mavwaked: reading from test file %s", *testFile)
} else {
arec := exec.CommandContext(ctx, "arecord",
"-D", *device,
"-f", "S16_LE",
"-r", "16000",
"-c", "1",
"-t", "raw",
)
arec.Stderr = os.Stderr
pipe, err := arec.StdoutPipe()
if err != nil {
return fmt.Errorf("arecord stdout pipe: %w", err)
}
if err := arec.Start(); err != nil {
return fmt.Errorf("start arecord: %w", err)
}
src = pipe
log.Printf("mavwaked: listening on device %s → %s", *device, *addr)
// Ensure arecord is killed when we exit.
go func() {
<-ctx.Done()
if p := arec.Process; p != nil {
_ = p.Signal(syscall.SIGTERM)
// Give it a moment, then force-kill.
go func() {
time.Sleep(2 * time.Second)
_ = p.Kill()
}()
}
}()
}
defer src.Close()
var barge bargeInConfig
if *bargeIn {
barge = bargeInConfig{RMS: float64(*bargeRMS) / 10000.0, Frames: *bargeFrames}
if barge.Enabled() {
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
} else {
// The log used to say "barge-in on (rms 0.0000 x 5)" here and then
// nothing happened, because Enabled needs a positive threshold.
log.Printf("mavwaked: -barge-in was passed but rms %.4f x %d frames disables it; "+
"both must be above zero, so barge-in is OFF",
barge.RMS, barge.Frames)
}
}
sess := newSession(vad, newAplayPlayer(), &voiceSender{vc: vc}, *lang, barge)
// Listen for nudges alongside capture. Connect eagerly so mavend has a
// voice session before he has said anything: without one, a nudge routed
// to voice finds nobody home and goes to the away channels instead.
if err := vc.Connect(ctx); err != nil {
log.Printf("mavwaked: voice server not reachable yet, retrying in background: %v", err)
}
go runNudgeReceiver(ctx, vc, sess)
return captureLoop(ctx, src, sess)
}
// captureLoop reads PCM from src and hands whole frames to the session.
// Returns when ctx is done or src is exhausted.
func captureLoop(ctx context.Context, src io.Reader, sess *session) error {
br := bufio.NewReaderSize(src, defaultReadSize)
frameBytes := sess.vad.FrameSamples() * 2 // 480 samples × 2 bytes = 960 bytes per 30ms
log.Printf("mavwaked: capture loop starting (frame=%d bytes, %dms)",
frameBytes, defaultFrameMs)
var partial []byte
for {
select {
case <-ctx.Done():
log.Printf("mavwaked: context done, stopping capture")
return ctx.Err()
default:
}
// Read exactly one frame (or wait for more data).
buf := make([]byte, frameBytes)
n, err := io.ReadFull(br, buf)
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
if n > 0 {
// Flush partial frame.
partial = append(partial, buf[:n]...)
if len(partial) >= frameBytes {
if err := sess.feed(ctx, partial[:frameBytes]); err != nil {
log.Printf("mavwaked: process frame: %v", err)
}
partial = partial[frameBytes:]
}
}
return nil
}
return fmt.Errorf("read audio: %w", err)
}
// Include any leftover from previous partial read.
full := buf
if len(partial) > 0 {
full = append(partial, buf...)
partial = nil
}
if err := sess.feed(ctx, full); err != nil {
log.Printf("mavwaked: process frame: %v", err)
}
}
}
// voiceSender is the production utteranceSender: one PushToTalk round-trip
// over the voice wire. SurfaceVoice (not the default SurfacePCClient that
// c.PushToTalk uses) caps everything at L0, which is what makes an accidental
// VAD trigger safe.
type voiceSender struct{ vc *voice.Client }
func (s *voiceSender) Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) {
var resp voice.PushToTalkResp
err := s.vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{
Audio: utt,
Lang: lang,
Surface: voice.SurfaceVoice,
}, &resp)
if err != nil {
return audio.Audio{}, fmt.Errorf("push-to-talk: %w", err)
}
log.Printf("mavwaked: reply: %q (%.2fs audio)", resp.ReplyText, resp.ReplyAudio.Duration())
if len(resp.RoutedChannels) > 0 {
log.Printf("mavwaked: also routed to: %v", resp.RoutedChannels)
}
return resp.ReplyAudio, nil
}