d0ea927ac3
It reaches the player, but not from the push goroutine. An unusable push is dropped and does not wedge the next one. It waits for a reply to finish. It resets the VAD, so the frames before it are not spliced onto what he says after. And a second nudge replaces an unspoken first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
171 lines
5.3 KiB
Go
171 lines
5.3 KiB
Go
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))
|
|
}
|
|
}
|