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)) } }