e57647c9a3
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
272 lines
7.6 KiB
Go
272 lines
7.6 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.
|
||
//
|
||
// 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
|
||
}
|
||
}
|