polats Claude Opus 4.8 (1M context) commited on
Commit
e648dca
·
1 Parent(s): 575fb61

Voice: provider-driven per-hero voices; settings is provider-only

Browse files

Settings → Voice is now just a provider picker (no global voice dropdown, no
"narrate war diaries" checkbox) — the voice belongs to the hero, not the app.
Copy updated to explain the model.

Persona panel now respects the chosen provider:
- Qwen3-TTS (design): voice-design text stays editable; designs/clones as before.
- Kokoro/Kitten/Web Speech (fixed-voice): the design text goes read-only and a
per-hero "Voice" dropdown of the provider's named voices appears. Play
synthesizes the quote in that voice (cached as a WAV for Kokoro/Kitten via a
new PCM→WAV encoder; spoken live for Web Speech). The pick is saved per hero
(voiceId) and the cache tracks voiceIdUsed for staleness, mirroring Qwen3.

The provider is set on another tab, so tts.js now emits onTtsEngineChange and
the panel re-renders its voice controls live (no reliance on tab visibility).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

web/personaPanel.js CHANGED
@@ -7,7 +7,10 @@ import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, bac
7
  import { extractLivePersona } from '/web/personaStream.js'
8
  import { parsePersonaJson } from '/web/personaParse.js'
9
  import { getPersonaSystem, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
10
- import { createVoiceWav, cloneVoiceWav, playWav, stopPreview } from '/web/tts.js'
 
 
 
11
  import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio } from '/web/personaStore.js'
12
 
13
  const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
@@ -143,6 +146,11 @@ export function mountPersonaPanel(host) {
143
  const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
144
  const quoteEl = el('blockquote', { class: 'persona-quote persona-edit', 'data-ph': 'A line they say…' })
145
  const voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
 
 
 
 
 
146
  // ▶ play sits on the Quote heading and does everything: it (re)creates the voice when
147
  // needed and replays it otherwise. A pulsing badge shows when there's no voice yet, or
148
  // the quote/voice was edited since the last one was made.
@@ -167,7 +175,7 @@ export function mountPersonaPanel(host) {
167
  nameEl,
168
  secHead('About'), aboutEl,
169
  secHead('Quote', playBtn), quoteEl,
170
- secHead('Voice design'), voiceEl,
171
  ])
172
  const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl, thinkWrap])
173
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
@@ -188,7 +196,7 @@ export function mountPersonaPanel(host) {
188
  else updateVoiceUI()
189
  }
190
  // Stop any sounding voice and reset the button (used on toggle, nav-away, new pick).
191
- function stopVoice() { stopPreview(); setPlaying(false) }
192
  // Play a WAV buffer with the button reflecting play→stop→idle, even if it's cut short.
193
  async function playBuf(arrayBuffer) {
194
  setPlaying(true)
@@ -206,19 +214,52 @@ export function mountPersonaPanel(host) {
206
 
207
  // The line the voice actually says (quote, else about, else a fallback).
208
  const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A hero'} reporting for duty.`
209
- // Cached audio is stale if the line or the voice design changed since it was made.
210
- const isDirty = () => hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || ''))
211
- // The voice DESIGN text itself changed since the cached voice was made → we must
212
- // re-DESIGN a new timbre. (Cloning would just re-speak the OLD voice — the bug.)
213
- const designChanged = () => hasVoice && lastPersona && (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || '')
 
 
 
 
 
 
 
214
  // Badge when there's a persona but no current voice (none yet, or it went stale).
