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