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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user