4438762999b7290fa5ea0aaa87611c678eb6001f

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

Message

stt: replace kagome romanization with judge-provided romaji

Diff

This diff is truncated to protect this page.

  1diff --git a/PLAN.md b/PLAN.md
  2index 119a21b91732db7ba2482ce7b809cf246ecc8316..d03c04534970f6b218536cc5fd82b0af1e8ce4b6 100644
  3--- a/PLAN.md
  4+++ b/PLAN.md
  5@@ -161,7 +161,7 @@ Do not create `internal/servers` or a `models/` directory. Model files and servi
  6 
  7 ### Phase 2 — model and audio adapters
  8 - [ ] 4. Implement `internal/llm`: chat-completions HTTP/SSE client, Qwen3 no-thinking request options, bounded history, NPC and judge prompt builders, and parsers for both strict contracts. Test against `httptest` servers.
  9diff --git a/cmd/jp/main.go b/cmd/jp/main.go
 10index 3728ca97e20ab89fb59b281a96fb7b5a2e679e9c..db386e59361cb5f90e80100bf80dd69deb48f47e 100644
 11--- a/cmd/jp/main.go
 12+++ b/cmd/jp/main.go
 13@@ -98,14 +98,9 @@ func buildOrchestrator(cfg config.Config, state *game.State, personas []persona.
 14 	npcModel := &adapters.NPCModel{Client: llmClient, Policy: llm.DefaultHistoryPolicy()}
 15 	judgeModel := &adapters.JudgeModel{Client: llmClient}
 16 
 17-	romanizer, err := stt.NewRomanizer()
 18-	if err != nil {
 19-		fatalf("load romanizer: %v", err)
 20-	}
 21 	speechIn := &adapters.SpeechInput{
 22 		Recorder: stt.NewRecorder(cfg.RecordCommand, recordCap),
 23 		Whisper:  stt.NewWhisperClient(cfg.STTURL, cfg.STTLanguage, hc),
 24-		Romanize: romanizer,
 25 	}
 26 	speechOut := &adapters.SpeechOutput{
 27 		Client: tts.NewClient(cfg.TTSBaseURL, hc),
 28diff --git a/go.mod b/go.mod
 29index 7e76705a4c6700f26b6001b8fa445bdacc6e77de..052652798fe4a04c7d7d05f343a84af496da3f96 100644
 30--- a/go.mod
 31+++ b/go.mod
 32@@ -3,14 +3,12 @@ module japanese
 33 go 1.27.0
 34 
 35 require (
 36+	github.com/charmbracelet/bubbletea v1.3.10
 37 	github.com/ebitengine/oto/v3 v3.5.0
 38-	github.com/ikawaha/kagome-dict/ipa v1.2.6
 39-	github.com/ikawaha/kagome/v2 v2.11.0
 40 )
 41 
 42 require (
 43 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 44-	github.com/charmbracelet/bubbletea v1.3.10 // indirect
 45 	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
 46 	github.com/charmbracelet/lipgloss v1.1.0 // indirect
 47 	github.com/charmbracelet/x/ansi v0.10.1 // indirect
 48@@ -18,7 +16,6 @@ require (
 49 	github.com/charmbracelet/x/term v0.2.1 // indirect
 50 	github.com/ebitengine/purego v0.11.0 // indirect
 51 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 52-	github.com/ikawaha/kagome-dict v1.1.7 // indirect
 53 	github.com/jfreymuth/pulse v0.1.3 // indirect
 54 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 55 	github.com/mattn/go-isatty v0.0.20 // indirect
 56diff --git a/go.sum b/go.sum
 57index d82ca688ab092f03ea165aaf92cbbcdc84caffa0..60f01736a33de0ecd0c4d4a9a68077d053bb5ec2 100644
 58--- a/go.sum
 59+++ b/go.sum
 60@@ -18,12 +18,6 @@ github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua
 61 github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
 62 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
 63 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 64-github.com/ikawaha/kagome-dict v1.1.7 h1:O/uAL+WCGhp6kT0+szxBSPaSM4i+vdArSefFvJE4Nug=
 65-github.com/ikawaha/kagome-dict v1.1.7/go.mod h1:9tvk7/jZkvYt40foxkB9CqSAAknoQrIPfzqQd05UkFw=
 66-github.com/ikawaha/kagome-dict/ipa v1.2.6 h1:Bcvm4jgxAAnTIKb6ckqUKBiFDN0wuanFfycMuYt7xGQ=
 67-github.com/ikawaha/kagome-dict/ipa v1.2.6/go.mod h1:ONdTMUAKMCq9yx4s69QRtPcJLEMVM0BNNYQrMCJLWb0=
 68-github.com/ikawaha/kagome/v2 v2.11.0 h1:R914EkRzay9qtUbsFzEbcdZ3wHwwSPvbPkuBI1oIf78=
 69-github.com/ikawaha/kagome/v2 v2.11.0/go.mod h1:6mYPezBou+iNVnX9uNa00Sfu6S6t2zcM8Nv1EW9Y9so=
 70 github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
 71 github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
 72 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 73@@ -45,6 +39,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 74 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 75 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 76 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 77+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
 78+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
 79 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 80 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 81 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
 82diff --git a/internal/adapters/speech.go b/internal/adapters/speech.go
 83index b84e9e1d9f201b8b8de5169d466c6701a3db5528..08ec9708b1b35b79b58a073e3e8396134905b7b6 100644
 84--- a/internal/adapters/speech.go
 85+++ b/internal/adapters/speech.go
 86@@ -4,18 +4,16 @@ import (
 87 	"context"
 88 	"fmt"
 89 
 90-	"japanese/internal/game"
 91 	"japanese/internal/stt"
 92 	"japanese/internal/tts"
 93 )
 94 
 95-// SpeechInput adapts the stt recorder, Whisper client, and romanizer to
 96-// game.SpeechInput. Begin starts push-to-talk capture; End stops it, uploads
 97-// the recording to Whisper, and romanizes the transcript.
 98+// SpeechInput adapts the stt recorder and Whisper client to game.SpeechInput.
 99+// Begin starts push-to-talk capture; End stops it, uploads the recording to
100+// Whisper, and returns the raw transcript.
101 type SpeechInput struct {
102 	Recorder *stt.Recorder
103 	Whisper  *stt.WhisperClient
104-	Romanize *stt.Romanizer
105 
106 	cur *stt.Recording
107 }
108@@ -29,20 +27,19 @@ func (s *SpeechInput) Begin(ctx context.Context) error {
109 	return nil
110 }
111 
112-func (s *SpeechInput) End(ctx context.Context) (game.Transcript, error) {
113+func (s *SpeechInput) End(ctx context.Context) (string, error) {
114 	if s.cur == nil {
115-		return game.Transcript{}, fmt.Errorf("mic: recording not started")
116+		return "", fmt.Errorf("mic: recording not started")
117 	}
118 	path, err := s.cur.Stop()
119 	if err != nil {
120-		return game.Transcript{}, fmt.Errorf("mic: %w", err)
121+		return "", fmt.Errorf("mic: %w", err)
122 	}
123 	raw, err := s.Whisper.Transcribe(ctx, path)
124 	if err != nil {
125-		return game.Transcript{}, err
126+		return "", err
127 	}
128-	tr := s.Romanize.Romanize(raw)
129-	return game.Transcript{Raw: tr.Raw, Romaji: tr.Romaji}, nil
130+	return raw, nil
131 }
132 
133 // SpeechOutput adapts the tts client and player to game.SpeechOutput. It
134diff --git a/internal/adapters/speech_test.go b/internal/adapters/speech_test.go
135index cc87cc739c4c402f292249cecc929169123bc1c3..57d66d0410202dc437291e8a34e86e5d53abd191 100644
136--- a/internal/adapters/speech_test.go
137+++ b/internal/adapters/speech_test.go
138@@ -47,20 +47,10 @@ func TestSpeechOutputSpeakErrorDoesNotFireOnDone(t *testing.T) {
139 	}
140 }
141 
142-func mustRomanizer(t *testing.T) *stt.Romanizer {
143-	t.Helper()
144-	r, err := stt.NewRomanizer()
145-	if err != nil {
146-		t.Fatalf("NewRomanizer: %v", err)
147-	}
148-	return r
149-}
150-
151 func TestSpeechInputBeginSurfacesRecorderFailure(t *testing.T) {
152 	in := &SpeechInput{
153 		Recorder: stt.NewRecorder("jp-definitely-not-a-real-command-xyz", 10*time.Second),
154 		Whisper:  stt.NewWhisperClient("http://127.0.0.1:0/inference", "", nil),
155-		Romanize: mustRomanizer(t),
156 	}
157 	err := in.Begin(context.Background())
158 	if err == nil || !strings.Contains(err.Error(), "mic:") {
159@@ -72,7 +62,6 @@ func TestSpeechInputEndWithoutBegin(t *testing.T) {
160 	in := &SpeechInput{
161 		Recorder: stt.NewRecorder("sleep 5", 10*time.Second),
162 		Whisper:  stt.NewWhisperClient("http://127.0.0.1:0/inference", "", nil),
163-		Romanize: mustRomanizer(t),
164 	}
165 	if _, err := in.End(context.Background()); err == nil || !strings.Contains(err.Error(), "recording not started") {
166 		t.Fatalf("expected a not-started error, got %v", err)
167@@ -88,7 +77,6 @@ func TestSpeechInputEndSurfacesSTTErrorNamingURL(t *testing.T) {
168 	in := &SpeechInput{
169 		Recorder: stt.NewRecorder("sleep 5", 10*time.Second),
170 		Whisper:  stt.NewWhisperClient(srv.URL, "", srv.Client()),
171-		Romanize: mustRomanizer(t),
172 	}
173 	if err := in.Begin(context.Background()); err != nil {
174 		t.Fatalf("Begin: %v", err)
175@@ -98,3 +86,25 @@ func TestSpeechInputEndSurfacesSTTErrorNamingURL(t *testing.T) {
176 		t.Fatalf("expected STT error naming URL %q, got %v", srv.URL, err)
177 	}
178 }
179+
180+func TestSpeechInputEndReturnsRawTranscript(t *testing.T) {
181+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
182+		_, _ = w.Write([]byte(`{"text":"こんにちは"}`))
183+	}))
184+	defer srv.Close()
185+
186+	in := &SpeechInput{
187+		Recorder: stt.NewRecorder("true", 10*time.Second),
188+		Whisper:  stt.NewWhisperClient(srv.URL, "", srv.Client()),
189+	}
190+	if err := in.Begin(context.Background()); err != nil {
191+		t.Fatalf("Begin: %v", err)
192+	}
193+	got, err := in.End(context.Background())
194+	if err != nil {
195+		t.Fatalf("End: %v", err)
196+	}
197+	if got != "こんにちは" {
198+		t.Errorf("End returned %q, want the raw transcript unchanged", got)
199+	}
200+}
201diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
202index 286bb99a15048d94a7280f67d8016cfc2c1264a3..ea075a4be0d7501f54430b17911513fc096b0406 100644
203--- a/internal/game/orchestrator.go
204+++ b/internal/game/orchestrator.go
205@@ -8,18 +8,11 @@ import (
206 	"japanese/internal/llm"
207 )
208 
209-// Transcript is a captured spoken line: the original text (for LLM prompts)
210-// and its deterministic romaji form (for display).
211-type Transcript struct {
212-	Raw    string
213-	Romaji string
214-}
215-
216-// SpeechInput captures push-to-talk audio and returns a romanized transcript.
217-// Begin starts capture; End stops it, transcribes, and romanizes.
218+// SpeechInput captures push-to-talk audio and returns the raw transcript. Begin
219+// starts capture; End stops it and transcribes to the raw Japanese line.
220 type SpeechInput interface {
221 	Begin(ctx context.Context) error
222-	End(ctx context.Context) (Transcript, error)
223+	End(ctx context.Context) (string, error)
224 }
225 
226 // NPCRequest carries everything the NPC model needs for one reply. Judge output
227@@ -62,11 +55,14 @@ type PersonaView struct {
228 // ErrNotInLocation is returned when a turn is finished outside any location.
229 var ErrNotInLocation = errors.New("game: not in a location")
230 
231-// TurnResult reports what happened in one turn. A non-nil Err means no NPC
232-// reply was produced, so nothing was recorded to history. JudgeErr and SpeakErr
233-// are recoverable: they do not prevent recording and are surfaced separately.
234+// TurnResult reports what happened in one turn. Transcript is the raw Japanese
235+// line (for LLM prompts only, never shown); the display romaji comes from Judge
236+// when it succeeds, and there is no romaji when it fails. A non-nil Err means no
237+// NPC reply was produced, so nothing was recorded to history. JudgeErr and
238+// SpeakErr are recoverable: they do not prevent recording and are surfaced
239+// separately.
240 type TurnResult struct {
241-	Transcript Transcript
242+	Transcript string
243 	Judge      llm.JudgeResult
244 	JudgeErr   error
245 	NPC        llm.NPCReply
246@@ -118,12 +114,12 @@ func (o *Orchestrator) Finish(ctx context.Context) TurnResult {
247 		return res
248 	}
249 
250-	tr, err := o.speechIn.End(ctx)
251+	raw, err := o.speechIn.End(ctx)
252 	if err != nil {
253 		res.Err = err
254 		return res
255 	}
256-	res.Transcript = tr
257+	res.Transcript = raw
258 
259 	view := o.personas[loc]
260 	prior := toLLMTurns(o.state.History(loc))
261@@ -138,14 +134,14 @@ func (o *Orchestrator) Finish(ctx context.Context) TurnResult {
262 	wg.Add(2)
263 	go func() {
264 		defer wg.Done()
265-		judgeRes, judgeErr = o.judge.Judge(ctx, JudgeRequest{Situation: view.Situation, Transcript: tr.Raw})
266+		judgeRes, judgeErr = o.judge.Judge(ctx, JudgeRequest{Situation: view.Situation, Transcript: raw})
267 	}()
268 	go func() {
269 		defer wg.Done()
270 		npcRes, npcErr = o.npc.Reply(ctx, NPCRequest{
271 			Persona:    view.Description,
272 			Situation:  view.Situation,
273-			Transcript: tr.Raw,
274+			Transcript: raw,
275 			History:    prior,
276 		})
277 	}()
278@@ -158,11 +154,16 @@ func (o *Orchestrator) Finish(ctx context.Context) TurnResult {
279 	}
280 	res.NPC = npcRes
281 
282+	var playerRomaji string
283+	if judgeErr == nil {
284+		playerRomaji = judgeRes.Romaji
285+	}
286+
287 	if err := o.speechOut.Speak(ctx, npcRes.Kana); err != nil {
288 		res.SpeakErr = err
289 	}
290 
291-	o.state.RecordTurn(loc, tr.Raw, tr.Romaji, Reply{Romaji: npcRes.Romaji, English: npcRes.English})
292+	o.state.RecordTurn(loc, raw, playerRomaji, Reply{Romaji: npcRes.Romaji, English: npcRes.English})
293 	return res
294 }
295 
296diff --git a/internal/game/state.go b/internal/game/state.go
297index 506ce70b70f6f2e36466398f666c8ec59c40b4ea..47c92e78919ee8bc7ddab3f356f35d03161e6f87 100644
298--- a/internal/game/state.go
299+++ b/internal/game/state.go
300@@ -53,7 +53,7 @@ type Reply struct {
301 
302 // Turn is one exchange in a location's history: the player's spoken line and
303 // the NPC reply to it. PlayerRaw is the original transcript (for LLM prompts);
304-// PlayerRomaji is the deterministic romaji form shown to the player.
305+// PlayerRomaji is the romaji form shown to the player, sourced from the judge.
306 type Turn struct {
307 	PlayerRaw    string
308 	PlayerRomaji string
309diff --git a/internal/game/turn_test.go b/internal/game/turn_test.go
310index e6c60f3d83df9d0620a64cc81c0022281b2a295b..e6597c02390aa412df25aaf367a807c11ef2dc64 100644
311--- a/internal/game/turn_test.go
312+++ b/internal/game/turn_test.go
313@@ -13,13 +13,13 @@ import (
314 )
315 
316 type fakeInput struct {
317-	transcript Transcript
318-	endErr     error
319+	raw    string
320+	endErr error
321 }
322 
323 func (f *fakeInput) Begin(ctx context.Context) error { return nil }
324-func (f *fakeInput) End(ctx context.Context) (Transcript, error) {
325-	return f.transcript, f.endErr
326+func (f *fakeInput) End(ctx context.Context) (string, error) {
327+	return f.raw, f.endErr
328 }
329 
330 type fakeNPC struct {
331@@ -76,9 +76,9 @@ func newOrchAtStation(t *testing.T, in SpeechInput, npc NPCModel, judge JudgeMod
332 }
333 
334 func TestHappyTurnEndToEnd(t *testing.T) {
335-	in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
336+	in := &fakeInput{raw: "raw-line"}
337 	npc := &fakeNPC{reply: llm.NPCReply{Romaji: "romaji-reply", Kana: "kana-reply", English: "english-reply"}}
338-	judge := &fakeJudge{result: llm.JudgeResult{Score: 80, Feedback: "good"}}
339+	judge := &fakeJudge{result: llm.JudgeResult{Score: 80, Romaji: "ro-ma-ji", Feedback: "good"}}
340 	out := &fakeOut{}
341 
342 	o, s := newOrchAtStation(t, in, npc, judge, out)
343@@ -87,8 +87,8 @@ func TestHappyTurnEndToEnd(t *testing.T) {
344 	if res.Err != nil {
345 		t.Fatalf("turn error: %v", res.Err)
346 	}
347-	if res.Transcript.Raw != "raw-line" || res.Transcript.Romaji != "ro-ma-ji" {
348-		t.Errorf("transcript = %+v", res.Transcript)
349+	if res.Transcript != "raw-line" {
350+		t.Errorf("transcript = %q, want raw-line", res.Transcript)
351 	}
352 	if res.JudgeErr != nil || res.Judge.Score != 80 {
353 		t.Errorf("judge = %+v err=%v", res.Judge, res.JudgeErr)
354@@ -104,7 +104,7 @@ func TestHappyTurnEndToEnd(t *testing.T) {
355 	if len(hist) != 1 {
356 		t.Fatalf("history len = %d, want 1", len(hist))
357 	}
358-	if hist[0].PlayerRaw != "raw-line" || hist[0].NPC.Romaji != "romaji-reply" || hist[0].NPC.Reveal != Hidden {
359+	if hist[0].PlayerRaw != "raw-line" || hist[0].PlayerRomaji != "ro-ma-ji" || hist[0].NPC.Romaji != "romaji-reply" || hist[0].NPC.Reveal != Hidden {
360 		t.Errorf("recorded turn = %+v", hist[0])
361 	}
362 
363@@ -118,7 +118,7 @@ func TestHappyTurnEndToEnd(t *testing.T) {
364 }
365 
366 func TestJudgeFailureDoesNotBlockNPC(t *testing.T) {
367-	in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
368+	in := &fakeInput{raw: "raw-line"}
369 	npc := &fakeNPC{reply: llm.NPCReply{Romaji: "r", Kana: "k", English: "e"}}
370 	judge := &fakeJudge{err: errors.New("judge down")}
371 	out := &fakeOut{}
372@@ -138,11 +138,15 @@ func TestJudgeFailureDoesNotBlockNPC(t *testing.T) {
373 	if got := len(s.History("station")); got != 1 {
374 		t.Errorf("history len = %d, want 1", got)
375 	}
376+	hist := s.History("station")
377+	if hist[0].PlayerRaw != "raw-line" || hist[0].PlayerRomaji != "" {
378+		t.Errorf("judge failure should record raw with empty romaji, got %+v", hist[0])
379+	}
380 }
381 
382 func TestJudgeOutputNeverEntersNPCInput(t *testing.T) {
383 	const secret = "JUDGE-SECRET-XYZ"
384-	in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
385+	in := &fakeInput{raw: "raw-line"}
386 	npc := &fakeNPC{reply: llm.NPCReply{Romaji: "r", Kana: "k", English: "e"}}
387 	judge := &fakeJudge{result: llm.JudgeResult{Score: 10, Feedback: secret}}
388 	out := &fakeOut{}
389@@ -165,7 +169,7 @@ func TestJudgeOutputNeverEntersNPCInput(t *testing.T) {
390 }
391 
392 func TestMalformedNPCOutputSurfacesRecoverableError(t *testing.T) {
393-	in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
394+	in := &fakeInput{raw: "raw-line"}
395 	npc := &fakeNPC{err: &llm.ContractError{Kind: llm.MissingField, Field: "KANA"}}
396 	judge := &fakeJudge{result: llm.JudgeResult{Score: 50, Feedback: "ok"}}
397 	out := &fakeOut{}
398@@ -206,7 +210,7 @@ func TestSTTFailureSurfacesNamedErrorAndNoSideEffects(t *testing.T) {
399 }
400 
401 func TestTTSFailureIsRecoverableAndStillRecords(t *testing.T) {
402-	in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
403+	in := &fakeInput{raw: "raw-line"}
404 	npc := &fakeNPC{reply: llm.NPCReply{Romaji: "r", Kana: "k", English: "e"}}
405 	judge := &fakeJudge{}
406 	out := &fakeOut{err: fmt.Errorf("tts: request to http://127.0.0.1:8080/v1/audio/speech: boom")}
407@@ -263,7 +267,7 @@ func TestJudgeAndNPCRunInParallel(t *testing.T) {
408 		mu.Unlock()
409 	}
410 
411-	in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
412+	in := &fakeInput{raw: "raw-line"}
413diff --git a/internal/stt/romaji.go b/internal/stt/romaji.go
414deleted file mode 100644
415index 2dee611ad256a2e5e2594b05ec58c40099f833dd..0000000000000000000000000000000000000000
416--- a/internal/stt/romaji.go
417+++ /dev/null
418@@ -1,229 +0,0 @@
419-// Package stt wraps push-to-talk recording, the Whisper multipart client, and
420-// deterministic Japanese-to-romaji transcription. It is an HTTP/process client
421-// only and never starts or manages any model service.
422-package stt
423-
424-import (
425-	"fmt"
426-	"strings"
427-
428-	"github.com/ikawaha/kagome-dict/ipa"
429-	"github.com/ikawaha/kagome/v2/tokenizer"
430-)
431-
432-const unreadableMarker = "[unreadable]"
433-
434-// Transcript carries both the original Japanese line (for LLM prompts) and its
435-// deterministic romaji form (for the UI). The romaji field never contains kana
436-// or kanji.
437-type Transcript struct {
438-	Raw    string
439-	Romaji string
440-}
441-
442-// Romanizer turns raw Japanese text into a Transcript. It owns a Kagome/IPADIC
443-// tokenizer, so one instance should be created once and reused.
444-type Romanizer struct {
445-	tk *tokenizer.Tokenizer
446-}
447-
448-func NewRomanizer() (*Romanizer, error) {
449-	tk, err := tokenizer.New(ipa.Dict(), tokenizer.OmitBosEos())
450-	if err != nil {
451-		return nil, fmt.Errorf("stt: init kagome: %w", err)
452-	}
453-	return &Romanizer{tk: tk}, nil
454-}
455-
456-// Romanize tokenizes raw and returns both the original line and its romaji.
457-func (r *Romanizer) Romanize(raw string) Transcript {
458-	var sb strings.Builder
459-	for _, tok := range r.tk.Tokenize(raw) {
460-		frag := fragment(tok)
461-		if frag == "" {
462-			continue
463-		}
464-		if sb.Len() > 0 {
465-			sb.WriteString(" ")
466-		}
467-		sb.WriteString(frag)
468-	}
469-	return Transcript{Raw: raw, Romaji: strings.TrimSpace(sb.String())}
470-}
471-
472-func fragment(tok tokenizer.Token) string {
473-	if reading, ok := tok.Reading(); ok && isKanaOnly(reading) {
474-		return KanaToHepburn(reading)
475-	}
476-	if isKanaOnly(tok.Surface) {
477-		return KanaToHepburn(tok.Surface)
478-	}
479-	if hasKanjiOrKana(tok.Surface) {
480-		return unreadableMarker
481-	}
482-	if hasASCIIAlnum(tok.Surface) {
483-		return strings.TrimSpace(tok.Surface)
484-	}
485-	return ""
486-}
487-
488-// kanaToHepburn maps single syllables (both scripts) to Hepburn romaji. Small
489-// ya/yu/yo, the prolonged-sound mark, and small vowels are handled separately
490-// in KanaToHepburn.
491-var kanaToHepburn = map[rune]string{
492-	// hiragana plain
493-	'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
494-	'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
495-	'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
496-	'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
497-	'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
498-	'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
499-	'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
500-	'や': "ya", 'ゆ': "yu", 'よ': "yo",
501-	'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
502-	'わ': "wa", 'を': "wo", 'ん': "n",
503-	// hiragana dakuten / handakuten
504-	'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
505-	'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
506-	'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
507-	'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
508-	'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
509-	// hiragana small vowels
510-	'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e",
511-
512-	// katakana plain
513-	'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
514-	'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
515-	'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
516-	'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
517-	'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
518diff --git a/internal/stt/romaji_test.go b/internal/stt/romaji_test.go
519deleted file mode 100644
520index 8dca0cdcf997fc238dff56bd5a32a1498872d866..0000000000000000000000000000000000000000
521--- a/internal/stt/romaji_test.go
522+++ /dev/null
523@@ -1,75 +0,0 @@
524-package stt
525-
526-import (
527-	"strings"
528-	"testing"
529-)
530-
531-func TestKanaToHepburn(t *testing.T) {
532-	cases := []struct{ in, want string }{
533-		{"こんにちは", "konnichiha"},
534-		{"キャク", "kyaku"},
535-		{"ジャ", "ja"},
536-		{"ぎゃく", "gyaku"},
537-		{"にゃん", "nyan"},
538-		{"ひょう", "hyou"},
539-		{"とうきょう", "toukyou"},
540-		{"コーヒー", "koohii"},
541-		{"ヴ", ""}, // unknown kana dropped
542-	}
543-	for _, c := range cases {
544-		if got := KanaToHepburn(c.in); got != c.want {
545-			t.Errorf("KanaToHepburn(%q) = %q, want %q", c.in, got, c.want)
546-		}
547-	}
548-}
549-
550-func TestRomanizeBasic(t *testing.T) {
551-	r, err := NewRomanizer()
552-	if err != nil {
553-		t.Fatalf("NewRomanizer: %v", err)
554-	}
555-	cases := []struct{ raw, wantRomaji string }{
556-		{"こんにちは。", "konnichiha"},
557-		{"猫をみた", "neko wo mi ta"},
558-		{"ラーメンがおいしいです", "raamen ga oishii desu"},
559-		{"100円です", "100 en desu"},
560-		{"客", "kyaku"},
561-		{"じゃがいも", "jagaimo"},
562-	}
563-	for _, c := range cases {
564-		tr := r.Romanize(c.raw)
565-		if tr.Raw != c.raw {
566-			t.Errorf("Romanize(%q).Raw = %q, want unchanged", c.raw, tr.Raw)
567-		}
568-		if tr.Romaji != c.wantRomaji {
569-			t.Errorf("Romanize(%q).Romaji = %q, want %q", c.raw, tr.Romaji, c.wantRomaji)
570-		}
571-	}
572-}
573-
574-func TestRomanizeUnknownMarker(t *testing.T) {
575-	r, err := NewRomanizer()
576-	if err != nil {
577-		t.Fatalf("NewRomanizer: %v", err)
578-	}
579-	tr := r.Romanize("㓆") // CJK Extension A, not in IPADIC
580-	if !strings.Contains(tr.Romaji, unreadableMarker) {
581-		t.Errorf("Romaji = %q, want it to contain %q", tr.Romaji, unreadableMarker)
582-	}
583-}
584-
585-func TestRomanizeNoKanaLeak(t *testing.T) {
586-	r, err := NewRomanizer()
587-	if err != nil {
588-		t.Fatalf("NewRomanizer: %v", err)
589-	}
590-	for _, raw := range []string{"こんにちは。", "猫をみた", "ラーメンがおいしいです", "100円です", "㓆をみた"} {
591-		tr := r.Romanize(raw)
592-		for _, ru := range tr.Romaji {
593-			if isKanaRune(ru) || isKanjiRune(ru) {
594-				t.Errorf("Romanize(%q).Romaji = %q leaks kana/kanji rune U+%04X", raw, tr.Romaji, ru)
595-			}
596-		}
597-	}
598-}
599diff --git a/internal/stt/whisper.go b/internal/stt/whisper.go
600index 1a9c4792c1902a35b65704d239c9c98730813933..51707374635383736ef5d5bf7b0f92f29e9651e9 100644
601--- a/internal/stt/whisper.go
602+++ b/internal/stt/whisper.go
603@@ -1,3 +1,5 @@
604+// Package stt wraps push-to-talk recording and the Whisper multipart client. It
605+// is an HTTP/process client only and never starts or manages any model service.
606 package stt
607 
608 import (
609diff --git a/internal/ui/app.go b/internal/ui/app.go
610index c3ac6380ecc55c74729d24564656324e8170ccea..ef228e5ebbe1b67e7280c3ec837769cb1d4797a1 100644
611--- a/internal/ui/app.go
612+++ b/internal/ui/app.go
613@@ -179,13 +179,14 @@ func (m *model) applyBegin(err error) {
614 }
615 
616 func (m *model) applyFinish(res game.TurnResult) {
617-	m.transcriptRomaji = res.Transcript.Romaji
618 	if res.JudgeErr == nil {
619+		m.transcriptRomaji = res.Judge.Romaji
620 		m.hasJudge = true
621 		m.judgeScore = res.Judge.Score
622 		m.judgeFeedback = res.Judge.Feedback
623 		m.judgeErr = ""
624 	} else {
625+		m.transcriptRomaji = transcriptUnavailable
626 		m.hasJudge = false
627 		m.judgeErr = fmt.Sprintf("LLM (%s): %v", m.svcURL("LLM"), res.JudgeErr)
628 	}
629diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go
630index 4f2c89bfedaadb793b72c19cea596252b174518e..306b4547c6c506ace93bae0d596728ef9b3ba87a 100644
631--- a/internal/ui/app_test.go
632+++ b/internal/ui/app_test.go
633@@ -27,8 +27,8 @@ func testState(t *testing.T) *game.State {
634 type fakeSpeech struct{}
635 
636 func (fakeSpeech) Begin(ctx context.Context) error { return nil }
637-func (fakeSpeech) End(ctx context.Context) (game.Transcript, error) {
638-	return game.Transcript{Raw: "ra", Romaji: "ra"}, nil
639+func (fakeSpeech) End(ctx context.Context) (string, error) {
640+	return "ra", nil
641 }
642 
643 type fakeNPC struct{}
644@@ -40,7 +40,7 @@ func (fakeNPC) Reply(ctx context.Context, req game.NPCRequest) (llm.NPCReply, er
645 type fakeJudge struct{}
646 
647 func (fakeJudge) Judge(ctx context.Context, req game.JudgeRequest) (llm.JudgeResult, error) {
648-	return llm.JudgeResult{Score: 82, Feedback: "fine romaji feedback"}, nil
649+	return llm.JudgeResult{Score: 82, Romaji: "ra", Feedback: "fine romaji feedback"}, nil
650 }
651 
652 type fakeOut struct{}
653@@ -84,7 +84,7 @@ func TestToggleStateMachine(t *testing.T) {
654 	}
655 
656 	// finish result -> idle
657-	res := game.TurnResult{Transcript: game.Transcript{Romaji: "ra"}, NPC: llm.NPCReply{English: "english-reply"}}
658+	res := game.TurnResult{Transcript: "raw", Judge: llm.JudgeResult{Score: 80, Romaji: "ra", Feedback: "ok"}, NPC: llm.NPCReply{English: "english-reply"}}
659 	if _, _ = m.Update(finishResultMsg{res: res}); m.phase != phaseIdle {
660 		t.Fatalf("after finish: phase=%v; want idle", m.phase)
661 	}
662@@ -124,7 +124,7 @@ func TestFinishWorkerRunsTurn(t *testing.T) {
663 	if fr.res.Err != nil {
664 		t.Fatalf("turn err: %v", fr.res.Err)
665 	}
666-	if fr.res.Transcript.Romaji != "ra" || fr.res.Judge.Score != 82 || fr.res.NPC.English != "english-reply" {
667+	if fr.res.Transcript != "ra" || fr.res.Judge.Score != 82 || fr.res.NPC.English != "english-reply" {
668 		t.Fatalf("unexpected turn result: %+v", fr.res)
669 	}
670 }
671@@ -174,8 +174,8 @@ func TestProbeCheckingThenTransitionsInPlace(t *testing.T) {
672 func TestNpcSpeakingTransitionsToDone(t *testing.T) {
673 	m, _ := buildModel(t)
674 	res := game.TurnResult{
675-		Transcript: game.Transcript{Romaji: "ra"},
676-		Judge:      llm.JudgeResult{Score: 80, Feedback: "ok"},
677+		Transcript: "raw",
678+		Judge:      llm.JudgeResult{Score: 80, Romaji: "ra", Feedback: "ok"},
679 		NPC:        llm.NPCReply{Romaji: "r", English: "e"},
680 	}
681 	if _, _ = m.Update(finishResultMsg{res: res}); m.npcStatus != "speaking" {
682@@ -186,6 +186,23 @@ func TestNpcSpeakingTransitionsToDone(t *testing.T) {
683 	}
684 }
685 
686+func TestApplyFinishShowsJudgeRomajiOrPlaceholder(t *testing.T) {
687+	m, _ := buildModel(t)
688+
689+	okRes := game.TurnResult{Transcript: "raw", Judge: llm.JudgeResult{Score: 90, Romaji: "konnichiwa", Feedback: "ok"}, NPC: llm.NPCReply{Romaji: "r", English: "e"}}
690+	m.Update(finishResultMsg{res: okRes})
691+	if m.transcriptRomaji != "konnichiwa" {
692+		t.Fatalf("transcript = %q, want judge romaji", m.transcriptRomaji)
693+	}
694+
695+	badRes := game.TurnResult{Transcript: "こんにちは", JudgeErr: errors.New("judge down"), NPC: llm.NPCReply{Romaji: "r", English: "e"}}
696+	m.Update(finishResultMsg{res: badRes})
697+	if m.transcriptRomaji != transcriptUnavailable {
698+		t.Fatalf("transcript = %q, want placeholder on judge failure", m.transcriptRomaji)
699+	}
700+	assertNoJapanese(t, m.View())
701+}
702+
703 func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
704 
705 func TestRevealOrdering(t *testing.T) {
706diff --git a/internal/ui/render.go b/internal/ui/render.go
707index 00b85408796e8ec0efff47f6896b9fc1392bd2f2..6f824ce6b01aa6563ed5a114e5e19039d2128214 100644
708--- a/internal/ui/render.go
709+++ b/internal/ui/render.go
710@@ -24,6 +24,10 @@ type serviceLine struct {
711 // controlsHint is the idle status line. It only lists keys wired in this build.
712 const controlsHint = "move: arrows / WASD   Space: talk   R: romaji   T: english   q: quit"
713 
714+// transcriptUnavailable is shown for the player's line when the judge request
715+// failed, so no raw Japanese can ever reach the view.
716+const transcriptUnavailable = "(transcript unavailable)"
717+
718 // viewState is the plain, render-only snapshot the pure render functions take.
719 // It holds no pointers and no game types, so rendering is trivially pure.
720 type viewState struct {