From e57647c9a306d64424f525afb0344f77e3b9a7ad Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 6 Jul 2026 14:09:34 +0400 Subject: [PATCH] 3.1 always-on listening: mavwaked with energy VAD + SurfaceVoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 1 + Dockerfile | 5 +- Makefile | 9 +- cmd/mavwaked/main.go | 271 ++++++++++++++++++++++++++++++++ cmd/mavwaked/vad.go | 228 +++++++++++++++++++++++++++ cmd/mavwaked/vad_test.go | 326 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 835 insertions(+), 5 deletions(-) create mode 100644 cmd/mavwaked/main.go create mode 100644 cmd/mavwaked/vad.go create mode 100644 cmd/mavwaked/vad_test.go diff --git a/.dockerignore b/.dockerignore index fb6997b..d84b0ac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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. diff --git a/Dockerfile b/Dockerfile index 0e4ab58..b55d0ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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. diff --git a/Makefile b/Makefile index 15cc734..5f1459d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/mavwaked/main.go b/cmd/mavwaked/main.go new file mode 100644 index 0000000..d8ee946 --- /dev/null +++ b/cmd/mavwaked/main.go @@ -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 + } +} diff --git a/cmd/mavwaked/vad.go b/cmd/mavwaked/vad.go new file mode 100644 index 0000000..11f26d5 --- /dev/null +++ b/cmd/mavwaked/vad.go @@ -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 +} diff --git a/cmd/mavwaked/vad_test.go b/cmd/mavwaked/vad_test.go new file mode 100644 index 0000000..20938bf --- /dev/null +++ b/cmd/mavwaked/vad_test.go @@ -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) + } +}