Spaces:
Running
Running
Persona: editable fields (auto-save), quote after about, Create/Replay voice
Browse files- Reorder result: name → tags → about → quote → voice design.
- All fields (name/about/quote/voice) are click-to-edit (contentEditable); every edit
auto-saves to the roster — no Save button. Generated personas auto-save immediately.
- "Hear voice" → "🎙 Create voice": synthesizes the QUOTE in the designed voice, caches
the WAV in IndexedDB (personaStore put/getAudio), and plays it. Once created, a ▶
replay button beside the quote replays the exact cached file; button becomes
"Recreate voice".
- tts.js: createVoiceWav() returns the raw WAV (caller caches/plays); qwen3.synthWav()
added. Verified end-to-end on the local GPU: synth → cache → retrieve.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/personaPanel.js +96 -68
- web/personaStore.js +31 -0
- web/shell/persona.css +26 -10
- web/tts.js +12 -1
- web/ttsQwen3.js +5 -3
web/personaPanel.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
| 1 |
// Tiny Army persona panel — mounted by tiny.js into #persona-stage. Recruits a soldier
|
| 2 |
-
// (name/about/traits + a voice design + a spoken quote)
|
| 3 |
-
//
|
| 4 |
-
//
|
|
|
|
| 5 |
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
|
| 6 |
import { extractLivePersona } from '/web/personaStream.js'
|
| 7 |
import { parsePersonaJson } from '/web/personaParse.js'
|
| 8 |
import { PERSONA_SYSTEM, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
|
| 9 |
-
import {
|
| 10 |
-
import { listPersonas, savePersona, removePersona, onRosterChange } from '/web/personaStore.js'
|
| 11 |
|
| 12 |
const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
|
| 13 |
const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
|
|
@@ -31,13 +32,15 @@ export function mountPersonaPanel(host) {
|
|
| 31 |
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit a soldier')
|
| 32 |
const rosterEl = el('div', { class: 'persona-roster' })
|
| 33 |
|
| 34 |
-
const nameEl = el('div', { class: 'persona-name
|
| 35 |
const tagsEl = el('div', { class: 'persona-tags' })
|
| 36 |
-
const
|
| 37 |
-
const
|
| 38 |
-
const
|
| 39 |
-
const
|
| 40 |
-
const
|
|
|
|
|
|
|
| 41 |
const thinkEl = el('pre', { class: 'persona-think' })
|
| 42 |
const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
|
| 43 |
const thinkWrap = el('details', { class: 'persona-think-wrap' },
|
|
@@ -49,69 +52,101 @@ export function mountPersonaPanel(host) {
|
|
| 49 |
btn, stats, status,
|
| 50 |
el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl,
|
| 51 |
])
|
| 52 |
-
const result = el('div', { class: 'persona-result' },
|
|
|
|
| 53 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
| 54 |
|
| 55 |
-
let lastPersona = null
|
| 56 |
-
let savedId = null
|
| 57 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
function setTags(p) {
|
| 60 |
tagsEl.replaceChildren(...[p.specialty, p.personality, p.vibe].filter(Boolean)
|
| 61 |
.map((t) => el('span', { class: 'persona-tag' }, t)))
|
| 62 |
}
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
lastPersona = p
|
| 67 |
savedId = opts.savedId || null
|
| 68 |
-
nameEl.textContent = p.name || '
|
| 69 |
setTags(p)
|
| 70 |
-
quoteEl.textContent = p.quote ? `“${p.quote}”` : ''
|
| 71 |
aboutEl.textContent = p.about || ''
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
-
//
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
if (!lastPersona
|
| 84 |
-
|
| 85 |
-
|
| 86 |
const prev = status.textContent
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
-
|
| 93 |
|
| 94 |
-
|
| 95 |
-
if (!
|
| 96 |
-
const
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
}
|
|
|
|
| 100 |
|
| 101 |
// ── Barracks roster (saved soldiers) ──────────────────────────────────────
|
| 102 |
function renderRoster(personas) {
|
| 103 |
if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No soldiers saved yet.')); return }
|
| 104 |
rosterEl.replaceChildren(...personas.map((p) =>
|
| 105 |
-
el('div', { class: 'persona-roster-item' }, [
|
| 106 |
-
el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }) },
|
| 107 |
-
`${p.name}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
|
| 108 |
el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
|
| 109 |
])))
|
| 110 |
}
|
| 111 |
renderRoster(listPersonas())
|
| 112 |
onRosterChange(renderRoster)
|
| 113 |
|
| 114 |
-
// A self-contained, paste-ready report of the last run.
|
| 115 |
let lastDebug = ''
|
| 116 |
function buildDebug(outcome, acc) {
|
| 117 |
const stripped = stripThinkFinal(acc || '')
|
|
@@ -121,18 +156,15 @@ export function mountPersonaPanel(host) {
|
|
| 121 |
`model: ${currentModelId()} (${currentModel().label})`,
|
| 122 |
`input: class=${sel.value} seed=${seed.value || '(none)'} maxTokens=${MAX_TOKENS}`,
|
| 123 |
`outcome: ${outcome}`,
|
| 124 |
-
`--- raw output (${(acc || '').length} chars) ---`,
|
| 125 |
-
|
| 126 |
-
`--- after stripThink → parser (${stripped.length} chars) ---`,
|
| 127 |
-
stripped || '(empty)',
|
| 128 |
].join('\n')
|
| 129 |
}
|
| 130 |
copyBtn.addEventListener('click', async () => {
|
| 131 |
const text = lastDebug || buildDebug('(no generation yet)', '')
|
| 132 |
try {
|
| 133 |
await navigator.clipboard.writeText(text)
|
| 134 |
-
copyBtn.textContent = '✓ copied'
|
| 135 |
-
setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
|
| 136 |
} catch {
|
| 137 |
thinkEl.textContent = text; thinkWrap.open = true
|
| 138 |
const r = document.createRange(); r.selectNodeContents(thinkEl)
|
|
@@ -148,8 +180,8 @@ export function mountPersonaPanel(host) {
|
|
| 148 |
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 149 |
nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
|
| 150 |
quoteEl.textContent = ''; voiceEl.textContent = ''
|
| 151 |
-
|
| 152 |
-
|
| 153 |
thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
|
| 154 |
let acc = ''
|
| 155 |
try {
|
|
@@ -160,8 +192,7 @@ export function mountPersonaPanel(host) {
|
|
| 160 |
maxTokens: MAX_TOKENS,
|
| 161 |
onToken: (piece) => {
|
| 162 |
acc += piece
|
| 163 |
-
thinkEl.textContent = acc
|
| 164 |
-
thinkEl.scrollTop = thinkEl.scrollHeight
|
| 165 |
const live = extractLivePersona(stripThink(acc))
|
| 166 |
if (live.name) nameEl.textContent = live.name
|
| 167 |
if (live.about) aboutEl.textContent = live.about
|
|
@@ -170,22 +201,19 @@ export function mountPersonaPanel(host) {
|
|
| 170 |
})
|
| 171 |
try {
|
| 172 |
const p = parsePersonaJson(stripThinkFinal(acc))
|
| 173 |
-
showPersona(p)
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
| 177 |
} catch (e) {
|
| 178 |
status.textContent = `the model rambled — couldn't parse a clean persona (${e.message || e}) · 📋 Copy debug`
|
| 179 |
-
lastDebug = buildDebug('PARSE ERROR: ' + (e.message || e), acc)
|
| 180 |
-
thinkWrap.open = true
|
| 181 |
}
|
| 182 |
} catch (e) {
|
| 183 |
status.textContent = `couldn't run the local model: ${e.message || e} · 📋 Copy debug`
|
| 184 |
-
lastDebug = buildDebug('EXCEPTION: ' + (e.message || e) + (e.stack ? '\n' + e.stack : ''), acc)
|
| 185 |
-
|
| 186 |
-
} finally {
|
| 187 |
-
busy = false; btn.disabled = false
|
| 188 |
-
}
|
| 189 |
}
|
| 190 |
btn.addEventListener('click', generate)
|
| 191 |
}
|
|
|
|
| 1 |
// Tiny Army persona panel — mounted by tiny.js into #persona-stage. Recruits a soldier
|
| 2 |
+
// (name/about/traits + a voice design + a spoken quote), lets you CREATE a voice file of
|
| 3 |
+
// them saying their quote (Qwen3-TTS) and REPLAY it, edit every field inline (auto-saved),
|
| 4 |
+
// and keeps everyone in a local-first barracks roster (personaStore) so they persist for
|
| 5 |
+
// returning visitors. Modeled on woid's agent store.
|
| 6 |
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
|
| 7 |
import { extractLivePersona } from '/web/personaStream.js'
|
| 8 |
import { parsePersonaJson } from '/web/personaParse.js'
|
| 9 |
import { PERSONA_SYSTEM, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
|
| 10 |
+
import { createVoiceWav, 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']
|
| 14 |
const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
|
|
|
|
| 32 |
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit a soldier')
|
| 33 |
const rosterEl = el('div', { class: 'persona-roster' })
|
| 34 |
|
| 35 |
+
const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
|
| 36 |
const tagsEl = el('div', { class: 'persona-tags' })
|
| 37 |
+
const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
|
| 38 |
+
const quoteEl = el('blockquote', { class: 'persona-quote persona-edit', 'data-ph': 'A line they say…' })
|
| 39 |
+
const replayBtn = el('button', { class: 'persona-replay', type: 'button', title: 'Replay voice', style: 'display:none' }, '▶')
|
| 40 |
+
const quoteRow = el('div', { class: 'persona-quote-row' }, [quoteEl, replayBtn])
|
| 41 |
+
const voiceLabel = el('div', { class: 'persona-voice-lbl' }, '🎙 Voice design')
|
| 42 |
+
const voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
|
| 43 |
+
const createBtn = el('button', { class: 'persona-go persona-go-alt persona-create', type: 'button', style: 'display:none' }, '🎙 Create voice')
|
| 44 |
const thinkEl = el('pre', { class: 'persona-think' })
|
| 45 |
const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
|
| 46 |
const thinkWrap = el('details', { class: 'persona-think-wrap' },
|
|
|
|
| 52 |
btn, stats, status,
|
| 53 |
el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl,
|
| 54 |
])
|
| 55 |
+
const result = el('div', { class: 'persona-result' },
|
| 56 |
+
[nameEl, tagsEl, aboutEl, quoteRow, voiceLabel, voiceEl, el('div', { class: 'persona-actions' }, [createBtn]), thinkWrap])
|
| 57 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
| 58 |
|
| 59 |
+
let lastPersona = null // the persona currently shown
|
| 60 |
+
let savedId = null // its roster id (set the moment it's shown — always saved)
|
| 61 |
+
let working = false
|
| 62 |
+
|
| 63 |
+
function autosave() {
|
| 64 |
+
if (!lastPersona) return
|
| 65 |
+
const rec = savePersona({ ...lastPersona, id: savedId, unitClass: lastPersona.unitClass || sel.value, seed: lastPersona.seed || seed.value })
|
| 66 |
+
savedId = rec.id
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// Make a field click-to-edit; persist on blur (always save after edit — no button).
|
| 70 |
+
function editable(elm, field, { single = false } = {}) {
|
| 71 |
+
elm.contentEditable = 'true'
|
| 72 |
+
elm.spellcheck = false
|
| 73 |
+
if (single) elm.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); elm.blur() } })
|
| 74 |
+
elm.addEventListener('blur', () => {
|
| 75 |
+
if (!lastPersona) return
|
| 76 |
+
const v = elm.textContent.trim()
|
| 77 |
+
if ((lastPersona[field] || '') === v) return
|
| 78 |
+
lastPersona[field] = v
|
| 79 |
+
autosave()
|
| 80 |
+
})
|
| 81 |
+
}
|
| 82 |
+
editable(nameEl, 'name', { single: true })
|
| 83 |
+
editable(aboutEl, 'about')
|
| 84 |
+
editable(quoteEl, 'quote', { single: true })
|
| 85 |
+
editable(voiceEl, 'voice')
|
| 86 |
|
| 87 |
function setTags(p) {
|
| 88 |
tagsEl.replaceChildren(...[p.specialty, p.personality, p.vibe].filter(Boolean)
|
| 89 |
.map((t) => el('span', { class: 'persona-tag' }, t)))
|
| 90 |
}
|
| 91 |
|
| 92 |
+
async function showPersona(p, opts = {}) {
|
| 93 |
+
lastPersona = { ...p }
|
|
|
|
| 94 |
savedId = opts.savedId || null
|
| 95 |
+
nameEl.textContent = p.name || ''
|
| 96 |
setTags(p)
|
|
|
|
| 97 |
aboutEl.textContent = p.about || ''
|
| 98 |
+
quoteEl.textContent = p.quote || ''
|
| 99 |
+
voiceEl.textContent = p.voice || ''
|
| 100 |
+
createBtn.style.display = ''
|
| 101 |
+
// Show replay if we have a cached voice file for this saved persona.
|
| 102 |
+
const has = savedId ? !!(await getAudio(savedId)) : false
|
| 103 |
+
replayBtn.style.display = has ? '' : 'none'
|
| 104 |
+
createBtn.textContent = has ? '🎙 Recreate voice' : '🎙 Create voice'
|
| 105 |
}
|
| 106 |
|
| 107 |
+
// 🎙 Create voice — synth the QUOTE in the designed voice, cache the WAV, play it.
|
| 108 |
+
async function createVoice() {
|
| 109 |
+
if (working || !lastPersona) return
|
| 110 |
+
const line = (lastPersona.quote || '').trim() || (lastPersona.about || '').trim() || `${lastPersona.name} reporting for duty.`
|
| 111 |
+
if (!lastPersona.voice) { status.textContent = 'add a voice design first'; return }
|
| 112 |
+
autosave() // ensure an id to key the audio
|
| 113 |
+
working = true; const prevTxt = createBtn.textContent; createBtn.textContent = '🎙 designing…'; createBtn.disabled = true
|
| 114 |
const prev = status.textContent
|
| 115 |
+
try {
|
| 116 |
+
const wav = await createVoiceWav(lastPersona.voice, line)
|
| 117 |
+
await putAudio(savedId, new Blob([wav], { type: 'audio/wav' }))
|
| 118 |
+
try { await playWav(wav.slice(0)) } catch { /* autoplay blocked — replay button still works */ }
|
| 119 |
+
replayBtn.style.display = ''
|
| 120 |
+
createBtn.textContent = '🎙 Recreate voice'
|
| 121 |
+
status.textContent = prev
|
| 122 |
+
} catch (e) {
|
| 123 |
+
status.textContent = `voice failed: ${e.message || e}`
|
| 124 |
+
createBtn.textContent = prevTxt
|
| 125 |
+
} finally { working = false; createBtn.disabled = false }
|
| 126 |
}
|
| 127 |
+
createBtn.addEventListener('click', createVoice)
|
| 128 |
|
| 129 |
+
async function replay() {
|
| 130 |
+
if (!savedId) return
|
| 131 |
+
const blob = await getAudio(savedId)
|
| 132 |
+
if (!blob) return createVoice()
|
| 133 |
+
try { await playWav(await blob.arrayBuffer()) } catch { /* ignore */ }
|
| 134 |
+
}
|
| 135 |
+
replayBtn.addEventListener('click', replay)
|
| 136 |
|
| 137 |
// ── Barracks roster (saved soldiers) ──────────────────────────────────────
|
| 138 |
function renderRoster(personas) {
|
| 139 |
if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No soldiers saved yet.')); return }
|
| 140 |
rosterEl.replaceChildren(...personas.map((p) =>
|
| 141 |
+
el('div', { class: 'persona-roster-item' + (p.id === savedId ? ' active' : '') }, [
|
| 142 |
+
el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }).then(() => renderRoster(listPersonas())) },
|
| 143 |
+
`${p.name || 'Soldier'}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
|
| 144 |
el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
|
| 145 |
])))
|
| 146 |
}
|
| 147 |
renderRoster(listPersonas())
|
| 148 |
onRosterChange(renderRoster)
|
| 149 |
|
|
|
|
| 150 |
let lastDebug = ''
|
| 151 |
function buildDebug(outcome, acc) {
|
| 152 |
const stripped = stripThinkFinal(acc || '')
|
|
|
|
| 156 |
`model: ${currentModelId()} (${currentModel().label})`,
|
| 157 |
`input: class=${sel.value} seed=${seed.value || '(none)'} maxTokens=${MAX_TOKENS}`,
|
| 158 |
`outcome: ${outcome}`,
|
| 159 |
+
`--- raw output (${(acc || '').length} chars) ---`, acc || '(empty)',
|
| 160 |
+
`--- after stripThink → parser (${stripped.length} chars) ---`, stripped || '(empty)',
|
|
|
|
|
|
|
| 161 |
].join('\n')
|
| 162 |
}
|
| 163 |
copyBtn.addEventListener('click', async () => {
|
| 164 |
const text = lastDebug || buildDebug('(no generation yet)', '')
|
| 165 |
try {
|
| 166 |
await navigator.clipboard.writeText(text)
|
| 167 |
+
copyBtn.textContent = '✓ copied'; setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
|
|
|
|
| 168 |
} catch {
|
| 169 |
thinkEl.textContent = text; thinkWrap.open = true
|
| 170 |
const r = document.createRange(); r.selectNodeContents(thinkEl)
|
|
|
|
| 180 |
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 181 |
nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
|
| 182 |
quoteEl.textContent = ''; voiceEl.textContent = ''
|
| 183 |
+
createBtn.style.display = 'none'; replayBtn.style.display = 'none'; lastPersona = null; savedId = null
|
| 184 |
+
stopPreview()
|
| 185 |
thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
|
| 186 |
let acc = ''
|
| 187 |
try {
|
|
|
|
| 192 |
maxTokens: MAX_TOKENS,
|
| 193 |
onToken: (piece) => {
|
| 194 |
acc += piece
|
| 195 |
+
thinkEl.textContent = acc; thinkEl.scrollTop = thinkEl.scrollHeight
|
|
|
|
| 196 |
const live = extractLivePersona(stripThink(acc))
|
| 197 |
if (live.name) nameEl.textContent = live.name
|
| 198 |
if (live.about) aboutEl.textContent = live.about
|
|
|
|
| 201 |
})
|
| 202 |
try {
|
| 203 |
const p = parsePersonaJson(stripThinkFinal(acc))
|
| 204 |
+
await showPersona(p)
|
| 205 |
+
autosave() // generated personas are saved immediately (no Save button)
|
| 206 |
+
renderRoster(listPersonas())
|
| 207 |
+
status.textContent = 'enlisted ✓ (saved) — edit any field, or create a voice'
|
| 208 |
+
lastDebug = buildDebug('parsed OK', acc); thinkWrap.open = false
|
| 209 |
} catch (e) {
|
| 210 |
status.textContent = `the model rambled — couldn't parse a clean persona (${e.message || e}) · 📋 Copy debug`
|
| 211 |
+
lastDebug = buildDebug('PARSE ERROR: ' + (e.message || e), acc); thinkWrap.open = true
|
|
|
|
| 212 |
}
|
| 213 |
} catch (e) {
|
| 214 |
status.textContent = `couldn't run the local model: ${e.message || e} · 📋 Copy debug`
|
| 215 |
+
lastDebug = buildDebug('EXCEPTION: ' + (e.message || e) + (e.stack ? '\n' + e.stack : ''), acc); thinkWrap.open = true
|
| 216 |
+
} finally { busy = false; btn.disabled = false }
|
|
|
|
|
|
|
|
|
|
| 217 |
}
|
| 218 |
btn.addEventListener('click', generate)
|
| 219 |
}
|
web/personaStore.js
CHANGED
|
@@ -56,4 +56,35 @@ export function removePersona(id) {
|
|
| 56 |
const d = read()
|
| 57 |
const next = d.personas.filter((x) => x.id !== id)
|
| 58 |
if (next.length !== d.personas.length) write({ personas: next })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
|
|
|
| 56 |
const d = read()
|
| 57 |
const next = d.personas.filter((x) => x.id !== id)
|
| 58 |
if (next.length !== d.personas.length) write({ personas: next })
|
| 59 |
+
delAudio(id)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// ── Voice audio store (IndexedDB — WAV blobs are too big for localStorage) ──────
|
| 63 |
+
const DB = 'tinyarmy', STORE = 'voices'
|
| 64 |
+
let _dbp = null
|
| 65 |
+
function db() {
|
| 66 |
+
if (!_dbp) {
|
| 67 |
+
_dbp = new Promise((res, rej) => {
|
| 68 |
+
const r = indexedDB.open(DB, 1)
|
| 69 |
+
r.onupgradeneeded = () => { if (!r.result.objectStoreNames.contains(STORE)) r.result.createObjectStore(STORE) }
|
| 70 |
+
r.onsuccess = () => res(r.result)
|
| 71 |
+
r.onerror = () => rej(r.error)
|
| 72 |
+
})
|
| 73 |
+
}
|
| 74 |
+
return _dbp
|
| 75 |
+
}
|
| 76 |
+
export async function putAudio(id, blob) {
|
| 77 |
+
try {
|
| 78 |
+
const d = await db()
|
| 79 |
+
await new Promise((res, rej) => { const t = d.transaction(STORE, 'readwrite'); t.objectStore(STORE).put(blob, id); t.oncomplete = res; t.onerror = () => rej(t.error) })
|
| 80 |
+
} catch { /* best-effort */ }
|
| 81 |
+
}
|
| 82 |
+
export async function getAudio(id) {
|
| 83 |
+
try {
|
| 84 |
+
const d = await db()
|
| 85 |
+
return await new Promise((res) => { const t = d.transaction(STORE, 'readonly'); const q = t.objectStore(STORE).get(id); q.onsuccess = () => res(q.result || null); q.onerror = () => res(null) })
|
| 86 |
+
} catch { return null }
|
| 87 |
+
}
|
| 88 |
+
async function delAudio(id) {
|
| 89 |
+
try { const d = await db(); d.transaction(STORE, 'readwrite').objectStore(STORE).delete(id) } catch { /* ignore */ }
|
| 90 |
}
|
web/shell/persona.css
CHANGED
|
@@ -62,19 +62,35 @@
|
|
| 62 |
font-size: 17px; line-height: 1.6; max-width: 60ch; color: var(--p-ink);
|
| 63 |
white-space: pre-wrap;
|
| 64 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
.persona-voice-desc {
|
| 66 |
-
font-family: var(--p-mono); font-size:
|
| 67 |
-
max-width: 60ch; margin-top:
|
| 68 |
}
|
| 69 |
-
.persona-
|
| 70 |
.persona-quote {
|
| 71 |
-
|
| 72 |
-
font-family: 'Fraunces', Georgia, serif; font-size:
|
| 73 |
-
line-height: 1.35; color: var(--p-ink); max-width:
|
| 74 |
-
}
|
| 75 |
-
.persona-quote:empty {
|
| 76 |
-
.persona-
|
| 77 |
-
.persona-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
/* ── Barracks roster (saved soldiers) ──────────────────────────────────────── */
|
| 80 |
.persona-roster-label { margin-top: 18px; }
|
|
|
|
| 62 |
font-size: 17px; line-height: 1.6; max-width: 60ch; color: var(--p-ink);
|
| 63 |
white-space: pre-wrap;
|
| 64 |
}
|
| 65 |
+
.persona-voice-lbl {
|
| 66 |
+
font-family: var(--p-mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
|
| 67 |
+
color: var(--p-muted); margin-top: 16px;
|
| 68 |
+
}
|
| 69 |
.persona-voice-desc {
|
| 70 |
+
font-family: var(--p-mono); font-size: 12px; line-height: 1.5; color: var(--p-muted);
|
| 71 |
+
max-width: 60ch; margin-top: 4px; font-style: italic;
|
| 72 |
}
|
| 73 |
+
.persona-quote-row { display: flex; align-items: flex-start; gap: 10px; margin-top: 16px; }
|
| 74 |
.persona-quote {
|
| 75 |
+
flex: 1; margin: 0; padding: 6px 0 6px 16px; border-left: 3px solid var(--p-transmit);
|
| 76 |
+
font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-style: italic;
|
| 77 |
+
line-height: 1.35; color: var(--p-ink); max-width: 54ch;
|
| 78 |
+
}
|
| 79 |
+
.persona-quote:not(:empty)::before { content: '“'; }
|
| 80 |
+
.persona-quote:not(:empty)::after { content: '”'; }
|
| 81 |
+
.persona-replay {
|
| 82 |
+
flex-shrink: 0; cursor: pointer; margin-top: 4px;
|
| 83 |
+
font-size: 13px !important; color: var(--p-paper) !important; background: var(--p-transmit) !important;
|
| 84 |
+
border: 1.5px solid var(--p-transmit) !important; border-radius: 0 !important; padding: 6px 11px !important; line-height: 1;
|
| 85 |
+
}
|
| 86 |
+
.persona-replay:hover { background: var(--p-ink) !important; border-color: var(--p-ink) !important; }
|
| 87 |
+
.persona-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
|
| 88 |
+
|
| 89 |
+
/* Click-to-edit fields (name / about / quote / voice) — auto-saved on blur. */
|
| 90 |
+
.persona-edit { cursor: text; border-radius: 0; outline: none; transition: background .12s; }
|
| 91 |
+
.persona-edit:hover { background: color-mix(in srgb, var(--p-card) 60%, transparent); box-shadow: 0 0 0 1px var(--p-paper-2); }
|
| 92 |
+
.persona-edit:focus { background: var(--p-card); box-shadow: 0 0 0 1.5px var(--p-transmit); }
|
| 93 |
+
.persona-edit:empty::before { content: attr(data-ph); color: var(--p-muted); opacity: .6; font-style: italic; }
|
| 94 |
|
| 95 |
/* ── Barracks roster (saved soldiers) ──────────────────────────────────────── */
|
| 96 |
.persona-roster-label { margin-top: 18px; }
|
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 } from '/web/ttsAudio.js'
|
| 10 |
import { ensurePersistentStorage } from '/web/storage.js'
|
| 11 |
|
| 12 |
const ENGINES = [kokoro, qwen3local, qwen3, kitten, webspeech]
|
|
@@ -23,6 +23,17 @@ export async function previewVoice(desc, text) {
|
|
| 23 |
return playSamples(audio, sampleRate)
|
| 24 |
}
|
| 25 |
export const stopPreview = () => stopAudio()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
const voiceSel = {} // engineId -> chosen voice id
|
| 27 |
|
| 28 |
const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
|
|
|
|
| 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]
|
|
|
|
| 23 |
return playSamples(audio, sampleRate)
|
| 24 |
}
|
| 25 |
export const stopPreview = () => stopAudio()
|
| 26 |
+
|
| 27 |
+
// Create a persona's voice FILE: synth the line in the designed voice and return the
|
| 28 |
+
// raw WAV (ArrayBuffer) so it can be cached + replayed verbatim. Caller plays it.
|
| 29 |
+
export async function createVoiceWav(desc, text) {
|
| 30 |
+
qwen3.setDesc(desc)
|
| 31 |
+
return qwen3.synthWav(text, 'persona')
|
| 32 |
+
}
|
| 33 |
+
export async function playWav(arrayBuffer) {
|
| 34 |
+
const { audio, sampleRate } = await decodeAudio(arrayBuffer)
|
| 35 |
+
return playSamples(audio, sampleRate)
|
| 36 |
+
}
|
| 37 |
const voiceSel = {} // engineId -> chosen voice id
|
| 38 |
|
| 39 |
const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
|
web/ttsQwen3.js
CHANGED
|
@@ -35,16 +35,17 @@ export const isLocalhost = () => {
|
|
| 35 |
try { return /^(localhost|127\.0\.0\.1|\[?::1\]?|0\.0\.0\.0)$/i.test(location.hostname) } catch { return false }
|
| 36 |
}
|
| 37 |
|
| 38 |
-
// POST to `${base}/qwen-tts` → WAV
|
| 39 |
-
async function
|
| 40 |
const instruct = (get(voiceId).desc() || '').trim()
|
| 41 |
const resp = await fetch(`${base}/qwen-tts`, {
|
| 42 |
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 43 |
body: JSON.stringify({ text, instruct, language: 'English' }),
|
| 44 |
})
|
| 45 |
if (!resp.ok) throw new Error(`Qwen3-TTS ${resp.status}: ${(await resp.text()).slice(0, 140)}`)
|
| 46 |
-
return
|
| 47 |
}
|
|
|
|
| 48 |
|
| 49 |
const common = {
|
| 50 |
mode: 'pcm', needsDownload: false, networked: true,
|
|
@@ -60,6 +61,7 @@ export const engine = {
|
|
| 60 |
label: 'Qwen3-TTS · Voice Design (cloud)',
|
| 61 |
available: () => true,
|
| 62 |
synth: (text, voiceId) => postSynth(ttsBase(), text, voiceId),
|
|
|
|
| 63 |
backendLabel: () => { const b = ttsBase(); try { return b ? '🖥 ' + new URL(b).host : '☁ DashScope' } catch { return '☁ DashScope' } },
|
| 64 |
}
|
| 65 |
|
|
|
|
| 35 |
try { return /^(localhost|127\.0\.0\.1|\[?::1\]?|0\.0\.0\.0)$/i.test(location.hostname) } catch { return false }
|
| 36 |
}
|
| 37 |
|
| 38 |
+
// POST to `${base}/qwen-tts` → raw WAV ArrayBuffer. base '' = same-origin.
|
| 39 |
+
async function postSynthWav(base, text, voiceId) {
|
| 40 |
const instruct = (get(voiceId).desc() || '').trim()
|
| 41 |
const resp = await fetch(`${base}/qwen-tts`, {
|
| 42 |
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 43 |
body: JSON.stringify({ text, instruct, language: 'English' }),
|
| 44 |
})
|
| 45 |
if (!resp.ok) throw new Error(`Qwen3-TTS ${resp.status}: ${(await resp.text()).slice(0, 140)}`)
|
| 46 |
+
return resp.arrayBuffer()
|
| 47 |
}
|
| 48 |
+
const postSynth = async (base, text, voiceId) => decodeAudio(await postSynthWav(base, text, voiceId))
|
| 49 |
|
| 50 |
const common = {
|
| 51 |
mode: 'pcm', needsDownload: false, networked: true,
|
|
|
|
| 61 |
label: 'Qwen3-TTS · Voice Design (cloud)',
|
| 62 |
available: () => true,
|
| 63 |
synth: (text, voiceId) => postSynth(ttsBase(), text, voiceId),
|
| 64 |
+
synthWav: (text, voiceId) => postSynthWav(ttsBase(), text, voiceId),
|
| 65 |
backendLabel: () => { const b = ttsBase(); try { return b ? '🖥 ' + new URL(b).host : '☁ DashScope' } catch { return '☁ DashScope' } },
|
| 66 |
}
|
| 67 |
|