Diff
1diff --git a/internal/adapters/speech.go b/internal/adapters/speech.go
2index e7a93bd3edcfa56b253f735ec229d93a5512afa0..cf3d96d3b84cd361455a852529d5c823ba87b2b8 100644
3--- a/internal/adapters/speech.go
4+++ b/internal/adapters/speech.go
5@@ -2,7 +2,9 @@ package adapters
6
7 import (
8 "context"
9+ "errors"
10 "fmt"
11+ "sync"
12
13 "japanese/internal/stt"
14 "japanese/internal/tts"
15@@ -49,6 +51,9 @@ type SpeechOutput struct {
16 Client *tts.Client
17 Player *tts.Player
18 OnDone func()
19+
20+ mu sync.Mutex
21+ last []byte
22 }
23
24 func (s *SpeechOutput) Speak(ctx context.Context, kana string) error {
25@@ -56,5 +61,22 @@ func (s *SpeechOutput) Speak(ctx context.Context, kana string) error {
26 if err != nil {
27 return err
28 }
29+ s.mu.Lock()
30+ cp := make([]byte, len(wav))
31+ copy(cp, wav)
32+ s.last = cp
33+ s.mu.Unlock()
34+ return s.Player.Play(wav, s.OnDone)
35+}
36+
37+// Replay plays the last synthesized clip without re-synthesizing it. It returns
38+// an error if no clip has been synthesized yet.
39+func (s *SpeechOutput) Replay() error {
40+ s.mu.Lock()
41+ wav := s.last
42+ s.mu.Unlock()
43+ if len(wav) == 0 {
44+ return errors.New("speech: nothing to replay")
45+ }
46 return s.Player.Play(wav, s.OnDone)
47 }
48diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
49index f4c0f96d8bce8dca6342755f51c3c7997696b2ab..a575faffa64891ea1f1eb4a92f9fddfbe587f89b 100644
50--- a/internal/game/orchestrator.go
51+++ b/internal/game/orchestrator.go
52@@ -17,9 +17,11 @@ type SpeechInput interface {
53 End(ctx context.Context) (string, error)
54 }
55
56-// SpeechOutput synthesizes and plays an NPC reply's audio from its kana.
57+// SpeechOutput synthesizes and plays an NPC reply's audio from its kana, and can
58+// replay the last line it produced without re-synthesizing.
59 type SpeechOutput interface {
60 Speak(ctx context.Context, kana string) error
61+ Replay() error
62 }
63
64 // JudgeModel scores one spoken line. It is independent of the game model.
65@@ -56,6 +58,9 @@ const newPartnerNudge = "(The scene continues. The person the player is now talk
66 // ErrEmptyTranscript is returned when push-to-talk produced no usable transcript.
67 var ErrEmptyTranscript = errors.New("game: empty transcript")
68
69+// ErrNoSpeech is returned by ReplayLast before any NPC has spoken yet.
70+var ErrNoSpeech = errors.New("game: nothing to replay yet")
71+
72 // TurnResult reports what happened in one turn. Reply is always set unless Err is
73 // non-nil (no reply was produced, so the session did not advance). Judge and
74 // JudgeErr apply to spoken turns only. SpeakErr is a recoverable TTS failure.
75@@ -82,6 +87,7 @@ type Orchestrator struct {
76 speechIn SpeechInput
77 speechOut SpeechOutput
78
79+ lastKana string
80 staticChars int
81 budget int
82 }
83@@ -107,6 +113,15 @@ func (o *Orchestrator) Begin(ctx context.Context) error {
84 return o.speechIn.Begin(ctx)
85 }
86
87+// ReplayLast re-plays the last line an NPC said from cache (no re-synthesis).
88+// It returns ErrNoSpeech if no NPC has spoken yet. It does not touch game state.
89+func (o *Orchestrator) ReplayLast() error {
90+ if o.lastKana == "" {
91+ return ErrNoSpeech
92+ }
93+ return o.speechOut.Replay()
94+}
95+
96 // ActionTurn runs one typed action: record it, advance the game loop, handle any
97 // location change, play speech if the reply includes a line, and log the turn.
98 func (o *Orchestrator) ActionTurn(ctx context.Context, action string) TurnResult {
99@@ -126,6 +141,7 @@ func (o *Orchestrator) ActionTurn(ctx context.Context, action string) TurnResult
100 shown, desc := o.handleNewPartner(ctx, reply)
101
102 if hasSpeech(shown) {
103+ o.lastKana = shown.Kana
104 if err := o.speechOut.Speak(ctx, shown.Kana); err != nil {
105 res.SpeakErr = err
106 }
107@@ -204,6 +220,7 @@ func (o *Orchestrator) FinishSpeak(ctx context.Context) TurnResult {
108 shown, desc := o.handleNewPartner(ctx, reply)
109
110 if hasSpeech(shown) {
111+ o.lastKana = shown.Kana
112 if err := o.speechOut.Speak(ctx, shown.Kana); err != nil {
113 res.SpeakErr = err
114 }
115diff --git a/internal/ui/app.go b/internal/ui/app.go
116index 602795f86918404fd6f1f88ca446bc4eaf197b67..43f824a738b97e2c21fab9b6e2cb21168a8c106a 100644
117--- a/internal/ui/app.go
118+++ b/internal/ui/app.go
119@@ -2,6 +2,7 @@ package ui
120
121 import (
122 "context"
123+ "errors"
124 "fmt"
125 "strings"
126 "time"
127@@ -39,6 +40,7 @@ const (
128
129 type beginResultMsg struct{ err error }
130 type finishResultMsg struct{ res game.TurnResult }
131+type replayResultMsg struct{ err error }
132 type tickMsg struct{}
133
134 // model is the Bubble Tea state. It owns only UI-local fields (input buffer,
135@@ -51,6 +53,7 @@ type model struct {
136
137 phase phase
138 beginning bool
139+ replaying bool
140 recSince time.Time
141
142 input textinput.Model
143@@ -87,6 +90,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
144 m.applyBegin(v.err)
145 case finishResultMsg:
146 m.applyFinish(v.res)
147+ case replayResultMsg:
148+ m.applyReplay(v.err)
149 case tickMsg:
150 if m.phase == phaseRecording {
151 cmd = nextTick()
152@@ -116,6 +121,12 @@ func (m *model) handleKey(k tea.KeyMsg) tea.Cmd {
153 if m.phase == phaseIdle {
154 m.state.RevealEnglish()
155 }
156+ case tea.KeyF5:
157+ if m.phase == phaseIdle {
158+ m.replaying = true
159+ m.phase = phaseProcessing
160+ return replayTurn(m.orch)
161+ }
162 case tea.KeyEnter:
163 return m.submit()
164 default:
165@@ -185,6 +196,20 @@ func (m *model) applyFinish(res game.TurnResult) {
166 m.phase = phaseIdle
167 }
168
169+// applyReplay maps a completed replay onto UI-local state. On success the audio
170+// is playing and no status is shown; on failure it surfaces a transient note.
171+func (m *model) applyReplay(err error) {
172+ m.replaying = false
173+ m.phase = phaseIdle
174+ switch {
175+ case err == nil:
176+ case errors.Is(err, game.ErrNoSpeech):
177+ m.status = "nothing to replay yet"
178+ default:
179+ m.status = "replay failed: " + err.Error()
180+ }
181+}
182+
183 func turnStatus(res game.TurnResult) string {
184 switch {
185 case res.Err != nil:
186@@ -223,6 +248,9 @@ func (m *model) phaseWord() string {
187 case phaseRecording:
188 return "recording"
189 case phaseProcessing:
190+ if m.replaying {
191+ return "replaying"
192+ }
193 return "processing"
194 }
195 return "idle"
196@@ -320,6 +348,12 @@ func askTurn(o *game.Orchestrator, question string) tea.Cmd {
197 }
198 }
199
200+func replayTurn(o *game.Orchestrator) tea.Cmd {
201+ return func() tea.Msg {
202+ return replayResultMsg{err: o.ReplayLast()}
203+ }
204+}
205+
206 func nextTick() tea.Cmd {
207 return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} })
208 }
209diff --git a/internal/ui/render.go b/internal/ui/render.go
210index 35f97ccc464a2e3da919b3c5562913e143d9f3b5..0907c5f00f441eaf7e27980d3c59066e33a06047 100644
211--- a/internal/ui/render.go
212+++ b/internal/ui/render.go
213@@ -9,7 +9,7 @@ import (
214 )
215
216 // controlsHint is the idle status line. It lists only keys wired in this build.
217-const controlsHint = "F2 talk · F3 romaji · F4 english · ? ask · Esc quit"
218+const controlsHint = "F2 talk · F3 romaji · F4 english · F5 replay · ? ask · Esc quit"
219
220 // viewState is the plain, render-only snapshot the pure render functions take.
221 // It holds no pointers and no game types, so rendering stays trivially pure.
222@@ -64,6 +64,8 @@ func statusLine(status, phaseWord string, recElapsed, recCap int) string {
223 return "thinking… " + sanitizeRomajiOnly(status)
224 }
225 return "thinking…"
226+ case "replaying":
227+ return "replaying…"
228 }
229 if status != "" {
230 return sanitizeRomajiOnly(status) + " " + hintStyle.Render(controlsHint)