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
This commit is contained in:
kami
2026-07-06 14:09:34 +04:00
parent 05236ad480
commit e57647c9a3
6 changed files with 835 additions and 5 deletions
+1
View File
@@ -13,6 +13,7 @@ certs
/mavpoll
/mavcaldav
/mavenclient
/mavwaked
# heavy deps we don't need in context. We keep only the prebuilt runtime libs
# (deps/lib, deps/piper) and the headers the CGO build needs.
+3 -2
View File
@@ -44,14 +44,15 @@ RUN go build -o /out/mavend ./cmd/mavend && \
go build -o /out/mavttsd ./cmd/mavttsd && \
go build -o /out/mavweb ./cmd/mavweb && \
go build -o /out/mavpoll ./cmd/mavpoll && \
go build -o /out/mavcaldav ./cmd/mavcaldav
go build -o /out/mavcaldav ./cmd/mavcaldav && \
go build -o /out/mavwaked ./cmd/mavwaked
FROM debian:trixie-slim AS runtime
# tzdata so the TZ env (set in compose) resolves — otherwise Go can't load the
# zone and time.Now() stays UTC, and mavend answers clock/date queries and
# evaluates quiet-hours in UTC.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libvulkan1 mesa-vulkan-drivers libgomp1 tzdata && \
ca-certificates libvulkan1 mesa-vulkan-drivers libgomp1 tzdata alsa-utils && \
rm -rf /var/lib/apt/lists/*
# runtime native libs: whisper/ggml (incl. vulkan) are real files in deps/lib.
+6 -3
View File
@@ -8,11 +8,11 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: all build build-stt build-tts build-daemon build-client build-web build-poll build-caldav clean test run-stt run-tts run-web download-embedder
.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test run-stt run-tts run-web download-embedder
all: build
build: build-stt build-tts build-daemon build-client build-web build-poll build-caldav
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav
build-stt:
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
@@ -30,6 +30,9 @@ build-client:
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
$(GO) build $(GOFLAGS) -o mavenclient ./cmd/mavenclient/
build-waked:
$(GO) build $(GOFLAGS) -o mavwaked ./cmd/mavwaked/
build-web:
$(GO) build $(GOFLAGS) -o mavweb ./cmd/mavweb/
@@ -97,4 +100,4 @@ download-embedder:
@echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/'
clean:
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked
+271
View File
@@ -0,0 +1,271 @@
// 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
}
}
+228
View File
@@ -0,0 +1,228 @@
// Package main — mavwaked: always-on voice listening client.
//
// VAD state-machine: reads PCM 16k mono int16 windows, tracks speech↔silence
// transitions, and yields complete utterances as audio.Audio.
package main
import (
"math"
"time"
"github.com/kami/maven/internal/audio"
)
// Defaults — same energy thresholds as mavsttd's gateReason (proven in
// silence_test.go) plus our own silence-hold timer.
const (
defaultSampleRate = 16000
defaultChannels = 1
defaultBits = 16
defaultFrameMs = 30 // 480 samples — silero-vad window
defaultSpeechMs = 300 // min speech before accepting utterance
defaultSilenceMs = 800 // silence hold before declaring end-of-utterance
defaultMaxMs = 10000 // cap single utterance at 10s
defaultMinRMS = 0.01 // RMS floor (same as mavsttd)
)
// frameSamples — samples per 30ms frame at 16kHz.
const frameSamples = defaultSampleRate * defaultFrameMs / 1000 // = 480
// SpeechState tracks whether the capture pipeline is listening or inside an
// utterance. Public so the caller can read the current state for logging.
type SpeechState int
const (
StateSilence SpeechState = iota
StateSpeech
)
// VAD is the energy-based voice activity detector.
//
// It consumes 30ms PCM frames, tracks the RMS energy floor adaptively, and
// signals when an utterance starts and ends. Same energy approach as
// mavsttd's gateReason — proven on real room audio in production.
type VAD struct {
minRMS float64
speechMs int // minimum ms of consecutive speech before triggering
silenceMs int // ms of consecutive silence before ending utterance
maxMs int // absolute cap on utterance length
// state
state SpeechState
speechFrames int // consecutive speech frames during silence
silenceFrames int // consecutive silence frames during speech
totalFrames int // frames since speech started (capped at max)
utterance []byte // raw PCM buffer for current utterance
// adaptive noise floor — tracks recent silence RMS so the threshold
// follows the room's ambient level. Initialised to minRMS; updated
// on each silence frame.
floorRMS float64
}
// NewVAD creates a VAD with the given thresholds. Zero values use defaults.
func NewVAD(minRMS, speechMs, silenceMs, maxMs int) *VAD {
v := &VAD{
minRMS: defaultMinRMS,
speechMs: defaultSpeechMs,
silenceMs: defaultSilenceMs,
maxMs: defaultMaxMs,
floorRMS: defaultMinRMS,
}
if minRMS > 0 {
v.minRMS = float64(minRMS) / 10000.0
v.floorRMS = v.minRMS
}
if speechMs > 0 {
v.speechMs = speechMs
}
if silenceMs > 0 {
v.silenceMs = silenceMs
}
if maxMs > 0 {
v.maxMs = maxMs
}
return v
}
// FrameSamples returns the number of PCM int16 samples this VAD expects per
// feed call. Callers must chunk their stream accordingly.
func (v *VAD) FrameSamples() int { return frameSamples }
// State returns the current speech state.
func (v *VAD) State() SpeechState { return v.state }
// Feed processes one 30ms frame of PCM int16 LE samples. Returns:
// - utterance: a complete utterance's PCM bytes, or nil if still accumulating
// - state: the current speech state after processing this frame
//
// When an utterance is returned, the internal buffer resets and the caller
// should send the audio to the voice server before feeding more frames.
func (v *VAD) Feed(frame []int16) (_ audio.Audio, state SpeechState) {
rms := frameRMS(frame)
isSpeech := rms >= v.floorRMS
switch v.state {
case StateSilence:
if isSpeech {
v.speechFrames++
if v.speechFrames*frameSamples*1000/defaultSampleRate >= v.speechMs {
// Transition to speech: start buffering utterance.
v.state = StateSpeech
v.silenceFrames = 0
v.totalFrames = 0
v.utterance = nil
// Include the frames that built up to the threshold.
v.utterance = append(v.utterance, pcmBytes(frame)...)
return audio.Audio{}, StateSpeech
}
} else {
// Silence — update noise floor (slow attack, fast decay).
v.speechFrames = 0
v.floorRMS = v.floorRMS*0.9 + rms*0.1
if v.floorRMS < v.minRMS {
v.floorRMS = v.minRMS
}
}
return audio.Audio{}, StateSilence
case StateSpeech:
v.totalFrames++
v.utterance = append(v.utterance, pcmBytes(frame)...)
if v.totalFrames*frameSamples*1000/defaultSampleRate >= v.maxMs {
// Max utterance length reached — force-end the utterance.
utt := audio.Audio{
Format: audio.PCM16kMono,
Bytes: v.utterance,
}
v.reset()
return utt, StateSilence
}
if isSpeech {
v.silenceFrames = 0
return audio.Audio{}, StateSpeech
}
v.silenceFrames++
if v.silenceFrames*frameSamples*1000/defaultSampleRate >= v.silenceMs {
// Silence threshold reached — utterance complete.
utt := audio.Audio{
Format: audio.PCM16kMono,
Bytes: v.utterance,
}
v.reset()
return utt, StateSilence
}
return audio.Audio{}, StateSpeech
}
return audio.Audio{}, StateSilence
}
// Reset clears the internal state (e.g. after a timeout or error).
func (v *VAD) Reset() { v.reset() }
func (v *VAD) reset() {
v.state = StateSilence
v.speechFrames = 0
v.silenceFrames = 0
v.totalFrames = 0
v.utterance = nil
}
// frameRMS computes the RMS energy of one int16 PCM frame.
func frameRMS(frame []int16) float64 {
if len(frame) == 0 {
return 0
}
var sum float64
for _, s := range frame {
f := float64(s) / 32768.0
sum += f * f
}
return math.Sqrt(sum / float64(len(frame)))
}
// pcmBytes converts an int16 frame to raw PCM LE bytes.
func pcmBytes(frame []int16) []byte {
b := make([]byte, len(frame)*2)
for i, s := range frame {
b[i*2] = byte(s)
b[i*2+1] = byte(s >> 8)
}
return b
}
// PCMToF32 converts raw PCM LE bytes to float32 samples (normalized to [-1,1]).
// Used by tests and by the audio source to feed the VAD.
func PCMToF32(raw []byte) []float32 {
n := len(raw) / 2
out := make([]float32, n)
for i := 0; i < n; i++ {
s := int16(raw[i*2]) | int16(raw[i*2+1])<<8
out[i] = float32(s) / 32768.0
}
return out
}
// PCMToI16 converts raw PCM LE bytes to int16 samples. Direct feed for VAD.
func PCMToI16(raw []byte) []int16 {
n := len(raw) / 2
out := make([]int16, n)
for i := 0; i < n; i++ {
out[i] = int16(raw[i*2]) | int16(raw[i*2+1])<<8
}
return out
}
// AudioDuration returns the duration in seconds of raw PCM 16k mono int16.
func AudioDuration(raw []byte) time.Duration {
if len(raw) == 0 {
return 0
}
samples := len(raw) / 2
return time.Duration(samples) * time.Second / defaultSampleRate
}
+326
View File
@@ -0,0 +1,326 @@
package main
import (
"math"
"testing"
"time"
"github.com/kami/maven/internal/audio"
)
// frameRMSQuick computes RMS of raw PCM int16 LE bytes.
func frameRMSQuick(raw []byte) float64 {
if len(raw) == 0 {
return 0
}
var sum float64
for i := 0; i < len(raw); i += 2 {
s := int16(raw[i]) | int16(raw[i+1])<<8
f := float64(s) / 32768.0
sum += f * f
}
return math.Sqrt(sum / float64(len(raw)/2))
}
// loudFrame returns a 30ms frame of loud sine tone (RMS ~0.5).
func loudFrame() []int16 {
f := make([]int16, frameSamples)
for i := range f {
f[i] = int16(16000 * math.Sin(2*math.Pi*440*float64(i)/16000))
}
return f
}
// quietFrame returns a 30ms frame of near-silence (RMS ~0.005).
func quietFrame() []int16 {
f := make([]int16, frameSamples)
for i := range f {
f[i] = int16(80 * math.Sin(2*math.Pi*440*float64(i)/16000))
}
return f
}
// silentFrame returns a true silent frame (all zeros).
func silentFrame() []int16 {
return make([]int16, frameSamples)
}
func TestVAD_InitialState(t *testing.T) {
v := NewVAD(0, 0, 0, 0)
if v.State() != StateSilence {
t.Fatalf("initial state = %d, want %d (StateSilence)", v.State(), StateSilence)
}
if v.FrameSamples() != frameSamples {
t.Fatalf("FrameSamples = %d, want %d", v.FrameSamples(), frameSamples)
}
}
func TestVAD_SilenceStaysSilence(t *testing.T) {
v := NewVAD(0, 0, 0, 0)
// Feed silence for many frames — should never transition to speech.
for i := 0; i < 50; i++ {
utt, state := v.Feed(silentFrame())
if utt.Bytes != nil {
t.Fatalf("unexpected utterance at frame %d", i)
}
if state != StateSilence {
t.Fatalf("state = %d at frame %d, want StateSilence", state, i)
}
}
}
func TestVAD_SpeechTransition(t *testing.T) {
v := NewVAD(0, 0, 0, 0)
// Frames needed to trigger speech: ceil(300ms / 30ms) = 10
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
// Feed loud frames one by one. On the last frame state flips to Speech;
// no utterance returned — still accumulating.
for i := 0; i < speechFrames; i++ {
utt, state := v.Feed(loudFrame())
if i < speechFrames-1 {
if state != StateSilence {
t.Fatalf("state = %d at frame %d, want StateSilence", state, i)
}
} else {
if state != StateSpeech {
t.Fatalf("state after trigger = %d, want StateSpeech", state)
}
}
if utt.Bytes != nil {
t.Fatalf("unexpected utterance at frame %d (utterance not done)", i)
}
}
}
func TestVAD_UtteranceComplete(t *testing.T) {
v := NewVAD(0, 0, 0, 0)
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
// Silence needs ceil(800/30) = 27 frames; use the tracked duration check.
silenceFrames := (defaultSilenceMs + defaultFrameMs - 1) / defaultFrameMs
// Trigger speech.
for i := 0; i < speechFrames; i++ {
v.Feed(loudFrame())
}
// Feed a few speech frames.
for i := 0; i < 5; i++ {
utt, state := v.Feed(loudFrame())
if utt.Bytes != nil {
t.Fatalf("unexpected utterance during speech at frame %d", i)
}
if state != StateSpeech {
t.Fatalf("state = %d during speech, want StateSpeech", state)
}
}
// Feed silence frames until utterance completes.
for i := 0; i < silenceFrames; i++ {
utt, state := v.Feed(quietFrame())
if i < silenceFrames-1 {
if utt.Bytes != nil {
t.Fatalf("unexpected utterance before silence triggers at frame %d", i)
}
if state != StateSpeech {
t.Fatalf("state = %d during silence tail, want StateSpeech", state)
}
} else {
// Last frame should trigger utterance complete.
if state != StateSilence {
t.Fatalf("state after utterance = %d, want StateSilence", state)
}
if utt.Bytes == nil {
t.Fatal("expected non-nil utterance after silence trigger")
}
dur := audio.Audio{Format: audio.PCM16kMono, Bytes: utt.Bytes}.Duration()
if dur <= 0 {
t.Fatalf("utterance duration %.2f, want > 0", dur)
}
t.Logf("utterance: %.2fs, %d bytes", dur, len(utt.Bytes))
}
}
}
func TestVAD_MaxUtteranceLength(t *testing.T) {
v := NewVAD(0, 0, 0, 100) // max 100ms
speechFrames := defaultSpeechMs / defaultFrameMs
// Trigger speech.
for i := 0; i < speechFrames; i++ {
v.Feed(loudFrame())
}
// Calculate how many frames until max (100ms / 30ms = 3.33 → ceil 4)
maxFrames := 100 / defaultFrameMs // 3
if 100%defaultFrameMs != 0 {
maxFrames++
}
for i := 0; i < maxFrames-1; i++ {
utt, state := v.Feed(loudFrame())
if utt.Bytes != nil {
t.Fatalf("unexpected utterance at frame %d before max", i)
}
if state != StateSpeech {
t.Fatalf("unexpected state %d during speech", state)
}
}
// This frame should hit the max and force-end.
utt, state := v.Feed(loudFrame())
if state != StateSilence {
t.Fatalf("state after max = %d, want StateSilence", state)
}
if utt.Bytes == nil {
t.Fatal("expected utterance after max duration")
}
dur := audio.Audio{Format: audio.PCM16kMono, Bytes: utt.Bytes}.Duration()
if dur > 150*time.Millisecond.Seconds() {
t.Fatalf("utterance too long: %.2fs, want <= 150ms", dur)
}
}
func TestVAD_Reset(t *testing.T) {
v := NewVAD(0, 0, 0, 0)
speechFrames := defaultSpeechMs / defaultFrameMs
// Start speaking
for i := 0; i < speechFrames; i++ {
v.Feed(loudFrame())
}
if v.State() != StateSpeech {
t.Fatalf("expected StateSpeech after trigger")
}
// Reset
v.Reset()
if v.State() != StateSilence {
t.Fatalf("expected StateSilence after reset")
}
// Should be back to silence
utt, state := v.Feed(quietFrame())
if utt.Bytes != nil {
t.Fatal("expected nil utterance after reset + silence")
}
if state != StateSilence {
t.Fatalf("expected StateSilence after reset, got %d", state)
}
}
func TestVAD_ShortSpeechNotTriggered(t *testing.T) {
v := NewVAD(0, 0, 0, 0)
// A few loud frames below the speech_ms threshold should not trigger.
for i := 0; i < 3; i++ {
utt, state := v.Feed(loudFrame())
if utt.Bytes != nil {
t.Fatalf("unexpected utterance at frame %d", i)
}
if state != StateSilence {
t.Fatalf("expected StateSilence for short speech, got %d", state)
}
}
}
func TestVAD_AdaptiveFloor(t *testing.T) {
v := NewVAD(100, 0, 0, 0) // minRMS = 100/10000 = 0.01
speechFrames := defaultSpeechMs / defaultFrameMs
// Very quiet frames should lower the floor.
for i := 0; i < 30; i++ {
v.Feed(silentFrame())
}
// floorRMS should be ~0.01 (minRMS clamp)
if v.floorRMS < 0.009 || v.floorRMS > 0.011 {
t.Fatalf("floorRMS after silence = %.4f, want ~0.01", v.floorRMS)
}
// A frame at RMS ~0.005 (quietFrame) should now be below the floor
// and stay silence.
for i := 0; i < speechFrames; i++ {
_, state := v.Feed(quietFrame())
if state != StateSilence {
t.Fatalf("quiet frame triggered speech (floor=%.4f)", v.floorRMS)
}
}
}
func TestVAD_EmptyFrame(t *testing.T) {
v := NewVAD(0, 0, 0, 0)
utt, state := v.Feed(nil)
if utt.Bytes != nil {
t.Fatal("expected nil utterance for empty frame")
}
_ = state // state is undefined for empty; just don't panic
}
func TestVAD_ConstructedUtterance(t *testing.T) {
// Test a realistic scenario: speech followed by silence.
v := NewVAD(0, 0, 0, 0)
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
silenceFrames := (defaultSilenceMs + defaultFrameMs - 1) / defaultFrameMs
// Build frames: trigger threshold + 20 speech frames + silence tail.
totalSpeech := speechFrames + 20
frames := make([][]int16, 0, totalSpeech+silenceFrames)
for i := 0; i < totalSpeech; i++ {
frames = append(frames, loudFrame())
}
for i := 0; i < silenceFrames; i++ {
frames = append(frames, silentFrame())
}
var gotUtterance bool
var uttCount int
for _, f := range frames {
utt, state := v.Feed(f)
if utt.Bytes != nil {
gotUtterance = true
uttCount++
dur := audio.Audio{Format: audio.PCM16kMono, Bytes: utt.Bytes}.Duration()
// Utterance includes: 1 trigger frame + 20 speech + all silence
// frames before the threshold frames. silenceFrames count already
// includes the trailing frame that trips the return.
expectedFrames := 1 + 20 + silenceFrames
expectedDur := float64(expectedFrames) * float64(defaultFrameMs) / 1000.0
if dur < expectedDur*0.8 || dur > expectedDur*1.2 {
t.Fatalf("utterance duration %.2fs (expected ~%.2fs, %d frames, silenceFrames=%d)",
dur, expectedDur, expectedFrames, silenceFrames)
}
t.Logf("utterance: %.2fs, expected ~%.2fs (%d frames)", dur, expectedDur, expectedFrames)
}
if state != StateSilence && utt.Bytes != nil {
t.Fatalf("utterance returned but state = %d, want StateSilence", state)
}
}
if !gotUtterance {
t.Fatal("no utterance produced from speech+silence")
}
if uttCount != 1 {
t.Fatalf("expected exactly 1 utterance, got %d", uttCount)
}
if v.State() != StateSilence {
t.Fatalf("final state = %d, want StateSilence", v.State())
}
}
// Benchmarks.
func BenchmarkVAD(b *testing.B) {
v := NewVAD(0, 0, 0, 0)
f := loudFrame()
b.ResetTimer()
for i := 0; i < b.N; i++ {
v.Feed(f)
}
}
func BenchmarkFrameRMS(b *testing.B) {
f := loudFrame()
b.ResetTimer()
for i := 0; i < b.N; i++ {
frameRMS(f)
}
}