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
327 lines
8.9 KiB
Go
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)
|
|
}
|
|
}
|