215
  function updateVoiceUI() {
216
- const needs = !!lastPersona && (!hasVoice || isDirty())
217
  playBtn.classList.toggle('badged', needs)
218
  if (playing) return // 'Stop' title owned by setPlaying while sounding
219
- playBtn.title = !lastPersona ? 'Play voice' : (!hasVoice ? 'Create voice' : (isDirty() ? 'Update & play' : 'Play voice'))
220
  }
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  function autosave() {
223
  if (!lastPersona) return
224
  const rec = savePersona({ ...lastPersona, id: savedId, unitClass: lastPersona.unitClass || sel.value, seed: lastPersona.seed || seed.value })
@@ -254,7 +295,7 @@ export function mountPersonaPanel(host) {
254
  quoteEl.textContent = p.quote || ''
255
  voiceEl.textContent = p.voice || ''
256
  hasVoice = savedId ? !!(await getAudio(savedId)) : false
257
- updateVoiceUI(); refreshVisibility()
258
  }
259
 
260
  // ▶ The one voice button: if the cached voice is current, just replay it. If the voice
@@ -265,32 +306,46 @@ export function mountPersonaPanel(host) {
265
  if (working || !lastPersona) return
266
  const line = lineFor(lastPersona)
267
 
 
 
 
 
 
 
 
 
 
268
  // Up-to-date voice exists → just replay the cached file.
269
  if (hasVoice && !isDirty()) {
270
  const blob = savedId ? await getAudio(savedId) : null
271
  if (blob) { await playBuf(await blob.arrayBuffer()); return }
272
  hasVoice = false // cache vanished — fall through to re-make it
273
  }
274
- if (!lastPersona.voice) { status.textContent = 'add a voice design first'; return }
275
  autosave() // ensure an id to key the audio
276
 
277
- // Clone only when the timbre should be preserved (voice unchanged, just new words);
278
- // otherwise design a new voice from the (possibly edited) description.
279
- const reclone = hasVoice && !designChanged()
 
280
  // Generating → the ▶ becomes a loading spinner (.busy) until the WAV is ready.
281
  working = true; playBtn.classList.add('busy'); playBtn.disabled = true
282
  const prev = status.textContent
283
- status.textContent = reclone ? 'updating the voice…' : 'designing the voice…'
284
  let wav = null
285
  try {
286
- if (reclone) {
287
  const blob = await getAudio(savedId)
288
  wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
289
- } else {
290
  wav = await createVoiceWav(lastPersona.voice, line)
 
 
291
  }
292
  await putAudio(savedId, new Blob([wav], { type: 'audio/wav' })) // save over
293
- lastPersona.voiceQuote = line; lastPersona.voiceDesignUsed = lastPersona.voice
 
 
294
  hasVoice = true; autosave()
295
  status.textContent = prev
296
  } catch (e) { status.textContent = `voice failed: ${e.message || e}` }
@@ -303,7 +358,10 @@ export function mountPersonaPanel(host) {
303
  // stops intersecting; cut the voice so it doesn't keep playing off-screen.
304
  try {
305
  new IntersectionObserver((entries) => {
306
- for (const e of entries) if (!e.isIntersecting && playing) stopVoice()
 
 
 
307
  }).observe(host)
308
  } catch { /* no IntersectionObserver — playback just won't auto-stop on nav */ }
309
 
 
7
  import { extractLivePersona } from '/web/personaStream.js'
8
  import { parsePersonaJson } from '/web/personaParse.js'
9
  import { getPersonaSystem, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
10
+ import {
11
+ createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive,
12
+ activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange,
13
+ } from '/web/tts.js'
14
  import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio } from '/web/personaStore.js'
15
 
16
  const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
 
146
  const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
147
  const quoteEl = el('blockquote', { class: 'persona-quote persona-edit', 'data-ph': 'A line they say…' })
148
  const voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
149
+ // Fixed-voice providers (Kokoro/Kitten/Web Speech) don't design from text — pick a
150
+ // named voice here instead. Hidden when the provider is Qwen3-TTS (Voice Design).
151
+ const voicePickEl = el('select', { class: 'persona-input persona-voice-pick' })
152
+ const voicePickRow = el('div', { class: 'persona-voice-pick-row' },
153
+ [el('label', { class: 'persona-label' }, 'Voice'), voicePickEl])
154
  // ▶ play sits on the Quote heading and does everything: it (re)creates the voice when
155
  // needed and replays it otherwise. A pulsing badge shows when there's no voice yet, or
156
  // the quote/voice was edited since the last one was made.
 
175
  nameEl,
176
  secHead('About'), aboutEl,
177
  secHead('Quote', playBtn), quoteEl,
178
+ secHead('Voice design'), voiceEl, voicePickRow,
179
  ])
180
  const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl, thinkWrap])
181
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
 
196
  else updateVoiceUI()
197
  }
198
  // Stop any sounding voice and reset the button (used on toggle, nav-away, new pick).
199
+ function stopVoice() { stopVoiceLive(); setPlaying(false) }
200
  // Play a WAV buffer with the button reflecting play→stop→idle, even if it's cut short.
201
  async function playBuf(arrayBuffer) {
202
  setPlaying(true)
 
214
 
215
  // The line the voice actually says (quote, else about, else a fallback).
216
  const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A hero'} reporting for duty.`
217
+ // Which "voice" identity drives the active provider, and what the cached file used.
218
+ // Qwen3-TTS the free-form DESIGN text; others the picked named voice id.
219
+ const isDesign = () => activeEngineIsDesign()
220
+ const isNative = () => activeEngineIsNative()
221
+ const voiceNow = () => (isDesign() ? (lastPersona?.voice || '') : (lastPersona?.voiceId || ''))
222
+ const voiceUsed = () => (isDesign() ? (lastPersona?.voiceDesignUsed || '') : (lastPersona?.voiceIdUsed || ''))
223
+ // Cached audio is stale if the line or the voice identity changed since it was made.
224
+ // Native (Web Speech) never caches, so it's never "dirty".
225
+ const isDirty = () => !isNative() && hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || voiceNow() !== voiceUsed())
226
+ // Only Qwen3 has a clone step: re-speak the SAME timbre when just the words changed.
227
+ // A changed DESIGN text means a new timbre → re-design (cloning would keep the old voice).
228
+ const designChanged = () => isDesign() && hasVoice && lastPersona && (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || '')
229
  // Badge when there's a persona but no current voice (none yet, or it went stale).
