diff --git a/cmd/mavwaked/main.go b/cmd/mavwaked/main.go index 4f0c5a4..f346705 100644 --- a/cmd/mavwaked/main.go +++ b/cmd/mavwaked/main.go @@ -12,10 +12,12 @@ // samples and the capture frame is 480, so silero.go re-chunks. This comment // used to say the two matched, which was true of silero v4. // -// There is still no wake-word model, so anything spoken near the microphone -// becomes a turn (V-487 stage two). The SurfaceVoice auth layer caps all -// commands at L0 (no destructive acts), which is what makes an accidental -// trigger safe rather than expensive. +// The keyword is "Мэйвен" and it is required, when -wake-model points at the +// head (V-487 stage two). Without it anything spoken near the microphone +// becomes a turn, which the SurfaceVoice auth layer makes safe rather than +// expensive: it caps all commands at L0, no destructive acts. It does not cap +// reading, so an open gate still lets the room hear his facts read back. +// wakeword.go holds the cadence and wakefeatures.go the three models. // // The conn carries both directions. mavwaked sends utterances and receives // proactive nudges on it, and it is opened at startup rather than at the first @@ -62,6 +64,12 @@ const ( defaultAddr = "127.0.0.1:9100" defaultLang = "ru" defaultReadSize = 4096 // max PCM bytes per read from arecord (fits multiple frames) + + // defaultWakeWindowMs — how long the keyword stays good for. He says + // "Мэйвен" and then a sentence, and the VAD does not close the utterance + // until he stops, so this has to outlive the word by the length of what + // follows it. It is spent on dispatch: one keyword, one turn. + defaultWakeWindowMs = 8000 ) func main() { @@ -86,6 +94,11 @@ func run(args []string) error { vadModel := flag.String("vad-model", "", "silero-vad onnx file; empty runs the energy threshold instead") vadThreshold := flag.Float64("vad-threshold", defaultSileroThreshold, "speech probability a frame must clear") onnxLib := flag.String("onnx-lib", os.Getenv("MAVEN_ONNX_LIB"), "libonnxruntime.so, needed with -vad-model") + wakeModel := flag.String("wake-model", "", "keyword head onnx; empty ships every utterance, as before V-487") + wakeMel := flag.String("wake-mel", "", "melspectrogram.onnx, required with -wake-model") + wakeEmbed := flag.String("wake-embed", "", "embedding_model.onnx, required with -wake-model") + wakeThreshold := flag.Float64("wake-threshold", defaultWakeThreshold, "score the keyword must clear") + wakeWindowMs := flag.Int("wake-window-ms", defaultWakeWindowMs, "ms an utterance may still start after the keyword") flag.CommandLine.Parse(args) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) @@ -171,6 +184,21 @@ func run(args []string) error { } sess := newSession(vad, newAplayPlayer(), &voiceSender{vc: vc}, *lang, barge) + // Keyword gate. A model that will not load is logged and not fatal, for + // the same reason silero's is not: an open gate is the daemon he had + // yesterday, and a daemon that refuses to start is not. + if *wakeModel != "" { + w, err := newWakeWord(*wakeMel, *wakeEmbed, *wakeModel, *onnxLib, *wakeThreshold) + if err != nil { + log.Printf("mavwaked: wake word unavailable, every utterance is a turn: %v", err) + } else { + defer w.Close() + sess.UseWakeWord(w, time.Duration(*wakeWindowMs)*time.Millisecond) + log.Printf("mavwaked: wake word from %s, threshold %.3f, window %dms", + *wakeModel, *wakeThreshold, *wakeWindowMs) + } + } + // Listen for nudges alongside capture. Connect eagerly so mavend has a // voice session before he has said anything: without one, a nudge routed // to voice finds nobody home and goes to the away channels instead. diff --git a/cmd/mavwaked/session.go b/cmd/mavwaked/session.go index 8c0d600..b99ae0e 100644 --- a/cmd/mavwaked/session.go +++ b/cmd/mavwaked/session.go @@ -20,6 +20,15 @@ type utteranceSender interface { Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) } +// keywordGate answers whether the keyword has just been spoken. The +// production one is wakeWord; tests substitute a recorder, because a gate that +// can only be exercised with three ONNX files is a gate nobody tests. +type keywordGate interface { + Feed(frame []int16) bool + Reset() + Score() float64 +} + // bargeInConfig holds the two numbers barge-in needs. Zero Frames disables // barge-in entirely — the half-duplex gate still runs. type bargeInConfig struct { @@ -63,6 +72,14 @@ type session struct { // whenever playback ends. loudFrames int + // wake is the keyword gate, or nil when no model was loaded. wakeUntil is + // how long a keyword stays good for: he says "Мэйвен" and then a sentence, + // and the VAD does not close the utterance until he stops, so the window + // has to outlive the word by the length of what follows it. + wake keywordGate + wakeWindow time.Duration + wakeUntil time.Time + // pending holds a nudge the push receiver handed over, waiting for the // capture loop to speak it. It is the one field written from another // goroutine, hence the mutex; everything else in this struct belongs to @@ -76,6 +93,8 @@ type session struct { bargeIns int // times playback was cut because he spoke over her sent int // utterances shipped to the daemon nudges int // proactive pushes spoken through the speaker + wakes int // times the keyword opened the gate + ignored int // complete utterances dropped because the keyword was absent // loudSum and loudSeen accumulate the energy of suppressed frames, so // the operator can read what the room actually measures and set @@ -88,6 +107,12 @@ func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeI return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge, now: time.Now} } +// UseWakeWord puts the keyword gate in front of dispatch. Without it every +// utterance is shipped, which is what mavwaked did before V-487 stage two. +func (s *session) UseWakeWord(w keywordGate, window time.Duration) { + s.wake, s.wakeWindow = w, window +} + // frameDuration is the wall time one captured frame represents. const frameDuration = defaultFrameMs * time.Millisecond @@ -147,6 +172,7 @@ func (s *session) feed(ctx context.Context, frame []byte) error { s.bargeIns++ s.loudFrames = 0 s.vad.Reset() + s.resetWake() log.Printf("mavwaked: barge-in — stopped playback") s.replayRecent() return nil @@ -157,13 +183,28 @@ func (s *session) feed(ctx context.Context, frame []byte) error { if s.loudFrames != 0 { s.loudFrames = 0 s.vad.Reset() + // The wake word saw nothing during playback, so what it holds is from + // before she spoke. Judging what he says next on it would score a + // sentence that ended a reply ago. + s.resetWake() } if s.startPendingNudge() { return nil } - utt, state := s.vad.Feed(PCMToI16(frame)) + // The keyword is scored on the same frames the VAD sees, and only on the + // ones that reach here: every path above returns while she is speaking, so + // her own voice saying "Мэйвен" cannot wake her. + pcm := PCMToI16(frame) + if s.wake != nil && s.wake.Feed(pcm) { + s.wakes++ + s.wakeUntil = s.now().Add(s.wakeWindow) + log.Printf("mavwaked: keyword heard (score %.3f), listening for %s", + s.wake.Score(), s.wakeWindow) + } + + utt, state := s.vad.Feed(pcm) if state == StateSpeech || utt.Bytes == nil { return nil } @@ -218,6 +259,25 @@ func (s *session) startPendingNudge() bool { return true } +// awake reports whether an utterance ending now was addressed to her. +// +// With no wake word loaded every utterance is, which is exactly what mavwaked +// did before this gate existed. An operator with no model file gets the old +// daemon rather than a daemon that refuses to hear anything. +func (s *session) awake() bool { + if s.wake == nil { + return true + } + return s.now().Before(s.wakeUntil) +} + +// resetWake drops the gate's streaming state when there is a gate. +func (s *session) resetWake() { + if s.wake != nil { + s.wake.Reset() + } +} + // keepRecent stores a copy of one barge-in trigger frame, keeping at most // barge.Frames of them. func (s *session) keepRecent(frame []byte) { @@ -258,6 +318,19 @@ func (s *session) replayRecent() { // whole backlog straight into the VAD, and a Send error did the same on every // failed turn, so a dead socket drove a retry loop off nothing but backlog. func (s *session) dispatch(ctx context.Context, utt audio.Audio) error { + if !s.awake() { + s.ignored++ + log.Printf("mavwaked: utterance ignored, keyword not heard (%.2fs, %d ignored so far)", + utt.Duration(), s.ignored) + s.vad.Reset() + s.resetWake() + return nil + } + // One keyword, one turn. A window that renewed itself on every reply would + // leave the microphone open for as long as he kept talking, which is the + // state this gate exists to end. + s.wakeUntil = time.Time{} + log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...", utt.Duration(), len(utt.Bytes)) start := s.now() reply, err := s.sender.Send(ctx, utt, s.lang) @@ -286,6 +359,7 @@ func (s *session) dispatch(ctx context.Context, utt audio.Audio) error { // recorded before she started speaking. func (s *session) dropBacklog(start time.Time) { s.vad.Reset() + s.resetWake() s.loudFrames = 0 s.recent = s.recent[:0] if elapsed := s.now().Sub(start); elapsed > 0 { diff --git a/cmd/mavwaked/wakefeatures.go b/cmd/mavwaked/wakefeatures.go new file mode 100644 index 0000000..1ba69ae --- /dev/null +++ b/cmd/mavwaked/wakefeatures.go @@ -0,0 +1,187 @@ +package main + +// The three models behind the wake word (V-487 stage two). +// +// openWakeWord's pipeline, run in a row: +// +// audio -> melspectrogram.onnx -> 32-bin mel frames, one per 10ms +// 76 frames -> embedding_model.onnx -> one 96-dim embedding per 80ms +// 16 embeds -> maven_wakeword.onnx -> one score +// +// The first two are frozen and pretrained. Only the last was trained here, +// which is why it is 100KB and the other two are megabytes. The shapes are +// not guesses: 2.0s of 16kHz audio measures 197 mel frames, and 76-frame +// windows at stride 8 give exactly the 16 embeddings the head was fitted on. +// +// This file knows ONNX and nothing about the 80ms cadence. wakeword.go knows +// the cadence and nothing about tensors. + +import ( + "fmt" + + ort "github.com/yalue/onnxruntime_go" +) + +const ( + // melHop — samples per mel frame. 10ms at 16kHz. + melHop = 160 + // melBins — mel bins per frame, fixed by melspectrogram.onnx. + melBins = 32 + // embedFrames — mel frames one embedding is computed over, 760ms. + embedFrames = 76 + // embedStride — mel frames between embeddings, 80ms. + embedStride = 8 + // embedDim — the embedding width. + embedDim = 96 + // headWindow — embeddings the head scores at once, 1.28s of audio. + headWindow = 16 + + // melContext — samples of history prepended to each incremental mel + // call, chosen so the eight frames this call yields continue exactly + // where the previous call's eight stopped. + // + // melspectrogram.onnx returns N/160-3 frames for N samples, and frame i + // covers [i*160, i*160+400). With 480 samples of history the buffer is + // 1760 samples, which is 8 frames, and the oldest of them starts one hop + // after the newest of the previous call. Less history leaves a gap: the + // first frames of a bare chunk would be computed against silence. + melContext = 480 + + // chunkSamples — audio per embedding step, 80ms. + chunkSamples = embedStride * melHop +) + +// wakeModels holds the three ONNX sessions. It runs on CPU threads beside +// silero and never touches the GPU. That is a rule, not a result: a wake word +// that waits on card admission is not a wake word. +type wakeModels struct { + mel *ort.DynamicAdvancedSession + emb *ort.DynamicAdvancedSession + head *ort.DynamicAdvancedSession +} + +// newWakeModels loads all three. melPath and embedPath are openWakeWord's +// frozen feature models; headPath is the keyword head trained for "Мэйвен". +func newWakeModels(melPath, embedPath, headPath, libPath string) (*wakeModels, error) { + if !ort.IsInitialized() { + if libPath != "" { + ort.SetSharedLibraryPath(libPath) + } + if err := ort.InitializeEnvironment(); err != nil { + return nil, fmt.Errorf("wake word: onnx runtime: %w", err) + } + } + open := func(p string, in, out []string) (*ort.DynamicAdvancedSession, error) { + s, err := ort.NewDynamicAdvancedSession(p, in, out, nil) + if err != nil { + return nil, fmt.Errorf("wake word: load %s: %w", p, err) + } + return s, nil + } + m := &wakeModels{} + var err error + if m.mel, err = open(melPath, []string{"input"}, []string{"output"}); err != nil { + return nil, err + } + if m.emb, err = open(embedPath, []string{"input_1"}, []string{"conv2d_19"}); err != nil { + m.Close() + return nil, err + } + if m.head, err = open(headPath, []string{"embeddings"}, []string{"score"}); err != nil { + m.Close() + return nil, err + } + return m, nil +} + +// Close releases the three sessions. +func (m *wakeModels) Close() { + if m == nil { + return + } + for _, s := range []*ort.DynamicAdvancedSession{m.mel, m.emb, m.head} { + if s != nil { + s.Destroy() + } + } + m.mel, m.emb, m.head = nil, nil, nil +} + +// melFrames runs one buffer of samples and returns the mel frames it yielded. +func (m *wakeModels) melFrames(buf []float32) ([][melBins]float32, error) { + in, err := ort.NewTensor(ort.NewShape(1, int64(len(buf))), buf) + if err != nil { + return nil, err + } + defer in.Destroy() + + n := int64(len(buf)/melHop - 3) + if n < 1 { + return nil, fmt.Errorf("wake word: %d samples yield no mel frames", len(buf)) + } + out, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 1, n, melBins)) + if err != nil { + return nil, err + } + defer out.Destroy() + + if err := m.mel.Run([]ort.Value{in}, []ort.Value{out}); err != nil { + return nil, err + } + data := out.GetData() + frames := make([][melBins]float32, n) + for i := range frames { + for j := 0; j < melBins; j++ { + // The scaling openWakeWord applies between the two feature + // models, and the head was fitted on its output. + frames[i][j] = data[i*melBins+j]/10.0 + 2.0 + } + } + return frames, nil +} + +// embedding runs embedFrames mel frames through the frozen embedder. +func (m *wakeModels) embedding(mels [][melBins]float32) ([embedDim]float32, error) { + var e [embedDim]float32 + flat := make([]float32, 0, embedFrames*melBins) + for _, f := range mels { + flat = append(flat, f[:]...) + } + in, err := ort.NewTensor(ort.NewShape(1, embedFrames, melBins, 1), flat) + if err != nil { + return e, err + } + defer in.Destroy() + out, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 1, 1, embedDim)) + if err != nil { + return e, err + } + defer out.Destroy() + if err := m.emb.Run([]ort.Value{in}, []ort.Value{out}); err != nil { + return e, err + } + copy(e[:], out.GetData()) + return e, nil +} + +// score runs the trained head over headWindow embeddings. +func (m *wakeModels) score(embeds [][embedDim]float32) (float64, error) { + flat := make([]float32, 0, headWindow*embedDim) + for _, e := range embeds { + flat = append(flat, e[:]...) + } + in, err := ort.NewTensor(ort.NewShape(1, headWindow, embedDim), flat) + if err != nil { + return 0, err + } + defer in.Destroy() + out, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 1)) + if err != nil { + return 0, err + } + defer out.Destroy() + if err := m.head.Run([]ort.Value{in}, []ort.Value{out}); err != nil { + return 0, err + } + return float64(out.GetData()[0]), nil +} diff --git a/cmd/mavwaked/wakeword.go b/cmd/mavwaked/wakeword.go new file mode 100644 index 0000000..5f83902 --- /dev/null +++ b/cmd/mavwaked/wakeword.go @@ -0,0 +1,191 @@ +package main + +// The wake word, "Мэйвен" (V-487 stage two). +// +// Silero answers "is this frame speech". It does not answer "was this said to +// her", and until this file existed nothing did: every utterance near the +// microphone became a turn. What made that safe rather than expensive was +// SurfaceVoice capping acts at L0, and L0 does not cap reading, so the room +// could still hear his facts read back. +// +// This file owns the 80ms cadence and the three rings of state between the +// models. wakefeatures.go owns the tensors. +// +// Nil is a working value, and it is the CLOSED gate rather than the open one. +// Feed on a nil receiver reports no keyword; session.go asks separately +// whether a gate exists at all. That split is deliberate: a nil that answers +// "yes, keyword" reads as a working wake word in every log line it produces. + +import ( + "log" + "sync" +) + +// defaultWakeThreshold — score above which the keyword was said. Picked from +// the false-accept rate on held-out Russian speech, not from accuracy: a miss +// costs him a repeat, a false accept costs a turn nobody asked for. See +// docs/evals for the wakes-per-hour this buys. +const defaultWakeThreshold = 0.99 + +// wakeWord is the streaming state around wakeModels. It is fed the same +// capture frames the VAD sees and answers whether the keyword has just been +// spoken. +type wakeWord struct { + mu sync.Mutex + m *wakeModels + + threshold float64 + + // pending holds captured samples not yet part of a full 80ms chunk, and + // history holds the melContext samples before them. + pending []float32 + history []float32 + + // mels is the newest embedFrames mel frames, oldest first. + mels [][melBins]float32 + // embeds is the newest headWindow embeddings, oldest first. + embeds [][embedDim]float32 + + last float64 // most recent score, held between chunks +} + +// newWakeWord loads the models and wraps them in the streaming gate. +func newWakeWord(melPath, embedPath, headPath, libPath string, threshold float64) (*wakeWord, error) { + m, err := newWakeModels(melPath, embedPath, headPath, libPath) + if err != nil { + return nil, err + } + if threshold <= 0 { + threshold = defaultWakeThreshold + } + return &wakeWord{m: m, threshold: threshold}, nil +} + +// Close releases the models. +func (w *wakeWord) Close() { + if w == nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + w.m.Close() + w.m = nil +} + +// Feed takes one capture frame and reports whether the keyword was heard on +// it. A nil wakeWord hears nothing. +func (w *wakeWord) Feed(frame []int16) bool { + if w == nil { + return false + } + w.mu.Lock() + defer w.mu.Unlock() + + for _, v := range frame { + w.pending = append(w.pending, float32(v)/32768.0) + } + fired := false + for len(w.pending) >= chunkSamples { + chunk := w.pending[:chunkSamples] + if w.step(chunk) { + fired = true + } + w.history = append(w.history[:0], tailFloat32(append(w.history, chunk...), melContext)...) + // Slide the remainder to the front rather than reslicing. This runs + // every 80ms for as long as the daemon lives. + w.pending = append(w.pending[:0], w.pending[chunkSamples:]...) + } + return fired +} + +// Reset drops the streaming state, so a fresh utterance is not judged on audio +// from before it. Called after every dispatch and after barge-in, for the same +// reason silero is: echo-era history must not score the next sentence, and her +// own voice saying the keyword must not wake her. +func (w *wakeWord) Reset() { + if w == nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + w.pending, w.history = w.pending[:0], w.history[:0] + w.mels, w.embeds = nil, nil + w.last = 0 +} + +// Score returns the most recent score, for the operator to read out of the +// journal when picking a threshold for his room. +func (w *wakeWord) Score() float64 { + if w == nil { + return 0 + } + w.mu.Lock() + defer w.mu.Unlock() + return w.last +} + +// step runs one 80ms chunk through all three models. It returns true when the +// score crosses the threshold on this chunk. +func (w *wakeWord) step(chunk []float32) bool { + buf := make([]float32, 0, melContext+len(chunk)) + if pad := melContext - len(w.history); pad > 0 { + buf = append(buf, make([]float32, pad)...) + } + buf = append(buf, tailFloat32(w.history, melContext)...) + buf = append(buf, chunk...) + + frames, err := w.m.melFrames(buf) + if err != nil { + // A failed inference must not silence the microphone. Hold the last + // score and let the next chunk try again. + log.Printf("mavwaked: wake word: mel: %v", err) + return false + } + w.mels = tailMel(append(w.mels, frames...), embedFrames) + if len(w.mels) < embedFrames { + return false + } + e, err := w.m.embedding(w.mels) + if err != nil { + log.Printf("mavwaked: wake word: embedding: %v", err) + return false + } + w.embeds = tailEmbed(append(w.embeds, e), headWindow) + if len(w.embeds) < headWindow { + return false + } + score, err := w.m.score(w.embeds) + if err != nil { + log.Printf("mavwaked: wake word: head: %v", err) + return false + } + // Report the crossing, not the state. A keyword held above the threshold + // for a second is one wake, and firing on every chunk of it would make the + // gate look open when it is merely slow to fall. + crossed := score >= w.threshold && w.last < w.threshold + w.last = score + return crossed +} + +// The three rings. Each keeps the newest n entries and nothing older. + +func tailFloat32(s []float32, n int) []float32 { + if len(s) <= n { + return s + } + return s[len(s)-n:] +} + +func tailMel(s [][melBins]float32, n int) [][melBins]float32 { + if len(s) <= n { + return s + } + return append(s[:0], s[len(s)-n:]...) +} + +func tailEmbed(s [][embedDim]float32, n int) [][embedDim]float32 { + if len(s) <= n { + return s + } + return append(s[:0], s[len(s)-n:]...) +} 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() +}