d3fcc1dfdb
Nothing reads the microphone while Send is in flight, so the audio piles up in arecord's pipe and arrives in a burst the moment dispatch returns. A round-trip is p50 2.7s through the LLM router, which is about 90 frames of room, of him finishing his sentence, of the television. The old code reset the VAD on the reply path only, and for a reason that was not true: the comment said the VAD had been accumulating during the round-trip, when its state is exactly what Feed left it as. The two paths with no reset are the ones that mattered, because neither starts playback and so neither is covered by the half-duplex gate. A text-only turn fed the whole backlog into the VAD, and a Send error did the same on every failed turn, so a dead socket drove a retry loop off backlog alone. The backlog was scored for barge-in too. Five frames delivered in microseconds cut her off with audio recorded before she started speaking, which is the opposite of what the five-frame guard is for. Both are fixed by the same mechanism: measure the wall time the round-trip took, convert it to frames, and discard that many before anything looks at them. Barge-in also threw away the 150ms that proved he was talking. The VAD started from the next frame, so the first word of a short interruption was clipped before whisper saw it. Those frames are kept in a small ring and replayed after the reset. A stuck aplay was worse than before this feature existed. Playing() gates all capture, so a wedged child made her deaf rather than silent, for the full 30s ceiling inherited from the fire-and-forget version. The mute window is bounded by the reply's own duration plus a margin now. Three smaller ones. "-barge-in -barge-in-rms 0" logged "barge-in on" and then did nothing. The sent counter incremented before the error check, so failed round-trips counted as shipped. And the threshold the operator has to guess is now reported: mavwaked logs the mean energy of the frames it suppressed while speaking, so he can set it from data. Found in review of #76.
224 lines
7.4 KiB
Go
224 lines
7.4 KiB
Go
// 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).
|
||
//
|
||
// No wake-word model yet (MVP uses voice-activity-only trigger). The
|
||
// SurfaceVoice auth layer caps all commands at L0 (no destructive acts),
|
||
// making accidental triggers safe by design. A proper wake-word engine
|
||
// (openWakeWord / Silero VAD ONNX) is the planned upgrade — the VAD shape
|
||
// (30ms frames, 16kHz PCM) matches silero-vad's input interface exactly, so
|
||
// swapping energy-threshold for ONNX-inference is a local change in vad.go.
|
||
//
|
||
// 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")
|
||
flag.CommandLine.Parse(args)
|
||
|
||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||
defer stop()
|
||
|
||
// Voice client — reused across utterances; SendRequest reconnects on error.
|
||
vc := voice.Dial(*addr)
|
||
defer vc.Close()
|
||
|
||
// VAD engine.
|
||
vad := NewVAD(*minRMS, *speechMs, *silenceMs, *maxMs)
|
||
|
||
// 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)
|
||
|
||
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
|
||
}
|