deeab26bae9107e96e068c3ab91e96e6e30e2a73

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

Message

Add F5 to replay the last spoken line

Replays the cached WAV from memory instead of re-synthesizing. The orchestrator tracks the last spoken kana and the speech adapter caches each synthesized clip.

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)