diff --git a/cmd/mavwaked/wakeword_test.go b/cmd/mavwaked/wakeword_test.go new file mode 100644 index 0000000..ffe0b47 --- /dev/null +++ b/cmd/mavwaked/wakeword_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "context" + "testing" + "time" +) + +// fakeGate fires on demand instead of running three ONNX models. The gate's +// own arithmetic is measured on real audio in docs/evals; what these tests +// cover is the thing that decides whether an utterance is shipped. +type fakeGate struct { + fireOn int // fire when this many frames have been fed, 0 never fires + fed int + resets int +} + +func (g *fakeGate) Feed(_ []int16) bool { + g.fed++ + return g.fireOn > 0 && g.fed == g.fireOn +} +func (g *fakeGate) Reset() { g.resets++ } +func (g *fakeGate) Score() float64 { return 1 } + +// wakingSession wires a session whose gate fires on the first frame it sees. +func wakingSession(fireOn int, window time.Duration) (*session, *fakePlayer, *fakeSender, *fakeGate) { + sess, p, snd := newTestSession(bargeInConfig{}) + g := &fakeGate{fireOn: fireOn} + sess.UseWakeWord(g, window) + return sess, p, snd, g +} + +func TestKeywordlessSpeechNeverReachesSTT(t *testing.T) { + sess, p, snd, g := wakingSession(0, 8*time.Second) + speakThenPause(t, sess) + + if len(snd.sent) != 0 { + t.Fatalf("sent %d utterances, want 0 — this is the whole point of V-487", len(snd.sent)) + } + if sess.ignored != 1 { + t.Errorf("ignored = %d, want 1", sess.ignored) + } + if p.plays != 0 { + t.Errorf("plays = %d, want 0", p.plays) + } + if g.fed == 0 { + t.Error("the gate was never fed a frame") + } +} + +func TestKeywordOpensTheGate(t *testing.T) { + sess, p, snd, _ := wakingSession(1, 8*time.Second) + speakThenPause(t, sess) + + if len(snd.sent) != 1 { + t.Fatalf("sent %d utterances, want 1", len(snd.sent)) + } + if sess.wakes != 1 { + t.Errorf("wakes = %d, want 1", sess.wakes) + } + if sess.ignored != 0 { + t.Errorf("ignored = %d, want 0", sess.ignored) + } + if p.plays != 1 { + t.Errorf("plays = %d, want 1", p.plays) + } +} + +// One keyword buys one turn. Without this the microphone stays open for as +// long as he keeps talking, which is the state the gate exists to end. +func TestOneKeywordBuysOneTurn(t *testing.T) { + sess, p, snd, _ := wakingSession(1, 8*time.Second) + speakThenPause(t, sess) + p.Stop() // she finished her reply + sess.discard = 0 // the backlog drain is not what this measures + speakThenPause(t, sess) + + if len(snd.sent) != 1 { + t.Fatalf("sent %d utterances, want 1: the second had no keyword", len(snd.sent)) + } + if sess.ignored != 1 { + t.Errorf("ignored = %d, want 1", sess.ignored) + } +} + +// The keyword is heard, then he says nothing for longer than the window. What +// he says after that is not addressed to her. +func TestTheKeywordExpires(t *testing.T) { + sess, _, snd, _ := wakingSession(1, 500*time.Millisecond) + now := time.Unix(1750000000, 0) + sess.now = func() time.Time { return now } + + if err := sess.feed(context.Background(), silentBytes()); err != nil { + t.Fatalf("feed: %v", err) + } + if sess.wakes != 1 { + t.Fatalf("wakes = %d, want 1", sess.wakes) + } + now = now.Add(2 * time.Second) + speakThenPause(t, sess) + + if len(snd.sent) != 0 { + t.Fatalf("sent %d utterances, want 0 — the keyword had expired", len(snd.sent)) + } +} + +// Barge-in cuts her off whether or not the keyword was heard. What he says +// after cutting her off still has to carry it. +func TestBargeInStillInterruptsHer(t *testing.T) { + sess, p, _, g := wakingSession(0, 8*time.Second) + sess.barge = bargeInConfig{RMS: 0.2, Frames: 3} + p.playing = true + loud := frameAt(0.35) + for i := 0; i < 4; i++ { + if err := sess.feed(context.Background(), loud); err != nil { + t.Fatalf("feed %d: %v", i, err) + } + } + if sess.bargeIns != 1 { + t.Fatalf("bargeIns = %d, want 1", sess.bargeIns) + } + if p.stops != 1 { + t.Errorf("stops = %d, want 1", p.stops) + } + if g.resets == 0 { + t.Error("barge-in left pre-playback audio in the gate") + } +} + +// Her own reply must not wake her. Frames captured while the player runs never +// reach the gate, and the gate is cleared when playback ends. +func TestHerOwnVoiceNeverReachesTheGate(t *testing.T) { + sess, p, _, g := wakingSession(1, 8*time.Second) + p.playing = true + for i := 0; i < 10; i++ { + if err := sess.feed(context.Background(), frameAt(0.35)); err != nil { + t.Fatalf("feed: %v", err) + } + } + if g.fed != 0 { + t.Fatalf("gate was fed %d frames while she was speaking, want 0", g.fed) + } + if sess.wakes != 0 { + t.Errorf("wakes = %d, want 0", sess.wakes) + } +} + +// No model, no gate: the daemon behaves exactly as it did before V-487 stage +// two. An operator with a missing file gets yesterday's mavwaked, not one that +// refuses to hear anything. +func TestNoGateShipsEveryUtterance(t *testing.T) { + sess, _, snd := newTestSession(bargeInConfig{}) + speakThenPause(t, sess) + + if len(snd.sent) != 1 { + t.Fatalf("sent %d utterances, want 1", len(snd.sent)) + } + if sess.ignored != 0 { + t.Errorf("ignored = %d, want 0", sess.ignored) + } +} + +// A nil *wakeWord is the closed gate, not a crash and not an open one. +func TestNilWakeWordHearsNothing(t *testing.T) { + var w *wakeWord + if w.Feed([]int16{0, 0, 0}) { + t.Error("a nil wake word reported the keyword") + } + if w.Score() != 0 { + t.Error("a nil wake word reported a score") + } + w.Reset() + w.Close() +}