230
  function updateVoiceUI() {
231
+ const needs = !!lastPersona && !isNative() && (!hasVoice || isDirty())
232
  playBtn.classList.toggle('badged', needs)
233
  if (playing) return // 'Stop' title owned by setPlaying while sounding
234
+ playBtn.title = (!lastPersona || isNative()) ? 'Play voice' : (!hasVoice ? 'Create voice' : (isDirty() ? 'Update & play' : 'Play voice'))
235
  }
236
 
237
+ // Reflect the active provider: Qwen3-TTS designs from the editable text; the others
238
+ // use a named voice (text design goes read-only, the voice picker appears). Called on
239
+ // show + whenever the panel comes back into view (the provider is set in Settings).
240
+ function refreshVoiceMode() {
241
+ const design = isDesign()
242
+ voiceEl.contentEditable = design ? 'true' : 'false'
243
+ voiceEl.classList.toggle('readonly', !design)
244
+ voiceEl.setAttribute('data-ph', design ? 'How they sound…' : '(only used by Qwen3-TTS Voice Design)')
245
+ voicePickRow.style.display = design ? 'none' : ''
246
+ if (!design) {
247
+ const voices = activeVoices()
248
+ voicePickEl.replaceChildren(...voices.map((v) => el('option', { value: v.id }, v.label)))
249
+ let cur = (lastPersona && lastPersona.voiceId) || activeDefaultVoice()
250
+ if (!voices.some((v) => v.id === cur)) cur = voices[0] ? voices[0].id : ''
251
+ voicePickEl.value = cur
252
+ if (lastPersona) lastPersona.voiceId = cur
253
+ }
254
+ }
255
+ voicePickEl.addEventListener('change', () => {
256
+ if (!lastPersona) return
257
+ lastPersona.voiceId = voicePickEl.value
258
+ autosave(); updateVoiceUI()
259
+ })
260
+ // The provider is chosen on the Settings tab; re-render voice controls when it changes.
261
+ onTtsEngineChange(() => { stopVoice(); if (lastPersona) { refreshVoiceMode(); updateVoiceUI() } })
262
+
263
  function autosave() {
264
  if (!lastPersona) return
265
  const rec = savePersona({ ...lastPersona, id: savedId, unitClass: lastPersona.unitClass || sel.value, seed: lastPersona.seed || seed.value })
 
295
  quoteEl.textContent = p.quote || ''
296
  voiceEl.textContent = p.voice || ''
297
  hasVoice = savedId ? !!(await getAudio(savedId)) : false
298
+ refreshVoiceMode(); updateVoiceUI(); refreshVisibility()
299
  }
300
 
301
  // ▶ The one voice button: if the cached voice is current, just replay it. If the voice
 
306
  if (working || !lastPersona) return
307
  const line = lineFor(lastPersona)
308
 
309
+ // Native provider (Web Speech): can't render to a file — speak the line live.
310
+ if (isNative()) {
311
+ setPlaying(true)
312
+ try { await speakVoiceLive(lastPersona.voiceId || '', line) }
313
+ catch (e) { status.textContent = `voice failed: ${e.message || e}` }
314
+ finally { setPlaying(false) }
315
+ return
316
+ }
317
+
318
  // Up-to-date voice exists → just replay the cached file.
319
  if (hasVoice && !isDirty()) {
320
  const blob = savedId ? await getAudio(savedId) : null
321
  if (blob) { await playBuf(await blob.arrayBuffer()); return }
322
  hasVoice = false // cache vanished — fall through to re-make it
323
  }
324
+ if (isDesign() && !lastPersona.voice) { status.textContent = 'add a voice design first'; return }
325
  autosave() // ensure an id to key the audio
326
 
327
+ const design = isDesign()
328
+ // Qwen3 clones (same timbre, new words) only when the design text is unchanged;
329
+ // fixed-voice providers always re-synth the line in the picked named voice.
330
+ const reclone = design && hasVoice && !designChanged()
331
  // Generating → the ▶ becomes a loading spinner (.busy) until the WAV is ready.
332
  working = true; playBtn.classList.add('busy'); playBtn.disabled = true
333
  const prev = status.textContent
334
+ status.textContent = reclone ? 'updating the voice…' : (design ? 'designing the voice…' : 'creating the voice…')
335
  let wav = null
336
  try {
337
+ if (design && reclone) {
338
  const blob = await getAudio(savedId)
339
  wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
340
+ } else if (design) {
341
  wav = await createVoiceWav(lastPersona.voice, line)
342
+ } else {
343
+ wav = await synthVoiceWav(lastPersona.voiceId || '', line)
344
  }
345
  await putAudio(savedId, new Blob([wav], { type: 'audio/wav' })) // save over
346
+ lastPersona.voiceQuote = line
347
+ lastPersona.voiceDesignUsed = lastPersona.voice || ''
348
+ lastPersona.voiceIdUsed = lastPersona.voiceId || ''
349
  hasVoice = true; autosave()
350
  status.textContent = prev
351
  } catch (e) { status.textContent = `voice failed: ${e.message || e}` }
 
