Files
Maven/cmd/mavwaked/main.go
T
kami fed33a4e16 Stop mavwaked from hearing itself, and add barge-in (#287)
Playback was `go playAudio(reply)` — fire and forget, nobody holding the
process handle. Two audible consequences fell out of that.

She answered herself. The capture loop kept feeding the VAD while the
speaker was running, so her own reply came back in through the mic,
tripped the VAD, and was shipped to the daemon as a fresh command. There
is no acoustic echo canceller in this pipeline, so the fix is
half-duplex: while she is speaking, the capture side is muted. That part
is unconditional — it repairs a defect, it is not a new capability.

And talking over her did nothing, because there was no handle to cancel.
-barge-in now cuts playback when sustained energy clears a room-tuned
threshold (-barge-in-rms, default 0.12 normalised, over -barge-in-frames
consecutive frames, default 5). It is off by default: without an echo
canceller the only way to tell "he is talking over her" from "the mic is
hearing her" is that he is much louder, and how much louder depends on
where the mic sits.

The frame decision moved out of main.go into session.feed, behind a
player and an utteranceSender interface, so all of it is testable with
no mic, no speaker and no daemon. Nine tests cover the self-hearing
case, the off-by-default case, the consecutive-frame requirement,
speaker-leak-level audio not triggering, capturing the interrupting
utterance after a cut, and failed round-trips not starting playback.

The other seven items on #287 (partial STT, per-segment retry, mic
profiles, noise-floor calibration, short-response-while-speaking) are
untouched and stay on the task.
2026-08-01 05:36:13 +04:00

213 lines
6.9 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).
//
// 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 well
// above the speaker's leak level cuts playback so he can talk over her. It is
// off by default because the threshold is room-specific; see playback.go.
//
// 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}
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", 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
}