Spaces:
Running
Voice: provider-driven per-hero voices; settings is provider-only
Browse filesSettings → 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 +78 -20
- web/personaPromptBar.js +10 -4
- web/personaStore.js +4 -0
- web/settingsPanel.js +3 -2
- web/shell/persona.css +19 -3
- web/tts.js +38 -2
- web/ttsAudio.js +17 -0
- web/ttsBar.js +16 -33
- web/ttsQwen3.js +1 -0
|
@@ -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 {
|
|
|
|
|
|
|
|
|
|
| 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() {
|
| 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 |
-
//
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 278 |
-
//
|
| 279 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 |
}
|
|
@@ -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 |
-
'
|
| 47 |
-
'
|
|
|
|
| 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)
|
|
@@ -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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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; }
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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) {
|
|
@@ -1,12 +1,11 @@
|
|
| 1 |
-
// Voice
|
| 2 |
-
//
|
| 3 |
-
//
|
| 4 |
-
//
|
|
|
|
| 5 |
import {
|
| 6 |
listTtsEngines, getTtsEngineId, setTtsEngine,
|
| 7 |
-
|
| 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
|
| 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
|
| 42 |
-
const
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 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);
|
| 52 |
-
voiceSel.addEventListener('change', () => { setVoice(voiceSel.value); onChange && onChange() })
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 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 |
}
|
|
@@ -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
|