358
  // stops intersecting; cut the voice so it doesn't keep playing off-screen.
359
  try {
360
  new IntersectionObserver((entries) => {
361
+ for (const e of entries) {
362
+ if (!e.isIntersecting) { if (playing) stopVoice() }
363
+ else if (lastPersona) { refreshVoiceMode(); updateVoiceUI() } // provider may have changed in Settings
364
+ }
365
  }).observe(host)
366
  } catch { /* no IntersectionObserver — playback just won't auto-stop on nav */ }
367
 
web/personaPromptBar.js CHANGED
@@ -28,18 +28,24 @@ export function mountPersonaPromptBar(host) {
28
  function refreshNote() {
29
  note.textContent = isPersonaSystemCustom() ? 'Using your custom prompt.' : 'Using the built-in default.'
30
  }
31
- refreshNote()
 
 
 
 
 
 
32
 
33
  saveBtn.addEventListener('click', () => {
34
  setPersonaSystem(ta.value)
35
  ta.value = getPersonaSystem() // reflect (a default-equal edit clears the override)
36
- refreshNote()
37
  saveBtn.textContent = '✓ Saved'; setTimeout(() => { saveBtn.textContent = 'Save' }, 1400)
38
  })
39
  resetBtn.addEventListener('click', () => {
40
  resetPersonaSystem()
41
  ta.value = PERSONA_SYSTEM_DEFAULT
42
- refreshNote()
43
  })
44
 
45
  host.append(el('div', { class: 'persona-prompt-bar' }, [
@@ -47,5 +53,5 @@ export function mountPersonaPromptBar(host) {
47
  el('div', { class: 'persona-prompt-actions' }, [saveBtn, resetBtn]),
48
  note,
49
  ]))
50
- return { refresh: () => { ta.value = getPersonaSystem(); refreshNote() } }
51
  }
 
28
  function refreshNote() {
29
  note.textContent = isPersonaSystemCustom() ? 'Using your custom prompt.' : 'Using the built-in default.'
30
  }
31
+ // Badge the Save button while the textarea differs from the prompt in effect —
32
+ // i.e. there are unsaved edits. Cleared once saved (or the edit matches default).
33
+ function refreshEdited() {
34
+ saveBtn.classList.toggle('badged', ta.value.trim() !== getPersonaSystem().trim())
35
+ }
36
+ refreshNote(); refreshEdited()
37
+ ta.addEventListener('input', refreshEdited)
38
 
39
  saveBtn.addEventListener('click', () => {
40
  setPersonaSystem(ta.value)
41
  ta.value = getPersonaSystem() // reflect (a default-equal edit clears the override)
42
+ refreshNote(); refreshEdited()
43
  saveBtn.textContent = '✓ Saved'; setTimeout(() => { saveBtn.textContent = 'Save' }, 1400)
44
  })
45
  resetBtn.addEventListener('click', () => {
46
  resetPersonaSystem()
47
  ta.value = PERSONA_SYSTEM_DEFAULT
48
+ refreshNote(); refreshEdited()
49
  })
50
 
51
  host.append(el('div', { class: 'persona-prompt-bar' }, [
 
53
  el('div', { class: 'persona-prompt-actions' }, [saveBtn, resetBtn]),
54
  note,
55
  ]))
56
+ return { refresh: () => { ta.value = getPersonaSystem(); refreshNote(); refreshEdited() } }
57
  }
web/personaStore.js CHANGED
@@ -38,6 +38,9 @@ export function savePersona(p) {
38
  about: p.about || '',
39
  quote: p.quote || '',
40
  voice: p.voice || '',
 
 
 
41
  specialty: p.specialty || '',
42
  personality: p.personality || '',
43
  vibe: p.vibe || '',
@@ -46,6 +49,7 @@ export function savePersona(p) {
46
  // the audio is current (replay it) vs stale (re-make it), instead of always re-synthing.
47
  voiceQuote: p.voiceQuote || '',
48
  voiceDesignUsed: p.voiceDesignUsed || '',
 
49
  createdAt: now,
50
  updatedAt: now,
51
  }
 
38
  about: p.about || '',
39
  quote: p.quote || '',
40
  voice: p.voice || '',
41
+ // Named voice for fixed-voice providers (Kokoro/Kitten/Web Speech); the design `voice`
42
+ // text above is what Qwen3-TTS uses instead. Both are saved so a hero keeps its voice.
43
+ voiceId: p.voiceId || '',
44
  specialty: p.specialty || '',
45
  personality: p.personality || '',
46
  vibe: p.vibe || '',
 
49
  // the audio is current (replay it) vs stale (re-make it), instead of always re-synthing.
50
  voiceQuote: p.voiceQuote || '',
51
  voiceDesignUsed: p.voiceDesignUsed || '',
52
+ voiceIdUsed: p.voiceIdUsed || '',
53
  createdAt: now,
54
  updatedAt: now,
55
  }
