ce6a6821a9
keywordGate is an interface so the decision that ships an utterance can be exercised with a fake that fires on demand. A gate that can only be tested with a model file is a gate nobody tests. The three that carry the fixed-when criterion: keywordless speech never reaches STT, the keyword does, and barge-in still cuts her off mid-sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
175 lines
5.0 KiB
Go
175 lines
5.0 KiB
Go
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()
|
|
}
|