Diff
1diff --git a/Makefile b/Makefile
2index bc240eca5af3158c1de0e80a350551e9a5893ff4..589edb022c47280087e365cbc0c47dbaf3b5019b 100644
3--- a/Makefile
4+++ b/Makefile
5@@ -1,4 +1,4 @@
6-.PHONY: lint fmt build run llama audiocpp fetch-models
7+.PHONY: lint fmt build run run-server fetch-models
8
9 lint:
10 golangci-lint run ./...
11@@ -9,33 +9,13 @@ fmt:
12
13 build:
14 go build -o kaiwari ./cmd/kaiwari
15+ go build -o kaiwari-server ./cmd/kaiwari-server/
16
17 run:
18 go run ./cmd/kaiwari
19
20-llama:
21- llama-server \
22- --host 0.0.0.0 \
23- --port 9931 \
24- --hf-repo unsloth/gemma-4-12B-it-qat-GGUF:UD-Q4_K_XL \
25- --n-gpu-layers 99 \
26- --n-gpu-layers-draft 99 \
27- --fit off \
28- --flash-attn on \
29- --parallel 5 \
30- --kv-unified \
31- --ctx-size 32768 \
32- --batch-size 1024 \
33- --ubatch-size 1024 \
34- --temp 1.0 \
35- --top-p 0.95 \
36- --top-k 64 \
37- --spec-type draft-mtp \
38- --spec-draft-n-max 4 \
39- --reasoning off
40-
41-audiocpp:
42- audiocpp_server --config audio.cpp.json
43+run-server:
44+ go run ./cmd/kaiwari-server/
45
46 fetch-models:
47 hf download audio-cpp/audio.cpp-gguf Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF/qwen3-tts-12hz-1.7b-customvoice-q8_0.gguf --revision dc6fecccc2b0c6bdda0a8b2f38fa61394fee0b9c --local-dir models
48diff --git a/internal/adapters/speech.go b/internal/adapters/speech.go
49index 74517b9f92ee5f29d2d30aa8de61dd6429684569..b0f66983c84c86aa4af69dd4f3d04e2962ac638a 100644
50--- a/internal/adapters/speech.go
51+++ b/internal/adapters/speech.go
52@@ -91,8 +91,8 @@ type SpeechOutput struct {
53 last []byte
54 }
55
56-func (s *SpeechOutput) Speak(ctx context.Context, kana string) error {
57- wav, err := s.Client.Speech(ctx, kana)
58+func (s *SpeechOutput) Speak(ctx context.Context, kana, gender string) error {
59+ wav, err := s.Client.Speech(ctx, kana, tts.VoiceForGender(gender))
60 if err != nil {
61 return err
62 }
63diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
64index cd77b9a3f7cfd885d6486a63465edfa08e365ade..dee790c46120db123daccf081b6591b47d7e0ab5 100644
65--- a/internal/game/orchestrator.go
66+++ b/internal/game/orchestrator.go
67@@ -19,8 +19,9 @@ type SpeechInput interface {
68
69 // SpeechOutput synthesizes and plays an NPC reply's audio from its kana, and can
70 // replay the last line it produced without re-synthesizing.
71+// gender selects the voice; an empty gender means the speaker is unknown (ambient line).
72 type SpeechOutput interface {
73- Speak(ctx context.Context, kana string) error
74+ Speak(ctx context.Context, kana string, gender string) error
75 Replay() error
76 }
77
78@@ -267,7 +268,7 @@ func (o *Orchestrator) applyAndSpeak(ctx context.Context, reply llm.GameReply) (
79 var speakErr error
80 if hasSpeech(shown) {
81 o.lastKana = shown.Kana
82- speakErr = o.speechOut.Speak(ctx, shown.Kana)
83+ speakErr = o.speechOut.Speak(ctx, shown.Kana, o.speakerGender(shown.Location, shown.Talk))
84 }
85 return shown, desc, speakErr
86 }
87@@ -381,7 +382,17 @@ func (o *Orchestrator) generateSheet(ctx context.Context, intro llm.GameReply) {
88 if err != nil {
89 return
90 }
91- o.state.RegisterSheet(intro.Location, intro.Talk, sheet.Text())
92+ o.state.RegisterSheet(intro.Location, intro.Talk, sheet)
93+}
94+
95+// speakerGender returns the stored sheet's gender for a location + talk pair,
96+// or "" when no sheet is known (ambient line -> default voice).
97+func (o *Orchestrator) speakerGender(location, talk string) string {
98+ sheet, ok := o.state.SheetFor(location, talk)
99+ if !ok {
100+ return ""
101+ }
102+ return sheet.Gender
103 }
104
105 func joinDescs(a, b string) string {
106diff --git a/internal/game/state.go b/internal/game/state.go
107index 286ce0cae332998e24937f56e64c8857f55debca..603db64a692ebefa114f554190dc8b04b3b35c1d 100644
108--- a/internal/game/state.go
109+++ b/internal/game/state.go
110@@ -56,11 +56,11 @@ type DisplayEntry struct {
111
112 // sheetEntry is one stored character sheet, kept in first-met order so injected
113 // lines stay stable across turns (KV cache prefix). location and talk are
114-// normalized (trimmed + lowercased); text is the canonical single-line sheet.
115+// normalized (trimmed + lowercased); sheet is the parsed profile.
116 type sheetEntry struct {
117 location string
118 talk string
119- text string
120+ sheet llm.Sheet
121 }
122
123 // State is the pure, in-memory session. The LLM owns world consistency; State
124@@ -190,7 +190,7 @@ func (s *State) DropOldestSummary() {
125 // RegisterSheet stores one character sheet in first-met order. The key is the
126 // normalized location + talk pair; if that pair already has a sheet this is a
127 // no-op (the first sheet wins as the consistency anchor).
128-func (s *State) RegisterSheet(location, talk, text string) {
129+func (s *State) RegisterSheet(location, talk string, sheet llm.Sheet) {
130 loc := normalizeLocation(location)
131 tk := normalizeLocation(talk)
132 for _, e := range s.sheets {
133@@ -198,20 +198,20 @@ func (s *State) RegisterSheet(location, talk, text string) {
134 return
135 }
136 }
137- s.sheets = append(s.sheets, sheetEntry{location: loc, talk: tk, text: text})
138+ s.sheets = append(s.sheets, sheetEntry{location: loc, talk: tk, sheet: sheet})
139 }
140
141 // SheetFor returns the stored sheet for a location + talk pair and whether it
142 // exists. Both inputs are normalized before lookup.
143-func (s *State) SheetFor(location, talk string) (string, bool) {
144+func (s *State) SheetFor(location, talk string) (llm.Sheet, bool) {
145 loc := normalizeLocation(location)
146 tk := normalizeLocation(talk)
147 for _, e := range s.sheets {
148 if e.location == loc && e.talk == tk {
149- return e.text, true
150+ return e.sheet, true
151 }
152 }
153- return "", false
154+ return llm.Sheet{}, false
155 }
156
157 // sheetsAt returns the stored sheets for one normalized location, in order.
158@@ -239,7 +239,7 @@ func (s *State) promptContext() (string, []Message) {
159 if sheets := s.sheetsAt(s.open.Location); len(sheets) > 0 {
160 ctx.WriteString("Character sheets (internal; never quote or describe these):\n")
161 for _, e := range sheets {
162- fmt.Fprintf(&ctx, "- %s:%s %s\n", e.location, e.talk, e.text)
163+ fmt.Fprintf(&ctx, "- %s:%s %s\n", e.location, e.talk, e.sheet.Text())
164 }
165 }
166 if s.open.Prefix != "" {
167diff --git a/internal/llm/contract.go b/internal/llm/contract.go
168index 6815e97c59e9ec3c5e9410c537b0da7a501bf0bc..b50b2dd89930fc18b35003229319348346d24322 100644
169--- a/internal/llm/contract.go
170+++ b/internal/llm/contract.go
171@@ -49,7 +49,7 @@ func kindString(k ContractErrorKind) string {
172 var (
173 judgeFieldNames = []string{"SCORE", "ROMAJI", "FEEDBACK"}
174 gameFieldNames = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
175- sheetFieldNames = []string{"ROLE", "TRAITS", "MOOD", "STYLE", "UNCLEAR", "SAMPLE"}
176+ sheetFieldNames = []string{"ROLE", "GENDER", "TRAITS", "MOOD", "STYLE", "UNCLEAR", "SAMPLE"}
177 scratchFieldNames = []string{"ROMAJI", "TRANSLATION", "BREAKDOWN"}
178 )
179
180@@ -200,9 +200,10 @@ func ParseScratch(raw string) (ScratchAnswer, error) {
181 }
182
183 // Sheet is one person's compact character profile, parsed from the sheet
184-// contract. All six fields are required and non-empty.
185+// contract. All seven fields are required and non-empty.
186 type Sheet struct {
187 Role string // role, age range, one identifying appearance phrase
188+ Gender string // normalized "male" or "female"
189 Traits string // two to three personality traits
190 Mood string // mood today plus what they want or need right now
191 Style string // communication style: sentence length, formality, energy
192@@ -210,8 +211,9 @@ type Sheet struct {
193 Sample string // one short sample line in their voice, romaji
194 }
195
196-// ParseSheet parses the six-field character-sheet contract. All six fields are
197-// required and non-empty; a malformed reply is a recoverable *ContractError.
198+// ParseSheet parses the seven-field character-sheet contract. All seven fields
199+// are required and non-empty; GENDER must be male or female. A malformed reply
200+// is a recoverable *ContractError.
201 func ParseSheet(raw string) (Sheet, error) {
202 vals, err := parseFields(raw, sheetFieldNames)
203 if err != nil {
204@@ -220,8 +222,13 @@ func ParseSheet(raw string) (Sheet, error) {
205 if err := requireFields(vals, sheetFieldNames); err != nil {
206 return Sheet{}, err
207 }
208+ gender := strings.ToLower(strings.TrimSpace(vals["GENDER"]))
209+ if gender != "male" && gender != "female" {
210+ return Sheet{}, &ContractError{Kind: InvalidFormat, Field: "GENDER"}
211+ }
212 return Sheet{
213 Role: vals["ROLE"],
214+ Gender: gender,
215 Traits: vals["TRAITS"],
216 Mood: vals["MOOD"],
217 Style: vals["STYLE"],
218@@ -233,6 +240,7 @@ func ParseSheet(raw string) (Sheet, error) {
219 // Text renders the sheet as one space-joined line of its six fields, in order.
220 func (s Sheet) Text() string {
221 return "ROLE|" + s.Role +
222+ " GENDER|" + s.Gender +
223 " TRAITS|" + s.Traits +
224 " MOOD|" + s.Mood +
225 " STYLE|" + s.Style +
226diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go
227index c23a947e655bd3080c6fd72cfb94b49787a94e08..19efad03c2f812d022c7b9ceaedcc706645b9831 100644
228--- a/internal/llm/prompt.go
229+++ b/internal/llm/prompt.go
230@@ -108,11 +108,12 @@ func BuildCompactionMessages(location string, msgs []Message) []Message {
231 // player.
232 const sheetSystemTemplate = `You create one specific person for a spoken-Japanese practice world. You are only shaping who this local person is: not teaching or grading anyone, and never framing the player as a learner.
233
234-Using the details given, profile exactly one believable local person. Every field must be present and non-empty. Write only romaji - never kana or kanji anywhere. Keep the whole reply under about 400 characters.
235+Using the details given, profile exactly one believable local person. Every field must be present and non-empty. Write only romaji - never kana or kanji anywhere. Keep the whole reply under about 450 characters.
236
237-Your reply must be exactly six lines of plain text, in this order. No extra lines, no markdown, no code fences. Each line starts with its field key, a pipe |, then the value.
238+Your reply must be exactly seven lines of plain text, in this order. No extra lines, no markdown, no code fences. Each line starts with its field key, a pipe |, then the value.
239
240 ROLE|<role, age range, and one identifying appearance phrase>
241+GENDER|<male or female>
242 TRAITS|<two to three personality traits>
243 MOOD|<their mood today plus what they want or need right now>
244 STYLE|<communication style: sentence length, formality/politeness level, energy>
245diff --git a/internal/server/speech.go b/internal/server/speech.go
246index dd169ab3ce89d5b235d260d9346478874e98dc64..d2305d2a5df6dd12e07e80d57ad6ed8e75264ea5 100644
247--- a/internal/server/speech.go
248+++ b/internal/server/speech.go
249@@ -51,8 +51,8 @@ type bufferedOutput struct {
250 last []byte
251 }
252
253-func (b *bufferedOutput) Speak(ctx context.Context, kana string) error {
254- wav, err := b.client.Speech(ctx, kana)
255+func (b *bufferedOutput) Speak(ctx context.Context, kana, gender string) error {
256+ wav, err := b.client.Speech(ctx, kana, tts.VoiceForGender(gender))
257 if err != nil {
258 return err
259 }
260diff --git a/internal/services/probe.go b/internal/services/probe.go
261index 871ebe1692b59cb4243ac7c520d1a0dd55852b1a..d74e7acd8a63858560264854610e33f956e300a0 100644
262--- a/internal/services/probe.go
263+++ b/internal/services/probe.go
264@@ -50,7 +50,7 @@ func (m *Manager) waitHealthy(ctx context.Context) error {
265 func probeAll(ctx context.Context, ttsClient *tts.Client, sttClient *stt.ASRClient, llmURL string, hc *http.Client, audio []byte) []string {
266 names := []string{"tts", "stt", "llm"}
267 probes := []func(context.Context) error{
268- func(ctx context.Context) error { _, e := ttsClient.Speech(ctx, "あ"); return e },
269+ func(ctx context.Context) error { _, e := ttsClient.Speech(ctx, "あ", tts.DefaultVoice); return e },
270 func(ctx context.Context) error { _, e := sttClient.TranscribeBytes(ctx, audio); return e },
271 func(ctx context.Context) error { return probeLLMCompletion(ctx, llmURL, hc) },
272 }
273diff --git a/internal/tts/client.go b/internal/tts/client.go
274index ee10ee84454f074ae90f2566c9f9119360455f1b..27ecc744f5a670cfccb095280835ab1eaec2ddf7 100644
275--- a/internal/tts/client.go
276+++ b/internal/tts/client.go
277@@ -12,9 +12,15 @@ import (
278 )
279
280 const (
281- speechPath = "/v1/audio/speech"
282- ModelName = "qwen3"
283- defaultVoice = "Ono_Anna"
284+ speechPath = "/v1/audio/speech"
285+ ModelName = "qwen3"
286+
287+ // TTS voice presets (must match the names in audio.cpp.json).
288+ VoiceMale = "Ryan"
289+ VoiceFemale = "Ono_Anna"
290+ // DefaultVoice is used when a speaker's gender is unknown (ambient lines).
291+ DefaultVoice = VoiceFemale
292+
293 ttsLanguage = "Japanese"
294 speakInstructions = "Speak at a natural, conversational pace with clear articulation and natural pauses between sentences."
295 speechSeed = 1234
296@@ -49,14 +55,31 @@ type speechRequest struct {
297 ResponseFormat string `json:"response_format"`
298 }
299
300-// Speech posts kana to the speech endpoint and returns the WAV bytes.
301-func (c *Client) Speech(ctx context.Context, kana string) ([]byte, error) {
302+// VoiceForGender maps a character-sheet gender to a TTS voice preset.
303+// Unknown or empty genders fall back to DefaultVoice.
304+func VoiceForGender(gender string) string {
305+ switch strings.ToLower(strings.TrimSpace(gender)) {
306+ case "male":
307+ return VoiceMale
308+ case "female":
309+ return VoiceFemale
310+ default:
311+ return DefaultVoice
312+ }
313+}
314+
315+// Speech posts kana to the speech endpoint using the given voice preset and
316+// returns the WAV bytes. An empty voice falls back to DefaultVoice.
317+func (c *Client) Speech(ctx context.Context, kana, voice string) ([]byte, error) {
318 ctx, cancel := context.WithTimeout(ctx, speechTimeout)
319 defer cancel()
320+ if voice == "" {
321+ voice = DefaultVoice
322+ }
323 body, err := json.Marshal(speechRequest{
324 Model: ModelName,
325 Input: kana,
326- Voice: defaultVoice,
327+ Voice: voice,
328 Language: ttsLanguage,
329 Instructions: speakInstructions,
330 Seed: speechSeed,