web/settingsPanel.js CHANGED
@@ -43,8 +43,9 @@ export function mountSettingsPanel() {
43
  'The in-browser model that writes your soldiers and their war diaries. Runs on ' +
44
  'your device; models cache in your browser.', mountModelBar)
45
  injectSection(sample, 'tac-voice-settings', 'Voice',
46
- 'How war diaries are read aloud. Kokoro/Kitten run on your device; Qwen3-TTS ' +
47
- 'designs a voice in the cloud.', mountTtsBar)
 
48
  injectSection(sample, 'tac-persona-prompt-settings', 'Persona Prompt',
49
  'The system prompt that writes each hero (name, about, quote and voice design). ' +
50
  'Edit it to change their style; Save uses it on the next “Recruit hero”.', mountPersonaPromptBar)
 
43
  'The in-browser model that writes your soldiers and their war diaries. Runs on ' +
44
  'your device; models cache in your browser.', mountModelBar)
45
  injectSection(sample, 'tac-voice-settings', 'Voice',
46
+ 'The provider that voices your heroes. Qwen3-TTS designs a voice from each hero’s ' +
47
+ 'description; Kokoro/Kitten run on your device with a named voice you pick per hero. ' +
48
+ 'The voice belongs to the hero, so there’s no global voice to choose here.', mountTtsBar)
49
  injectSection(sample, 'tac-persona-prompt-settings', 'Persona Prompt',
50
  'The system prompt that writes each hero (name, about, quote and voice design). ' +
51
  'Edit it to change their style; Save uses it on the next “Recruit hero”.', mountPersonaPromptBar)
web/shell/persona.css CHANGED
@@ -104,6 +104,13 @@
104
  font-family: var(--p-mono); font-size: 12px; line-height: 1.5; color: var(--p-muted);
105
  max-width: 60ch; margin-top: 8px; font-style: italic;
106
  }
 
 
 
 
 
 
 
107
  .persona-quote {
108
  margin: 8px 0 0; padding: 4px 0 4px 16px; border-left: 3px solid var(--p-transmit);
109
  font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-style: italic;
@@ -251,15 +258,24 @@
251
  .tac-set-intro { font-size: 14px; line-height: 1.5; opacity: .75; margin: 2px 0 14px; }
252
 
253
  /* Persona-prompt editor (Settings → Persona Prompt). */
254
- .persona-prompt-bar { display: flex; flex-direction: column; gap: 10px; }
255
  .persona-prompt-edit {
256
- width: 100%; font-family: var(--p-mono); font-size: 12px; line-height: 1.55;
 
257
  color: var(--p-ink); background: var(--p-card); border: 1.5px solid var(--p-ink);
258
  border-radius: 0; padding: 10px 12px; resize: vertical; min-height: 200px;
259
  }
260
  .persona-prompt-edit:focus { outline: none; box-shadow: 0 0 0 1.5px var(--p-transmit); }
261
  .persona-prompt-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
262
- .persona-prompt-save { margin-top: 0 !important; }
 
 
 
 
 
 
 
 
263
 
264
  /* Collapsible control sections (model / voice). Desktop: no toggle, always shown. */
265
  .ctl-collapse > summary { display: none; }
 
104
  font-family: var(--p-mono); font-size: 12px; line-height: 1.5; color: var(--p-muted);
105
  max-width: 60ch; margin-top: 8px; font-style: italic;
106
  }
