diff --git a/cmd/mavwaked/nudge_test.go b/cmd/mavwaked/nudge_test.go new file mode 100644 index 0000000..20102af --- /dev/null +++ b/cmd/mavwaked/nudge_test.go @@ -0,0 +1,170 @@ +package main + +// The receiving half: a nudge pushed by mavend has to reach the speaker, and +// it has to obey the same two gates a reply obeys (V-671). + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/voice" +) + +func nudgeAudio() audio.Audio { + return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 8000)} +} + +// pushFrame builds the frame mavend's voicesink sends. +func pushFrame(t *testing.T, a audio.Audio) voice.Push { + t.Helper() + body, err := json.Marshal(voice.AudioNudgePush{ + RuleName: "test-rule", + Severity: 3, + Audio: a, + Text: "пора пить воду", + Ts: time.Unix(0, 0), + }) + if err != nil { + t.Fatalf("marshal push: %v", err) + } + return voice.Push{Kind: voice.PushKindAudioNudge, Params: body} +} + +// The defect itself: the push arrived and nothing came out of the speaker. +func TestNudgeReachesThePlayer(t *testing.T) { + sess, p, snd := newTestSession(bargeInConfig{}) + (&nudgeHandler{sess: sess}).OnPush(pushFrame(t, nudgeAudio())) + + if p.plays != 0 { + t.Fatal("nudge played from the push goroutine; it must wait for the capture loop") + } + if err := sess.feed(context.Background(), silentBytes()); err != nil { + t.Fatalf("feed: %v", err) + } + if p.plays != 1 { + t.Fatalf("plays = %d, want 1", p.plays) + } + if len(p.last.Bytes) != 8000 { + t.Errorf("played %d bytes, want the nudge audio", len(p.last.Bytes)) + } + if sess.nudges != 1 { + t.Errorf("nudges = %d, want 1", sess.nudges) + } + if len(snd.sent) != 0 { + t.Errorf("a nudge must not be shipped back to the daemon as an utterance") + } +} + +// A push of some other kind, or one carrying no audio, must not reach the +// player and must not wedge the one that follows. +func TestNudgeIgnoresUnusablePushes(t *testing.T) { + sess, p, _ := newTestSession(bargeInConfig{}) + h := &nudgeHandler{sess: sess} + + h.OnPush(voice.Push{Kind: "something-else", Params: json.RawMessage(`{}`)}) + h.OnPush(voice.Push{Kind: voice.PushKindAudioNudge, Params: json.RawMessage(`not json`)}) + h.OnPush(pushFrame(t, audio.Audio{Format: audio.PCM16kMono})) + + if err := sess.feed(context.Background(), silentBytes()); err != nil { + t.Fatalf("feed: %v", err) + } + if p.plays != 0 { + t.Fatalf("plays = %d, want 0", p.plays) + } + + h.OnPush(pushFrame(t, nudgeAudio())) + if err := sess.feed(context.Background(), silentBytes()); err != nil { + t.Fatalf("feed: %v", err) + } + if p.plays != 1 { + t.Fatalf("plays after a usable nudge = %d, want 1", p.plays) + } +} + +// The half-duplex gate covers a nudge exactly as it covers a reply: she does +// not start one over herself, and the mic stays muted while it runs. +func TestNudgeWaitsForTheReplyToFinish(t *testing.T) { + sess, p, _ := newTestSession(bargeInConfig{}) + speakThenPause(t, sess) + if !p.Playing() { + t.Fatal("expected the reply to be playing") + } + plays := p.plays + + (&nudgeHandler{sess: sess}).OnPush(pushFrame(t, nudgeAudio())) + for i := 0; i < 20; i++ { + if err := sess.feed(context.Background(), silentBytes()); err != nil { + t.Fatalf("feed: %v", err) + } + } + if p.plays != plays { + t.Fatalf("nudge cut across the reply: plays = %d, want %d", p.plays, plays) + } + + p.playing = false + if err := sess.feed(context.Background(), silentBytes()); err != nil { + t.Fatalf("feed: %v", err) + } + if p.plays != plays+1 { + t.Fatalf("nudge never played after the reply ended: plays = %d", p.plays) + } +} + +// Speaking a nudge must not leave half a sentence in the VAD. The frames +// captured before it are pre-nudge speech, and splicing them onto whatever he +// says afterwards ships one utterance that is two. +func TestNudgeResetsTheVAD(t *testing.T) { + sess, p, snd := newTestSession(bargeInConfig{}) + loud := frameAt(0.35) + speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs + for i := 0; i < speechFrames+5; i++ { + if err := sess.feed(context.Background(), loud); err != nil { + t.Fatalf("feed: %v", err) + } + } + + (&nudgeHandler{sess: sess}).OnPush(pushFrame(t, nudgeAudio())) + if err := sess.feed(context.Background(), silentBytes()); err != nil { + t.Fatalf("feed: %v", err) + } + if p.plays != 1 { + t.Fatalf("nudge did not play: plays = %d", p.plays) + } + + // Playback ends, silence follows. The half-formed utterance must be gone + // rather than closing on the first quiet frame. + p.playing = false + silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2 + for i := 0; i < silenceFrames; i++ { + if err := sess.feed(context.Background(), silentBytes()); err != nil { + t.Fatalf("feed: %v", err) + } + } + if len(snd.sent) != 0 { + t.Fatalf("sent %d utterances after a nudge, want 0", len(snd.sent)) + } +} + +// Two nudges queued back to back: the newer one is what he hears. The +// PushHandler contract in internal/voice says the next nudge replaces the +// stale one rather than dogpiling on it. +func TestNudgeReplacesAnUnspokenOne(t *testing.T) { + sess, p, _ := newTestSession(bargeInConfig{}) + h := &nudgeHandler{sess: sess} + + h.OnPush(pushFrame(t, audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 4000)})) + h.OnPush(pushFrame(t, audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 12000)})) + + if err := sess.feed(context.Background(), silentBytes()); err != nil { + t.Fatalf("feed: %v", err) + } + if p.plays != 1 { + t.Fatalf("plays = %d, want 1", p.plays) + } + if len(p.last.Bytes) != 12000 { + t.Errorf("played %d bytes, want the newer nudge", len(p.last.Bytes)) + } +}