f98ec74d6ddfc12afe58ff64dafa07535badb624

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

Message

Add support for male/female voices

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,