107
+ /* Read-only when the provider isn't Qwen3-TTS (the design text isn't used then). */
108
+ .persona-voice-desc.readonly { opacity: .6; cursor: default; }
109
+ .persona-voice-desc.readonly:hover, .persona-voice-desc.readonly:focus { background: transparent; box-shadow: none; }
110
+ /* Per-hero named-voice picker (Kokoro/Kitten/Web Speech). */
111
+ .persona-voice-pick-row { margin-top: 12px; max-width: 320px; }
112
+ .persona-voice-pick-row .persona-label { margin-top: 0; }
113
+ .persona-voice-pick { margin-top: 4px; }
114
  .persona-quote {
115
  margin: 8px 0 0; padding: 4px 0 4px 16px; border-left: 3px solid var(--p-transmit);
116
  font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-style: italic;
 
258
  .tac-set-intro { font-size: 14px; line-height: 1.5; opacity: .75; margin: 2px 0 14px; }
259
 
260
  /* Persona-prompt editor (Settings → Persona Prompt). */
261
+ .persona-prompt-bar { display: flex; flex-direction: column; gap: 10px; width: 100%; }
262
  .persona-prompt-edit {
263
+ display: block; width: 100%; max-width: none; box-sizing: border-box;
264
+ font-family: var(--p-mono); font-size: 12px; line-height: 1.55;
265
  color: var(--p-ink); background: var(--p-card); border: 1.5px solid var(--p-ink);
266
  border-radius: 0; padding: 10px 12px; resize: vertical; min-height: 200px;
267
  }
268
  .persona-prompt-edit:focus { outline: none; box-shadow: 0 0 0 1.5px var(--p-transmit); }
269
  .persona-prompt-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
270
+ /* Save sits left, Reset is pushed to the far right so the two are clearly separated. */
271
+ .persona-prompt-save { margin-top: 0 !important; position: relative; }
272
+ .persona-prompt-reset { margin-left: auto; }
273
+ /* Pulsing badge on Save = unsaved edits in the prompt. Reuses the play-button badge. */
274
+ .persona-prompt-save.badged::after {
275
+ content: ''; position: absolute; top: -5px; right: -5px; width: 10px; height: 10px;
276
+ background: var(--p-transmit); border: 1.5px solid var(--p-card); border-radius: 50%;
277
+ animation: tac-badge-pulse 1.3s ease-out infinite;
278
+ }
279
 
280
  /* Collapsible control sections (model / voice). Desktop: no toggle, always shown. */
281
  .ctl-collapse > summary { display: none; }
web/tts.js CHANGED
@@ -6,7 +6,7 @@ import { engine as kokoro } from '/web/ttsKokoro.js'
6
  import { engine as qwen3, engineLocal as qwen3local, isLocalhost } from '/web/ttsQwen3.js'
7
  import { engine as kitten } from '/web/ttsKitten.js'
8
  import { engine as webspeech } from '/web/ttsWebSpeech.js'
9
- import { playSamples, stopAudio, decodeAudio } from '/web/ttsAudio.js'
10
  import { ensurePersistentStorage } from '/web/storage.js'
11
 
12
  const ENGINES = [kokoro, qwen3local, qwen3, kitten, webspeech]
@@ -39,6 +39,34 @@ export async function playWav(arrayBuffer) {
39
  const { audio, sampleRate } = await decodeAudio(arrayBuffer)
40
  return playSamples(audio, sampleRate)
41
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  const voiceSel = {} // engineId -> chosen voice id
43
 
44
  const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
@@ -46,7 +74,15 @@ const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
46
  export const listTtsEngines = () =>
47
  ENGINES.map((e) => ({ id: e.id, label: e.label, available: e.available(), experimental: !!e.experimental, note: e.note || '' }))
48
  export const getTtsEngineId = () => activeId
49
- export function setTtsEngine(id) { if (ENGINES.some((e) => e.id === id)) activeId = id }
 
 
 
 
 
 
 
 
50
 
51
  export const listVoices = () => eng().listVoices()
52
  export const currentVoiceId = () => (voiceSel[activeId] !== undefined ? voiceSel[activeId] : eng().defaultVoice)
 
6
  import { engine as qwen3, engineLocal as qwen3local, isLocalhost } from '/web/ttsQwen3.js'
7
  import { engine as kitten } from '/web/ttsKitten.js'
8
  import { engine as webspeech } from '/web/ttsWebSpeech.js'
9
+ import { playSamples, stopAudio, decodeAudio, encodeWav } from '/web/ttsAudio.js'
10
  import { ensurePersistentStorage } from '/web/storage.js'
11
 
12
  const ENGINES = [kokoro, qwen3local, qwen3, kitten, webspeech]
 
39
  const { audio, sampleRate } = await decodeAudio(arrayBuffer)
40
  return playSamples(audio, sampleRate)
41
  }
42
+
43
+ // ── Fixed-voice engines (Kokoro / Kitten / Web Speech) ───────────────────────
44
+ // These don't "design" a voice from text; a hero picks one of the engine's named
45
+ // voices. The persona panel uses these when the active engine is NOT Qwen3.
46
+ export const activeEngineIsDesign = () => !!eng().design // Qwen3 → designs from a description
47
+ export const activeEngineIsNative = () => eng().mode === 'native' // Web Speech → speaks live, no WAV
48
+ export const activeEngineId = () => activeId
49
+ export const activeVoices = () => eng().listVoices()
50
+ export const activeDefaultVoice = () => eng().defaultVoice
51
+
52
+ // Synthesize `text` in a NAMED voice with the active PCM engine → a cacheable WAV
53
+ // (encode Kokoro/Kitten PCM, or pass through an engine that already returns WAV).
54
+ export async function synthVoiceWav(voiceId, text) {
55
+ const e = eng()
56
+ if (e.needsDownload) { await ensurePersistentStorage(); await e.ensure() }
57
+ if (e.synthWav) return e.synthWav(text, voiceId)
58
+ const { audio, sampleRate } = await e.synth(text, voiceId)
59
+ return encodeWav(audio, sampleRate)
60
+ }
61
+ // Speak `text` live in a named voice (native engines that can't render to a file).
62
+ export async function speakVoiceLive(voiceId, text) {
63
+ const e = eng()
64
+ if (e.speak) return e.speak(text, voiceId)
65
+ const { audio, sampleRate } = await e.synth(text, voiceId)
66
+ return playSamples(audio, sampleRate)
67
+ }
68
+ export function stopVoiceLive() { const e = eng(); if (e.stop) e.stop(); stopAudio() }
69
+
70
  const voiceSel = {} // engineId -> chosen voice id
71
 
72
  const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
 
74
  export const listTtsEngines = () =>
75
  ENGINES.map((e) => ({ id: e.id, label: e.label, available: e.available(), experimental: !!e.experimental, note: e.note || '' }))
76
  export const getTtsEngineId = () => activeId
77
+ // Notify listeners (e.g. the persona panel, on another tab) when the provider changes,
78
+ // so they can re-render voice controls without polling or relying on tab visibility.
79
+ const _engineListeners = new Set()
80
+ export function onTtsEngineChange(fn) { _engineListeners.add(fn); return () => _engineListeners.delete(fn) }
81
+ export function setTtsEngine(id) {
82
+ if (!ENGINES.some((e) => e.id === id) || id === activeId) return
83
+ activeId = id
84
+ for (const fn of _engineListeners) { try { fn(id) } catch { /* ignore */ } }
85
+ }
86
 
87
  export const listVoices = () => eng().listVoices()
88
  export const currentVoiceId = () => (voiceSel[activeId] !== undefined ? voiceSel[activeId] : eng().defaultVoice)
web/ttsAudio.js CHANGED
@@ -30,6 +30,23 @@ export function stopAudio() {
30
  _cur = null
31
  }
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  // Decode a WAV/audio ArrayBuffer to { audio: Float32Array, sampleRate } via the shared
34
  // AudioContext (decoding needs no user gesture; only playback does).
35
  export async function decodeAudio(arrayBuffer) {
 
30
  _cur = null
31
  }
32
 
33
+ // Encode mono Float32 samples to a 16-bit PCM WAV ArrayBuffer — so PCM engines
34
+ // (Kokoro/Kitten) can produce a cacheable voice file like Qwen3-TTS does.
35
+ export function encodeWav(float32, sampleRate) {
36
+ const n = float32.length
37
+ const buf = new ArrayBuffer(44 + n * 2)
38
+ const dv = new DataView(buf)
39
+ const str = (off, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(off + i, s.charCodeAt(i)) }
40
+ str(0, 'RIFF'); dv.setUint32(4, 36 + n * 2, true); str(8, 'WAVE')
41
+ str(12, 'fmt '); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true)
42
+ dv.setUint32(24, sampleRate, true); dv.setUint32(28, sampleRate * 2, true)
43
+ dv.setUint16(32, 2, true); dv.setUint16(34, 16, true)
44
+ str(36, 'data'); dv.setUint32(40, n * 2, true)
45
+ let off = 44
46
+ for (let i = 0; i < n; i++) { const s = Math.max(-1, Math.min(1, float32[i])); dv.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true); off += 2 }
47
+ return buf
48
+ }
49
+
50
  // Decode a WAV/audio ArrayBuffer to { audio: Float32Array, sampleRate } via the shared
