fed33a4e16
Playback was `go playAudio(reply)` — fire and forget, nobody holding the process handle. Two audible consequences fell out of that. She answered herself. The capture loop kept feeding the VAD while the speaker was running, so her own reply came back in through the mic, tripped the VAD, and was shipped to the daemon as a fresh command. There is no acoustic echo canceller in this pipeline, so the fix is half-duplex: while she is speaking, the capture side is muted. That part is unconditional — it repairs a defect, it is not a new capability. And talking over her did nothing, because there was no handle to cancel. -barge-in now cuts playback when sustained energy clears a room-tuned threshold (-barge-in-rms, default 0.12 normalised, over -barge-in-frames consecutive frames, default 5). It is off by default: without an echo canceller the only way to tell "he is talking over her" from "the mic is hearing her" is that he is much louder, and how much louder depends on where the mic sits. The frame decision moved out of main.go into session.feed, behind a player and an utteranceSender interface, so all of it is testable with no mic, no speaker and no daemon. Nine tests cover the self-hearing case, the off-by-default case, the consecutive-frame requirement, speaker-leak-level audio not triggering, capturing the interrupting utterance after a cut, and failed round-trips not starting playback. The other seven items on #287 (partial STT, per-segment retry, mic profiles, noise-floor calibration, short-response-while-speaking) are untouched and stay on the task.
238 lines
6.9 KiB
Go
238 lines
6.9 KiB
Go
// 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)
|
|
|
|
// Barge-in thresholds. Only used when -barge-in is passed. The RMS is
|
|
// x10000 like -min-rms, and sits an order of magnitude above the VAD's
|
|
// own floor on purpose: with no acoustic echo canceller, a frame only
|
|
// counts as "he is talking over her" if it is far louder than what the
|
|
// speaker leaks back into the mic. 5 frames is 150ms — long enough that
|
|
// a door or a cough does not cut her off mid-sentence.
|
|
defaultBargeRMS = 1200 // 0.12 normalised RMS
|
|
defaultBargeFrames = 5
|
|
)
|
|
|
|
// 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
|
|
}
|