mavwaked: throw away the round-trip backlog before it becomes a turn

Nothing reads the microphone while Send is in flight, so the audio piles
up in arecord's pipe and arrives in a burst the moment dispatch returns.
A round-trip is p50 2.7s through the LLM router, which is about 90
frames of room, of him finishing his sentence, of the television.

The old code reset the VAD on the reply path only, and for a reason that
was not true: the comment said the VAD had been accumulating during the
round-trip, when its state is exactly what Feed left it as. The two
paths with no reset are the ones that mattered, because neither starts
playback and so neither is covered by the half-duplex gate. A text-only
turn fed the whole backlog into the VAD, and a Send error did the same
on every failed turn, so a dead socket drove a retry loop off backlog
alone.

The backlog was scored for barge-in too. Five frames delivered in
microseconds cut her off with audio recorded before she started
speaking, which is the opposite of what the five-frame guard is for.
Both are fixed by the same mechanism: measure the wall time the
round-trip took, convert it to frames, and discard that many before
anything looks at them.

Barge-in also threw away the 150ms that proved he was talking. The VAD
started from the next frame, so the first word of a short interruption
was clipped before whisper saw it. Those frames are kept in a small ring
and replayed after the reset.

A stuck aplay was worse than before this feature existed. Playing()
gates all capture, so a wedged child made her deaf rather than silent,
for the full 30s ceiling inherited from the fire-and-forget version. The
mute window is bounded by the reply's own duration plus a margin now.

Three smaller ones. "-barge-in -barge-in-rms 0" logged "barge-in on" and
then did nothing. The sent counter incremented before the error check,
so failed round-trips counted as shipped. And the threshold the operator
has to guess is now reported: mavwaked logs the mean energy of the
frames it suppressed while speaking, so he can set it from data.

Found in review of #76.
This commit is contained in:
kami
2026-08-01 14:19:35 +04:00
parent d68708b5e1
commit d3fcc1dfdb
4 changed files with 293 additions and 14 deletions
+15 -4
View File
@@ -14,9 +14,12 @@
//
// While a reply is playing the capture side is muted (half-duplex): without
// it, Maven's own voice comes back in through the mic and she answers
// herself. -barge-in punches one hole in that gate — sustained energy well
// above the speaker's leak level cuts playback so he can talk over her. It is
// off by default because the threshold is room-specific; see playback.go.
// herself. -barge-in punches one hole in that gate — sustained energy above
// -barge-in-rms cuts playback so he can talk over her. It is off by default
// because the threshold is room-specific; see playback.go. The threshold is a
// raw frame RMS and has no reference to what the speaker actually leaks, so
// the daemon logs the mean energy of the frames it suppressed while speaking.
// Set -barge-in-rms from those numbers rather than by guessing.
//
// usage:
// mavwaked # default ALSA device, 127.0.0.1:9100
@@ -130,7 +133,15 @@ func run(args []string) error {
var barge bargeInConfig
if *bargeIn {
barge = bargeInConfig{RMS: float64(*bargeRMS) / 10000.0, Frames: *bargeFrames}
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
if barge.Enabled() {
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
} else {
// The log used to say "barge-in on (rms 0.0000 x 5)" here and then
// nothing happened, because Enabled needs a positive threshold.
log.Printf("mavwaked: -barge-in was passed but rms %.4f x %d frames disables it; "+
"both must be above zero, so barge-in is OFF",
barge.RMS, barge.Frames)
}
}
sess := newSession(vad, newAplayPlayer(), &voiceSender{vc: vc}, *lang, barge)