51
  // AudioContext (decoding needs no user gesture; only playback does).
52
  export async function decodeAudio(arrayBuffer) {
web/ttsBar.js CHANGED
@@ -1,12 +1,11 @@
1
- // Voice/TTS picker for the war-diary panel: choose the TTS engine (Kokoro / Kitten /
2
- // Web Speech switchable so you can compare), a voice, and whether to auto-narrate
3
- // while the diary streams. Mirrors modelBar.js. The play/stop button lives in the
4
- // panel (it owns the diary text); this bar only drives the tts.js facade state.
 
5
  import {
6
  listTtsEngines, getTtsEngineId, setTtsEngine,
7
- listVoices, currentVoiceId, setVoice,
8
- ttsBackendLabel, ttsNeedsDownload,
9
- getAutoNarrate, setAutoNarrate,
10
  } from '/web/tts.js'
11
 
12
  function el(tag, props = {}, kids = []) {
@@ -22,15 +21,10 @@ function el(tag, props = {}, kids = []) {
22
 
23
  export function mountTtsBar(host, { onChange } = {}) {
24
  const engSel = el('select', { class: 'model-select engine-select' })
25
- const voiceSel = el('select', { class: 'model-select' })
26
- const auto = el('input', { type: 'checkbox', class: 'tts-auto' })
27
- const autoWrap = el('label', { class: 'tts-auto-row' }, [auto, ' narrate war diaries as they write'])
28
  const info = el('div', { class: 'model-info' })
29
  host.append(el('div', { class: 'model-bar tts-bar' }, [
30
- el('label', { class: 'persona-label' }, '🔊 Voice (reads war diaries aloud)'),
31
- engSel,
32
- el('label', { class: 'persona-label' }, 'Voice'),
33
- voiceSel, info, autoWrap,
34
  ]))
35
 
36
  engSel.replaceChildren(...listTtsEngines().map((e) =>
@@ -38,26 +32,15 @@ export function mountTtsBar(host, { onChange } = {}) {
38
  `${e.label}${e.available ? '' : ' · ' + (e.note || 'n/a')}`)))
39
  engSel.value = getTtsEngineId()
40
 
41
- function renderVoices() {
42
- const voices = listVoices()
43
- voiceSel.replaceChildren(...(voices.length
44
- ? voices.map((v) => el('option', { value: v.id }, v.label))
45
- : [el('option', { value: '' }, 'default')]))
46
- const cur = currentVoiceId()
47
- if (voices.some((v) => v.id === cur)) voiceSel.value = cur
48
- info.textContent = `${ttsBackendLabel()}${ttsNeedsDownload() ? ' · downloads on first use' : ' · no download'}`
49
  }
50
 
51
- engSel.addEventListener('change', () => { setTtsEngine(engSel.value); renderVoices(); onChange && onChange() })
52
- voiceSel.addEventListener('change', () => { setVoice(voiceSel.value); onChange && onChange() })
53
 
54
- // auto-narrate is global state (the diary reads it); reflect + persist it here.
55
- auto.checked = getAutoNarrate()
56
- auto.addEventListener('change', () => setAutoNarrate(auto.checked))
57
-
58
- // Web Speech voices populate asynchronously.
59
- if (typeof speechSynthesis !== 'undefined') speechSynthesis.onvoiceschanged = () => renderVoices()
60
-
61
- renderVoices()
62
- return { autoNarrate: () => auto.checked, refresh: renderVoices }
63
  }
 
1
+ // Voice-PROVIDER picker for the Settings page: choose the TTS engine that voices your
2
+ // heroes (Qwen3-TTS designs a voice from each hero's description; Kokoro/Kitten/Web Speech
3
+ // use a named voice picked per-hero on the persona page). No voice dropdown here the
4
+ // voice is a property of the hero, not a global setting. This bar only sets the engine on
5
+ // the shared tts.js facade; every page reads that choice.
6
  import {
7
  listTtsEngines, getTtsEngineId, setTtsEngine,
8
+ ttsBackendLabel, ttsNeedsDownload, activeEngineIsDesign,
 
 
9
  } from '/web/tts.js'
10
 
11
  function el(tag, props = {}, kids = []) {
 
21
 
22
  export function mountTtsBar(host, { onChange } = {}) {
23
  const engSel = el('select', { class: 'model-select engine-select' })
 
 
 
24
  const info = el('div', { class: 'model-info' })
25
  host.append(el('div', { class: 'model-bar tts-bar' }, [
26
+ el('label', { class: 'persona-label' }, '🔊 Voice provider'),
27
+ engSel, info,
 
 
28
  ]))
29
 
30
  engSel.replaceChildren(...listTtsEngines().map((e) =>
 
32
  `${e.label}${e.available ? '' : ' · ' + (e.note || 'n/a')}`)))
33
  engSel.value = getTtsEngineId()
34
 
35
+ function renderInfo() {
36
+ const how = activeEngineIsDesign()
37
+ ? 'designs a voice from each hero’s description'
38
+ : 'pick a named voice per hero on the Personas page'
39
+ info.textContent = `${ttsBackendLabel()} · ${how}${ttsNeedsDownload() ? ' · downloads on first use' : ''}`
 
 
 
40
  }
41
 
42
+ engSel.addEventListener('change', () => { setTtsEngine(engSel.value); renderInfo(); onChange && onChange() })
 
43
 
44
+ renderInfo()
45
+ return { refresh: renderInfo }
 
 
 
 
 
 
 
46
  }
web/ttsQwen3.js CHANGED
@@ -67,6 +67,7 @@ async function postClone(base, text, refAb, refText, instruct) {
67
 
68
  const common = {
69
  mode: 'pcm', needsDownload: false, networked: true,
 
70
  listVoices: () => VOICES, defaultVoice: 'persona',
71
  ensure: async () => { /* nothing to load — server-side */ },
72
  setDesc(d) { _desc = (d || '').trim() }, // shared _desc across both variants
 
67
 
68
  const common = {
69
  mode: 'pcm', needsDownload: false, networked: true,
70
+ design: true, // designs a voice from a free-form description (the persona's `voice`)
71
  listVoices: () => VOICES, defaultVoice: 'persona',
72
  ensure: async () => { /* nothing to load — server-side */ },
73
  setDesc(d) { _desc = (d || '').trim() }, // shared _desc across both variants