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) } } }