package main import ( "context" "errors" "math" "testing" "time" "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) } } } // slowSender models the real thing: a round-trip takes wall-clock time, and // the microphone keeps recording into a pipe nobody is reading. type slowSender struct { fakeSender clock *time.Time took time.Duration } func (s *slowSender) Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) { *s.clock = s.clock.Add(s.took) return s.fakeSender.Send(ctx, utt, lang) } // newSlowSession wires a session whose round-trip takes took of wall time. func newSlowSession(barge bargeInConfig, reply audio.Audio, err error, took time.Duration) (*session, *fakePlayer, *slowSender) { now := time.Unix(0, 0) p := &fakePlayer{} snd := &slowSender{fakeSender: fakeSender{reply: reply, err: err}, clock: &now, took: took} sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", barge) sess.now = func() time.Time { return now } return sess, p, snd } // A text-only turn starts no playback, so the half-duplex gate does not cover // it. The backlog captured during the round-trip has to be dropped anyway, or // three seconds of room arrives at pipe speed and becomes a command. func TestSessionDropsBacklogAfterAnEmptyReply(t *testing.T) { sess, p, snd := newSlowSession(bargeInConfig{}, audio.Audio{Format: audio.PCM16kMono}, nil, 3*time.Second) speakThenPause(t, sess) if p.plays != 0 || len(snd.sent) != 1 { t.Fatalf("plays = %d, sent = %d; want one text-only turn", p.plays, len(snd.sent)) } // The tail of speakThenPause already spent a couple of them. if want := int(3 * time.Second / frameDuration); sess.discard+sess.dropped != want { t.Fatalf("discard %d + dropped %d frames, want %d (3s of backlog)", sess.discard, sess.dropped, want) } // The burst: the whole backlog, all of it him still talking. loud := frameAt(0.35) for i := 0; i < sess.discard; i++ { if err := sess.feed(context.Background(), loud); err != nil { t.Fatal(err) } } if len(snd.sent) != 1 { t.Errorf("the backlog was sent as a second utterance (sent = %d)", len(snd.sent)) } if sess.dropped == 0 { t.Error("no frames were counted as backlog") } } // Same on the error path. A dead daemon used to seed the next spurious trigger // on every failed turn, so a dead socket drove a retry loop off backlog alone. func TestSessionDropsBacklogAfterASendError(t *testing.T) { sess, _, _ := newSlowSession(bargeInConfig{}, audio.Audio{}, errors.New("boom"), 3*time.Second) // Not speakThenPause: the dispatch returns the send error, which that // helper treats as fatal. loud := frameAt(0.35) for i := 0; i < (defaultSpeechMs+defaultFrameMs-1)/defaultFrameMs+5; i++ { _ = sess.feed(context.Background(), loud) } for i := 0; i < (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs+2; i++ { _ = sess.feed(context.Background(), silentBytes()) } if sess.discard == 0 { t.Fatal("a failed round-trip left the backlog to be fed into the VAD") } } // Barge-in must not be triggerable by the backlog. Those frames are him // finishing the sentence he started before she answered, delivered in // microseconds, and the five-frame guard assumes real time. func TestSessionBacklogCannotBargeIn(t *testing.T) { sess, p, _ := newSlowSession(bargeInConfig{RMS: 0.12, Frames: 5}, replyAudio(), nil, 3*time.Second) speakThenPause(t, sess) if p.plays != 1 || !p.Playing() { t.Fatalf("plays = %d, playing = %v; want the reply playing", p.plays, p.Playing()) } loud := frameAt(0.35) backlog := sess.discard if backlog < 5 { t.Fatalf("discard = %d, want a real backlog", backlog) } for i := 0; i < backlog; i++ { if err := sess.feed(context.Background(), loud); err != nil { t.Fatal(err) } } if p.stops != 0 { t.Fatalf("she was cut off by audio recorded before she started speaking (stops = %d)", p.stops) } // Real-time speech after the backlog still interrupts her. for i := 0; i < 5; i++ { if err := sess.feed(context.Background(), loud); err != nil { t.Fatal(err) } } if p.stops != 1 { t.Fatalf("stops = %d, want 1 — barge-in must still work after the backlog", p.stops) } } // The frames that proved he was interrupting are replayed into the VAD, so his // first word is not clipped. Five trigger frames plus five real ones reach the // 300ms speech threshold; without the replay the first five are lost and no // utterance is produced at all. func TestSessionReplaysTheBargeInTriggerFrames(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) } if p.Playing() { t.Fatal("fake player still playing after Stop") } speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs for i := 0; i < speechFrames-5; i++ { if err := sess.feed(context.Background(), veryLoud); err != nil { t.Fatal(err) } } silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2 for i := 0; i < silenceFrames; i++ { if err := sess.feed(context.Background(), silentBytes()); err != nil { t.Fatal(err) } } if len(snd.sent) != 2 { t.Fatalf("sent %d utterances, want 2 — the 150ms that triggered barge-in was clipped", len(snd.sent)) } }