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.
283 lines
8.6 KiB
Go
283 lines
8.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"math"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/audio"
|
|
)
|
|
|
|
// fakePlayer records Play/Stop instead of shelling out to aplay.
|
|
type fakePlayer struct {
|
|
playing bool
|
|
plays int
|
|
stops int
|
|
last audio.Audio
|
|
}
|
|
|
|
func (p *fakePlayer) Play(a audio.Audio) { p.playing = true; p.plays++; p.last = a }
|
|
func (p *fakePlayer) Stop() { p.playing = false; p.stops++ }
|
|
func (p *fakePlayer) Playing() bool { return p.playing }
|
|
|
|
// fakeSender records what was shipped and hands back a canned reply.
|
|
type fakeSender struct {
|
|
sent []audio.Audio
|
|
reply audio.Audio
|
|
err error
|
|
}
|
|
|
|
func (s *fakeSender) Send(_ context.Context, utt audio.Audio, _ string) (audio.Audio, error) {
|
|
s.sent = append(s.sent, utt)
|
|
return s.reply, s.err
|
|
}
|
|
|
|
func replyAudio() audio.Audio {
|
|
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 16000)}
|
|
}
|
|
|
|
// frameAt returns a 30ms frame whose RMS is approximately rms.
|
|
func frameAt(rms float64) []byte {
|
|
amp := rms * math.Sqrt2 * 32768
|
|
f := make([]int16, frameSamples)
|
|
for i := range f {
|
|
f[i] = int16(amp * math.Sin(2*math.Pi*440*float64(i)/16000))
|
|
}
|
|
return pcmBytes(f)
|
|
}
|
|
|
|
func silentBytes() []byte { return make([]byte, frameSamples*2) }
|
|
|
|
// newTestSession wires a session with fakes and a default VAD.
|
|
func newTestSession(barge bargeInConfig) (*session, *fakePlayer, *fakeSender) {
|
|
p := &fakePlayer{}
|
|
s := &fakeSender{reply: replyAudio()}
|
|
return newSession(NewVAD(0, 0, 0, 0), p, s, "ru", barge), p, s
|
|
}
|
|
|
|
// speakThenPause drives a full utterance through the session: enough loud
|
|
// frames to trigger, then enough silence to end it.
|
|
func speakThenPause(t *testing.T, sess *session) {
|
|
t.Helper()
|
|
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
|
|
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
|
|
loud := frameAt(0.35)
|
|
for i := 0; i < speechFrames+5; i++ {
|
|
if err := sess.feed(context.Background(), loud); err != nil {
|
|
t.Fatalf("feed loud frame %d: %v", i, err)
|
|
}
|
|
}
|
|
for i := 0; i < silenceFrames; i++ {
|
|
if err := sess.feed(context.Background(), silentBytes()); err != nil {
|
|
t.Fatalf("feed silent frame %d: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionSendsUtteranceAndPlaysReply(t *testing.T) {
|
|
sess, p, snd := newTestSession(bargeInConfig{})
|
|
speakThenPause(t, sess)
|
|
|
|
if len(snd.sent) != 1 {
|
|
t.Fatalf("sent %d utterances, want 1", len(snd.sent))
|
|
}
|
|
if snd.sent[0].Format != audio.PCM16kMono {
|
|
t.Errorf("utterance format = %+v, want canonical", snd.sent[0].Format)
|
|
}
|
|
if p.plays != 1 {
|
|
t.Errorf("plays = %d, want 1", p.plays)
|
|
}
|
|
}
|
|
|
|
// The bug this whole file exists for: while the speaker is running, the mic
|
|
// hears Maven and the old code shipped that back as a fresh command.
|
|
func TestSessionDoesNotHearItselfWhilePlaying(t *testing.T) {
|
|
sess, p, snd := newTestSession(bargeInConfig{})
|
|
speakThenPause(t, sess)
|
|
if !p.Playing() {
|
|
t.Fatal("expected playback to be running after the reply")
|
|
}
|
|
|
|
// Feed a long stretch of loud audio — Maven's own voice coming back in.
|
|
base := sess.suppressed
|
|
loud := frameAt(0.35)
|
|
for i := 0; i < 200; i++ {
|
|
if err := sess.feed(context.Background(), loud); err != nil {
|
|
t.Fatalf("feed echo frame %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
if len(snd.sent) != 1 {
|
|
t.Fatalf("sent %d utterances, want 1 — her own reply was captured as a command", len(snd.sent))
|
|
}
|
|
if got := sess.suppressed - base; got != 200 {
|
|
t.Errorf("suppressed %d of the 200 echo frames, want all of them", got)
|
|
}
|
|
if p.stops != 0 {
|
|
t.Errorf("stops = %d, want 0 — barge-in is off, nothing should cut her off", p.stops)
|
|
}
|
|
}
|
|
|
|
// With barge-in off, no amount of noise stops playback.
|
|
func TestSessionBargeInDisabledByDefault(t *testing.T) {
|
|
sess, p, _ := newTestSession(bargeInConfig{})
|
|
if sess.barge.Enabled() {
|
|
t.Fatal("zero bargeInConfig must be disabled")
|
|
}
|
|
speakThenPause(t, sess)
|
|
veryLoud := frameAt(0.6)
|
|
for i := 0; i < 50; i++ {
|
|
_ = sess.feed(context.Background(), veryLoud)
|
|
}
|
|
if p.stops != 0 || sess.bargeIns != 0 {
|
|
t.Fatalf("stops = %d, bargeIns = %d, want 0 with barge-in off", p.stops, sess.bargeIns)
|
|
}
|
|
}
|
|
|
|
func TestSessionBargeInCutsPlayback(t *testing.T) {
|
|
barge := bargeInConfig{RMS: 0.12, Frames: 5}
|
|
sess, p, _ := newTestSession(barge)
|
|
speakThenPause(t, sess)
|
|
if !p.Playing() {
|
|
t.Fatal("expected playback after the reply")
|
|
}
|
|
|
|
// Four loud frames must not be enough — a door closing is not a voice.
|
|
veryLoud := frameAt(0.35)
|
|
for i := 0; i < 4; i++ {
|
|
_ = sess.feed(context.Background(), veryLoud)
|
|
}
|
|
if p.stops != 0 {
|
|
t.Fatalf("playback cut after 4 frames, want it to hold until %d", barge.Frames)
|
|
}
|
|
|
|
// The fifth cuts her off.
|
|
_ = sess.feed(context.Background(), veryLoud)
|
|
if p.stops != 1 || sess.bargeIns != 1 {
|
|
t.Fatalf("stops = %d, bargeIns = %d, want 1 and 1", p.stops, sess.bargeIns)
|
|
}
|
|
if p.Playing() {
|
|
t.Fatal("still playing after barge-in")
|
|
}
|
|
}
|
|
|
|
// A burst that falls back under the threshold resets the counter, so noise
|
|
// spread over a whole reply never accumulates into a false barge-in.
|
|
func TestSessionBargeInNeedsConsecutiveFrames(t *testing.T) {
|
|
sess, p, _ := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
|
speakThenPause(t, sess)
|
|
|
|
veryLoud := frameAt(0.35)
|
|
quiet := frameAt(0.02)
|
|
for i := 0; i < 20; i++ {
|
|
_ = sess.feed(context.Background(), veryLoud)
|
|
_ = sess.feed(context.Background(), veryLoud)
|
|
_ = sess.feed(context.Background(), quiet)
|
|
}
|
|
if p.stops != 0 || sess.bargeIns != 0 {
|
|
t.Fatalf("stops = %d, bargeIns = %d, want 0 — two-frame bursts must not accumulate", p.stops, sess.bargeIns)
|
|
}
|
|
}
|
|
|
|
// Speaker leak sits near the room floor; it must never reach the barge-in bar.
|
|
func TestSessionEchoLevelAudioNeverBargesIn(t *testing.T) {
|
|
sess, p, _ := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
|
speakThenPause(t, sess)
|
|
|
|
base := sess.suppressed
|
|
leak := frameAt(0.05) // loud enough for the VAD, far under the barge bar
|
|
for i := 0; i < 300; i++ {
|
|
_ = sess.feed(context.Background(), leak)
|
|
}
|
|
if p.stops != 0 {
|
|
t.Fatalf("stops = %d, want 0 — speaker leak must not read as barge-in", p.stops)
|
|
}
|
|
if got := sess.suppressed - base; got != 300 {
|
|
t.Errorf("suppressed %d of the 300 leak frames, want all of them", got)
|
|
}
|
|
}
|
|
|
|
// After barge-in the VAD must start clean, so the interrupting speech is
|
|
// captured as a whole utterance rather than joined onto echo state.
|
|
func TestSessionCapturesTheInterruptingUtterance(t *testing.T) {
|
|
sess, p, snd := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
|
speakThenPause(t, sess)
|
|
|
|
veryLoud := frameAt(0.35)
|
|
for i := 0; i < 5; i++ {
|
|
_ = sess.feed(context.Background(), veryLoud)
|
|
}
|
|
if p.stops != 1 {
|
|
t.Fatalf("expected barge-in, stops = %d", p.stops)
|
|
}
|
|
|
|
// He keeps talking; that is a new command.
|
|
speakThenPause(t, sess)
|
|
if len(snd.sent) != 2 {
|
|
t.Fatalf("sent %d utterances, want 2 — the interruption itself must be heard", len(snd.sent))
|
|
}
|
|
if p.plays != 2 {
|
|
t.Errorf("plays = %d, want 2", p.plays)
|
|
}
|
|
}
|
|
|
|
// A failed round-trip must surface as an error and must not start playback.
|
|
func TestSessionSendErrorDoesNotPlay(t *testing.T) {
|
|
p := &fakePlayer{}
|
|
snd := &fakeSender{err: errors.New("boom")}
|
|
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", bargeInConfig{})
|
|
|
|
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
|
|
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
|
|
loud := frameAt(0.35)
|
|
var lastErr error
|
|
for i := 0; i < speechFrames+5; i++ {
|
|
_ = sess.feed(context.Background(), loud)
|
|
}
|
|
for i := 0; i < silenceFrames; i++ {
|
|
if err := sess.feed(context.Background(), silentBytes()); err != nil {
|
|
lastErr = err
|
|
}
|
|
}
|
|
if lastErr == nil {
|
|
t.Fatal("send error was swallowed")
|
|
}
|
|
if p.plays != 0 || p.Playing() {
|
|
t.Fatalf("plays = %d, playing = %v, want no playback on a failed round-trip", p.plays, p.Playing())
|
|
}
|
|
}
|
|
|
|
// An empty reply (text-only turn) must leave the capture side open.
|
|
func TestSessionEmptyReplyLeavesCaptureOpen(t *testing.T) {
|
|
p := &fakePlayer{}
|
|
snd := &fakeSender{reply: audio.Audio{Format: audio.PCM16kMono}}
|
|
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", bargeInConfig{})
|
|
|
|
speakThenPause(t, sess)
|
|
if p.plays != 0 {
|
|
t.Fatalf("plays = %d, want 0 for an empty reply", p.plays)
|
|
}
|
|
speakThenPause(t, sess)
|
|
if len(snd.sent) != 2 {
|
|
t.Fatalf("sent %d, want 2 — capture must stay open when there is no audio reply", len(snd.sent))
|
|
}
|
|
}
|
|
|
|
func TestBargeInConfigEnabled(t *testing.T) {
|
|
cases := []struct {
|
|
c bargeInConfig
|
|
want bool
|
|
}{
|
|
{bargeInConfig{}, false},
|
|
{bargeInConfig{RMS: 0.12}, false},
|
|
{bargeInConfig{Frames: 5}, false},
|
|
{bargeInConfig{RMS: 0.12, Frames: 5}, true},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := tc.c.Enabled(); got != tc.want {
|
|
t.Errorf("%+v.Enabled() = %v, want %v", tc.c, got, tc.want)
|
|
}
|
|
}
|
|
}
|