1d9a6bc0113973415184639d4430bc476ac371fe

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

tts: add playback completion signal

Diff

  1diff --git a/cmd/jp/main.go b/cmd/jp/main.go
  2index 5c020cd77cfbc93ce46217e09022c30c5125fa6d..7dba3f8f47d3c65b2a77be9d0bf6a561346291cd 100644
  3--- a/cmd/jp/main.go
  4+++ b/cmd/jp/main.go
  5@@ -71,9 +71,10 @@ func runCmd(args []string) {
  6 	}
  7 
  8 	state := game.NewState(mapData)
  9-	orch := buildOrchestrator(cfg, state, personas)
 10+	playDone := make(chan struct{}, 1)
 11+	orch := buildOrchestrator(cfg, state, personas, playDone)
 12 
 13-	m := ui.NewModel(state, orch, services, int(recordCap.Seconds()))
 14+	m := ui.NewModel(state, orch, services, playDone, int(recordCap.Seconds()))
 15 	if _, err := tea.NewProgram(m).Run(); err != nil {
 16 		fatalf("run: %v", err)
 17 	}
 18@@ -85,7 +86,7 @@ func probeServices(cfg config.Config) []availability.Service {
 19 	return availability.CheckAll(ctx, cfg)
 20 }
 21 
 22-func buildOrchestrator(cfg config.Config, state *game.State, personas []persona.Persona) *game.Orchestrator {
 23+func buildOrchestrator(cfg config.Config, state *game.State, personas []persona.Persona, playDone chan struct{}) *game.Orchestrator {
 24 	hc := &http.Client{Timeout: 90 * time.Second}
 25 	llmClient := llm.NewClient(cfg.LLMBaseURL, hc)
 26 	npcModel := &adapters.NPCModel{Client: llmClient, Policy: llm.DefaultHistoryPolicy()}
 27@@ -103,6 +104,12 @@ func buildOrchestrator(cfg config.Config, state *game.State, personas []persona.
 28 	speechOut := &adapters.SpeechOutput{
 29 		Client: tts.NewClient(cfg.TTSBaseURL, hc),
 30 		Player: tts.NewPlayer(),
 31+		OnDone: func() {
 32+			select {
 33+			case playDone <- struct{}{}:
 34+			default:
 35+			}
 36+		},
 37 	}
 38 
 39 	return game.NewOrchestrator(state, buildPersonaViews(personas), speechIn, npcModel, judgeModel, speechOut)
 40diff --git a/internal/adapters/speech.go b/internal/adapters/speech.go
 41index 2fabeaf93032bab86a2922e085e5923ef39d114b..b84e9e1d9f201b8b8de5169d466c6701a3db5528 100644
 42--- a/internal/adapters/speech.go
 43+++ b/internal/adapters/speech.go
 44@@ -46,10 +46,12 @@ func (s *SpeechInput) End(ctx context.Context) (game.Transcript, error) {
 45 }
 46 
 47 // SpeechOutput adapts the tts client and player to game.SpeechOutput. It
 48-// synthesizes the NPC reply's kana and plays it without blocking.
 49+// synthesizes the NPC reply's kana and plays it without blocking. OnDone, when
 50+// set, is forwarded to the player and fires once the clip finishes normally.
 51 type SpeechOutput struct {
 52 	Client *tts.Client
 53 	Player *tts.Player
 54+	OnDone func()
 55 }
 56 
 57 func (s *SpeechOutput) Speak(ctx context.Context, kana string) error {
 58@@ -57,5 +59,5 @@ func (s *SpeechOutput) Speak(ctx context.Context, kana string) error {
 59 	if err != nil {
 60 		return err
 61 	}
 62-	return s.Player.Play(wav)
 63+	return s.Player.Play(wav, s.OnDone)
 64 }
 65diff --git a/internal/adapters/speech_test.go b/internal/adapters/speech_test.go
 66index 9a42959e6e1f5befe199b3644de108f8d1500797..e3a33901ad09f0e27b2f98b11188c46ddb45c246 100644
 67--- a/internal/adapters/speech_test.go
 68+++ b/internal/adapters/speech_test.go
 69@@ -6,6 +6,7 @@ import (
 70 	"net/http/httptest"
 71 	"strings"
 72 	"testing"
 73+	"time"
 74 
 75 	"japanese/internal/tts"
 76 )
 77@@ -22,3 +23,25 @@ func TestSpeechOutputSpeakErrorNamesURL(t *testing.T) {
 78 		t.Errorf("expected TTS error naming URL %q, got %v", srv.URL, err)
 79 	}
 80 }
 81+
 82+func TestSpeechOutputSpeakErrorDoesNotFireOnDone(t *testing.T) {
 83+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 84+		http.Error(w, "boom", http.StatusInternalServerError)
 85+	}))
 86+	defer srv.Close()
 87+
 88+	fired := make(chan struct{}, 1)
 89+	out := &SpeechOutput{
 90+		Client: tts.NewClient(srv.URL, srv.Client()),
 91+		Player: tts.NewPlayer(),
 92+		OnDone: func() { fired <- struct{}{} },
 93+	}
 94+	if err := out.Speak(context.Background(), "こんにちは"); err == nil {
 95+		t.Fatal("expected a TTS error")
 96+	}
 97+	select {
 98+	case <-fired:
 99+		t.Fatal("OnDone fired although speech failed")
100+	case <-time.After(50 * time.Millisecond):
101+	}
102+}
103diff --git a/internal/tts/player.go b/internal/tts/player.go
104index 227b23098c5b40e15eca7f4530ad592dc92a1092..c48ffaa47566cf05fe203bd3dd6cdb3861820b50 100644
105--- a/internal/tts/player.go
106+++ b/internal/tts/player.go
107@@ -22,6 +22,10 @@ const nullPlayDuration = 40 * time.Millisecond
108 // loop is never blocked. A new Play interrupts an in-flight playback, and Stop
109 // interrupts one explicitly. With nullOutput set, playback runs without opening
110 // an audio device.
111+//
112+// Each Play takes an onDone callback that fires exactly once when that clip
113+// finishes playing normally. An interrupted clip (a newer Play, or Stop) never
114+// fires onDone, so callers can distinguish "played to the end" from "cut off".
115 type Player struct {
116 	mu         sync.Mutex
117 	ctx        *oto.Context
118@@ -34,9 +38,10 @@ func NewPlayer() *Player { return &Player{} }
119 
120 // Play plays wav without blocking. It returns an error only if the WAV is
121 // invalid; audio-device failures are not surfaced (the clip simply does not
122-// play). Starting a new playback interrupts any in-flight one. The slice may be
123-// used briefly after Play returns, so do not reuse it.
124-func (p *Player) Play(wav []byte) error {
125+// play). Starting a new playback interrupts any in-flight one. onDone may be
126+// nil; when set it fires once the clip finishes normally, never on interrupt.
127+// The slice may be used briefly after Play returns, so do not reuse it.
128+func (p *Player) Play(wav []byte, onDone func()) error {
129 	spec, err := ParseWAV(wav)
130 	if err != nil {
131 		return err
132@@ -50,7 +55,7 @@ func (p *Player) Play(wav []byte) error {
133 		prev() // interrupt any in-flight playback
134 	}
135 	atomic.AddInt32(&p.active, 1)
136-	go p.play(ctx, spec)
137+	go p.play(ctx, spec, onDone)
138 	return nil
139 }
140 
141@@ -65,17 +70,25 @@ func (p *Player) Stop() {
142 	}
143 }
144 
145-func (p *Player) play(ctx context.Context, spec *WAVSpec) {
146+func (p *Player) play(ctx context.Context, spec *WAVSpec, onDone func()) {
147 	defer atomic.AddInt32(&p.active, -1)
148 
149 	data := make([]byte, len(spec.Data))
150 	copy(data, spec.Data)
151 
152+	finish := func() {
153+		if onDone != nil {
154+			onDone()
155+		}
156+	}
157+
158 	if p.nullOutput {
159 		select {
160 		case <-ctx.Done():
161+			return
162 		case <-time.After(nullPlayDuration):
163 		}
164+		finish()
165 		return
166 	}
167 
168@@ -109,6 +122,7 @@ func (p *Player) play(ctx context.Context, spec *WAVSpec) {
169 		}
170 	}
171 	pl.Close()
172+	finish()
173 }
174 
175 func (p *Player) isPlaying() bool { return atomic.LoadInt32(&p.active) > 0 }
176diff --git a/internal/tts/player_test.go b/internal/tts/player_test.go
177index 2d194a9c5930ba55255e2b28e9495820744bd5b3..140a5fb5078adc0d2e9d5f328c984a2789f74a6c 100644
178--- a/internal/tts/player_test.go
179+++ b/internal/tts/player_test.go
180@@ -7,7 +7,7 @@ import (
181 
182 func TestPlayRejectsInvalidWAV(t *testing.T) {
183 	p := NewPlayer()
184-	if err := p.Play([]byte("not a wav")); err == nil {
185+	if err := p.Play([]byte("not a wav"), nil); err == nil {
186 		t.Error("expected an error for an invalid WAV, got nil")
187 	}
188 }
189@@ -15,7 +15,7 @@ func TestPlayRejectsInvalidWAV(t *testing.T) {
190 func TestPlayNullOutputNoDevice(t *testing.T) {
191 	p := &Player{nullOutput: true}
192 	wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
193-	if err := p.Play(wav); err != nil {
194+	if err := p.Play(wav, nil); err != nil {
195 		t.Fatalf("Play: %v", err)
196 	}
197 	waitForIdle(t, p)
198@@ -24,7 +24,7 @@ func TestPlayNullOutputNoDevice(t *testing.T) {
199 func TestStopInterruptsPlayback(t *testing.T) {
200 	p := &Player{nullOutput: true}
201 	wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
202-	if err := p.Play(wav); err != nil {
203+	if err := p.Play(wav, nil); err != nil {
204 		t.Fatalf("Play: %v", err)
205 	}
206 	p.Stop()
207@@ -34,15 +34,47 @@ func TestStopInterruptsPlayback(t *testing.T) {
208 func TestNewPlaybackInterruptsPrevious(t *testing.T) {
209 	p := &Player{nullOutput: true}
210 	wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
211-	if err := p.Play(wav); err != nil {
212+	if err := p.Play(wav, nil); err != nil {
213 		t.Fatalf("Play first: %v", err)
214 	}
215-	if err := p.Play(wav); err != nil {
216+	if err := p.Play(wav, nil); err != nil {
217 		t.Fatalf("Play second: %v", err)
218 	}
219 	waitForIdle(t, p)
220 }
221 
222+func TestPlayCompletionFiresOnNullOutput(t *testing.T) {
223+	p := &Player{nullOutput: true}
224+	wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
225+	done := make(chan struct{}, 1)
226+	if err := p.Play(wav, func() { done <- struct{}{} }); err != nil {
227+		t.Fatalf("Play: %v", err)
228+	}
229+	select {
230+	case <-done:
231+	case <-time.After(2 * time.Second):
232+		t.Fatal("completion did not fire for a finished clip")
233+	}
234+	waitForIdle(t, p)
235+}
236+
237+func TestInterruptedPlayDoesNotFireCompletion(t *testing.T) {
238+	p := &Player{nullOutput: true}
239+	wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
240+	done := make(chan struct{}, 1)
241+	if err := p.Play(wav, func() { done <- struct{}{} }); err != nil {
242+		t.Fatalf("Play: %v", err)
243+	}
244+	p.Stop()
245+	time.Sleep(50 * time.Millisecond) // let the play goroutine observe the cancel
246+	select {
247+	case <-done:
248+		t.Fatal("interrupted playback fired a normal completion")
249+	default:
250+	}
251+	waitForIdle(t, p)
252+}
253+
254 func waitForIdle(t *testing.T, p *Player) {
255 	t.Helper()
256 	deadline := time.Now().Add(2 * time.Second)
257diff --git a/internal/ui/app.go b/internal/ui/app.go
258index d44f26c444284090963c6ce10f5bbf6f4a046213..6bd5d0c90a6f603dec7784b704a7663d8a2edbb4 100644
259--- a/internal/ui/app.go
260+++ b/internal/ui/app.go
261@@ -28,11 +28,13 @@ const (
262 type beginResultMsg struct{ err error }
263 type finishResultMsg struct{ res game.TurnResult }
264 type tickMsg struct{ t time.Time }
265+type playDoneMsg struct{}
266 
267 type model struct {
268 	state        *game.State
269 	orch         *game.Orchestrator
270 	services     []availability.Service
271+	playDone     chan struct{}
272 	recordCapSec int
273 
274 	phase     talkPhase
275@@ -48,16 +50,17 @@ type model struct {
276 	status           string
277 }
278 
279-func NewModel(state *game.State, orch *game.Orchestrator, services []availability.Service, recordCapSec int) *model {
280+func NewModel(state *game.State, orch *game.Orchestrator, services []availability.Service, playDone chan struct{}, recordCapSec int) *model {
281 	return &model{
282 		state:        state,
283 		orch:         orch,
284 		services:     services,
285+		playDone:     playDone,
286 		recordCapSec: recordCapSec,
287 	}
288 }
289 
290-func (m *model) Init() tea.Cmd { return nil }
291+func (m *model) Init() tea.Cmd { return m.listenPlayDone() }
292 
293 func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
294 	var cmds []tea.Cmd
295@@ -70,6 +73,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
296 		m.applyBegin(v.err)
297 	case finishResultMsg:
298 		m.applyFinish(v.res)
299+	case playDoneMsg:
300+		if m.npcStatus == "speaking" {
301+			m.npcStatus = "done"
302+		}
303+		if cmd := m.listenPlayDone(); cmd != nil {
304+			cmds = append(cmds, cmd)
305+		}
306 	case tickMsg:
307 		if m.phase == phaseRecording {
308 			cmds = append(cmds, nextTick())
309@@ -263,6 +273,19 @@ func nextTick() tea.Cmd {
310 	return tea.Tick(tickInterval, func(t time.Time) tea.Msg { return tickMsg{t} })
311 }
312 
313+// listenPlayDone blocks a worker until the player reports a finished clip, then
314+// delivers playDoneMsg. It returns nil when no completion channel is wired (for
315+// example in tests), so Update can stop rescheduling.
316+func (m *model) listenPlayDone() tea.Cmd {
317+	if m.playDone == nil {
318+		return nil
319+	}
320+	return func() tea.Msg {
321+		<-m.playDone
322+		return playDoneMsg{}
323+	}
324+}
325+
326 func batch(cmds []tea.Cmd) tea.Cmd {
327 	if len(cmds) == 0 {
328 		return nil
329diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go
330index 071ccca50c3075599905daf1fd002a95baa4f536..a8b19d25d1d5867b3839d3c2bb77882178dfe5ce 100644
331--- a/internal/ui/app_test.go
332+++ b/internal/ui/app_test.go
333@@ -54,7 +54,7 @@ func buildModel(t *testing.T) (*model, *game.State) {
334 		map[string]game.PersonaView{"ramen": {Description: "d", Situation: "s"}},
335 		fakeSpeech{}, fakeNPC{}, fakeJudge{}, fakeOut{},
336 	)
337-	m := NewModel(st, orch, nil, 10)
338+	m := NewModel(st, orch, nil, nil, 10)
339 	return m, st
340 }
341 
342@@ -146,6 +146,21 @@ func TestStopIgnoredWhileBeginInFlight(t *testing.T) {
343 	}
344 }
345 
346+func TestNpcSpeakingTransitionsToDone(t *testing.T) {
347+	m, _ := buildModel(t)
348+	res := game.TurnResult{
349+		Transcript: game.Transcript{Romaji: "ra"},
350+		Judge:      llm.JudgeResult{Score: 80, Feedback: "ok"},
351+		NPC:        llm.NPCReply{Romaji: "r", English: "e"},
352+	}
353+	if _, _ = m.Update(finishResultMsg{res: res}); m.npcStatus != "speaking" {
354+		t.Fatalf("after finish npcStatus = %q, want speaking", m.npcStatus)
355+	}
356+	if _, _ = m.Update(playDoneMsg{}); m.npcStatus != "done" {
357+		t.Fatalf("after play done npcStatus = %q, want done", m.npcStatus)
358+	}
359+}
360+
361 func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
362 
363 func TestRevealOrdering(t *testing.T) {