a99932b427
silero-vad replaces the energy threshold when -vad-model points at it. Everything after the speech decision is the same state machine: the speech hold, the silence hold, the length cap and the utterance buffer. The model window is 512 samples and the capture frame is 480, so silero.go re-chunks across frames. main.go claimed the two matched, which was true of silero v4. Stage two, the wake word, is not here. It needs a Russian keyword model that does not exist yet.
244 lines
8.5 KiB
Go
244 lines
8.5 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).
|
||
//
|
||
// 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.
|
||
//
|
||
// 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 — reused across utterances; SendRequest reconnects on error.
|
||
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)
|
||
|
||
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
|
||
}
|