Files
Maven/cmd/mavwaked/vad_test.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

327 lines
8.9 KiB
Go

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)
}
}