Files
Maven/cmd/mavwaked/main.go
T
kami e57647c9a3 3.1 always-on listening: mavwaked with energy VAD + SurfaceVoice
New cmd/mavwaked — always-on voice listening client that:
- Captures PCM from arecord subprocess (16kHz mono int16)
- Runs energy-based VAD in 30ms windows (RMS threshold, adaptive floor)
- Buffers utterances (300ms min speech, 800ms silence end, 10s max)
- Sends complete utterances as PushToTalk with Surface=SurfaceVoice (L0)
- Plays reply audio through aplay subprocess
- No new CGo/onnxruntime deps — pure Go
- 10 VAD tests with -race (speech detect, silence, max duration, reset, adaptive floor)
- Makefile build-waked target + Dockerfile integration + alsa-utils runtime dep
2026-07-06 14:09:34 +04:00

272 lines
7.6 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.
//
// usage:
// mavwaked # default ALSA device, 127.0.0.1:9100
// 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)")
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()
return captureLoop(ctx, src, vad, vc, *lang)
}
// captureLoop reads PCM from src, runs VAD, and sends complete utterances to
// the voice server. Returns when ctx is done or src is exhausted.
func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client, lang string) error {
br := bufio.NewReaderSize(src, defaultReadSize)
frameBytes := 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 := processFrame(partial[:frameBytes], vad, vc, lang); 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 := processFrame(full, vad, vc, lang); err != nil {
log.Printf("mavwaked: process frame: %v", err)
}
}
}
// processFrame feeds one 30ms PCM frame to the VAD and sends any completed
// utterance to the voice server.
func processFrame(frame []byte, vad *VAD, vc *voice.Client, lang string) error {
samples := PCMToI16(frame)
utt, state := vad.Feed(samples)
if state == StateSpeech {
// Speech is in progress; nothing to send yet.
return nil
}
if utt.Bytes == nil {
// Still in silence, or short speech that didn't trigger.
return nil
}
// We have a complete utterance — send it to the voice server.
return sendUtterance(context.Background(), utt, vc, lang)
}
// sendUtterance sends audio to the voice server and plays the reply.
func sendUtterance(ctx context.Context, utt audio.Audio, vc *voice.Client, lang string) error {
dur := utt.Duration()
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...",
dur, len(utt.Bytes))
// Use SendRequest directly so we can set SurfaceVoice instead of the
// default SurfacePCClient that c.PushToTalk uses.
var resp voice.PushToTalkResp
err := vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{
Audio: utt,
Lang: lang,
Surface: voice.SurfaceVoice,
}, &resp)
if err != nil {
return fmt.Errorf("push-to-talk: %w", err)
}
log.Printf("mavwaked: reply: %q (%.2fs audio)", resp.ReplyText, resp.ReplyAudio.Duration())
// Play the reply audio.
if len(resp.ReplyAudio.Bytes) > 0 {
go playAudio(resp.ReplyAudio)
} else {
log.Printf("mavwaked: empty reply audio (text only)")
}
if len(resp.RoutedChannels) > 0 {
log.Printf("mavwaked: also routed to: %v", resp.RoutedChannels)
}
return nil
}
// playAudio pipes PCM audio to aplay(1) for playback. Runs in a goroutine.
func playAudio(a audio.Audio) {
// Build WAV header for aplay (or pipe raw PCM with the right format flags).
cmd := exec.Command("aplay",
"-f", "S16_LE",
"-r", fmt.Sprintf("%d", a.Format.SampleRate),
"-c", fmt.Sprintf("%d", a.Format.Channels),
"-t", "raw",
)
stdin, err := cmd.StdinPipe()
if err != nil {
log.Printf("mavwaked: aplay stdin pipe: %v", err)
return
}
if err := cmd.Start(); err != nil {
log.Printf("mavwaked: start aplay: %v", err)
return
}
// Write audio to aplay's stdin.
if _, err := stdin.Write(a.Bytes); err != nil {
log.Printf("mavwaked: write to aplay: %v", err)
}
_ = stdin.Close()
// Wait for playback to finish (with a timeout).
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case err := <-done:
if err != nil {
log.Printf("mavwaked: aplay: %v", err)
}
case <-time.After(30 * time.Second):
log.Printf("mavwaked: aplay timeout, killing")
_ = cmd.Process.Kill()
<-done
}
}