polats Claude Opus 4.8 (1M context) commited on
Commit
308478f
·
1 Parent(s): 0ecdf98

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>

Files changed (5) hide show
  1. web/personaPanel.js +96 -68
  2. web/personaStore.js +31 -0
  3. web/shell/persona.css +26 -10
  4. web/tts.js +12 -1
  5. 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) on the chosen engine, lets you
3
- // HEAR their voice say their quote, and SAVE them to a local-first barracks roster
4
- // (personaStore) so they persist for returning visitors. Modeled on woid's agent store.
 
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 { previewVoice, stopPreview } from '/web/tts.js'
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' }, 'Your soldier')
35
  const tagsEl = el('div', { class: 'persona-tags' })
36
- const quoteEl = el('blockquote', { class: 'persona-quote' })
37
- const aboutEl = el('div', { class: 'persona-about' }, 'Pick a class and recruit — a small model writes their legend, designs their voice, and gives them a battle-cry.')
38
- const voiceEl = el('div', { class: 'persona-voice-desc' })
39
- const hearBtn = el('button', { class: 'persona-go persona-go-alt persona-hear', type: 'button', style: 'display:none' }, '🔊 Hear voice')
40
- const saveBtn = el('button', { class: 'persona-go persona-save', type: 'button', style: 'display:none' }, '💾 Save to barracks')
 
 
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' }, [nameEl, tagsEl, quoteEl, aboutEl, voiceEl, el('div', { class: 'persona-actions' }, [hearBtn, saveBtn]), thinkWrap])
 
53
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
54
 
55
- let lastPersona = null // the persona currently shown (generated or loaded)
56
- let savedId = null // its roster id once saved
57
- let hearing = false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // Show a persona (from generation or the roster) in the result pane.
65
- function showPersona(p, opts = {}) {
66
- lastPersona = p
67
  savedId = opts.savedId || null
68
- nameEl.textContent = p.name || 'Soldier'
69
  setTags(p)
70
- quoteEl.textContent = p.quote ? `“${p.quote}”` : ''
71
  aboutEl.textContent = p.about || ''
72
- voiceEl.textContent = p.voice ? `🎙 ${p.voice}` : ''
73
- hearBtn.style.display = p.voice ? '' : 'none'
74
- saveBtn.style.display = ''
75
- saveBtn.textContent = savedId ? '✓ saved' : '💾 Save to barracks'
76
- saveBtn.disabled = !!savedId
 
 
77
  }
78
 
79
- // 🔊 Hear voice — design the voice from `voice` and speak the QUOTE (falls back to a
80
- // line built from name/about if there's no quote). Networked (Qwen3-TTS).
81
- async function hear() {
82
- if (hearing) { stopPreview(); return }
83
- if (!lastPersona || !lastPersona.voice) return
84
- const line = lastPersona.quote || lastPersona.about || `${lastPersona.name} reporting for duty.`
85
- hearing = true; hearBtn.textContent = ' Stop'
86
  const prev = status.textContent
87
- status.textContent = 'designing the voice…'
88
- try { await previewVoice(lastPersona.voice, line); status.textContent = prev }
89
- catch (e) { status.textContent = `voice failed: ${e.message || e}` }
90
- finally { hearing = false; hearBtn.textContent = '🔊 Hear voice' }
 
 
 
 
 
 
 
91
  }
92
- hearBtn.addEventListener('click', hear)
93
 
94
- saveBtn.addEventListener('click', () => {
95
- if (!lastPersona || savedId) return
96
- const rec = savePersona({ ...lastPersona, unitClass: sel.value, seed: seed.value })
97
- savedId = rec.id
98
- saveBtn.textContent = '✓ saved'; saveBtn.disabled = true
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
- acc || '(empty)',
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
- hearBtn.style.display = 'none'; saveBtn.style.display = 'none'; lastPersona = null; savedId = null
152
- if (hearing) stopPreview()
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
- status.textContent = 'enlisted 💾 Save to keep'
175
- lastDebug = buildDebug('parsed OK', acc)
176
- thinkWrap.open = false
 
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
- thinkWrap.open = true
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: 11px; line-height: 1.5; color: var(--p-muted);
67
- max-width: 60ch; margin-top: 14px; font-style: italic;
68
  }
69
- .persona-voice-desc:empty { display: none; }
70
  .persona-quote {
71
- margin: 14px 0 0; padding: 8px 0 8px 16px; border-left: 3px solid var(--p-transmit);
72
- font-family: 'Fraunces', Georgia, serif; font-size: 20px; font-style: italic;
73
- line-height: 1.35; color: var(--p-ink); max-width: 56ch;
74
- }
75
- .persona-quote:empty { display: none; }
76
- .persona-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
77
- .persona-hear, .persona-save { display: inline-block; margin-top: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
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 → samples. base '' = same-origin.
39
- async function postSynth(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 decodeAudio(await resp.arrayBuffer())
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