Spaces:
Running
Running
Persona voice: one pulsing play button, sidebar-style headings, live badge
Browse files- One ▶ play button (create button removed). Always shown on the Quote heading; it
designs the voice when there's none, clones the last voice when the quote/voice was
edited (same timbre), or replays the cached file when current.
- Pulsing red badge on ▶ whenever there's no voice yet OR the quote/voice changed —
updates LIVE as you type (input listener), not just on blur.
- Section headings restyled like the sidebar: a short ink line before a small red
uppercase heading; the action button is anchored right after the heading (not at the
page edge). Verified: badge pulses on a no-voice persona; headings + button placement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/personaPanel.js +41 -43
- web/shell/persona.css +17 -10
web/personaPanel.js
CHANGED
|
@@ -37,9 +37,10 @@ export function mountPersonaPanel(host) {
|
|
| 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 voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
|
| 40 |
-
// ▶ play sits on the Quote heading
|
| 41 |
-
|
| 42 |
-
|
|
|
|
| 43 |
const thinkEl = el('pre', { class: 'persona-think' })
|
| 44 |
const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
|
| 45 |
const thinkWrap = el('details', { class: 'persona-think-wrap' },
|
|
@@ -59,7 +60,7 @@ export function mountPersonaPanel(host) {
|
|
| 59 |
nameEl, tagsEl,
|
| 60 |
secHead('About'), aboutEl,
|
| 61 |
secHead('Quote', playBtn), quoteEl,
|
| 62 |
-
secHead('Voice design'
|
| 63 |
thinkWrap,
|
| 64 |
])
|
| 65 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
|
@@ -73,11 +74,11 @@ export function mountPersonaPanel(host) {
|
|
| 73 |
const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A soldier'} reporting for duty.`
|
| 74 |
// Cached audio is stale if the line or the voice design changed since it was made.
|
| 75 |
const isDirty = () => hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || ''))
|
|
|
|
| 76 |
function updateVoiceUI() {
|
| 77 |
-
|
| 78 |
-
playBtn.classList.toggle('badged',
|
| 79 |
-
|
| 80 |
-
createBtn.title = hasVoice ? 'Recreate voice' : 'Create voice'
|
| 81 |
}
|
| 82 |
|
| 83 |
function autosave() {
|
|
@@ -91,13 +92,14 @@ export function mountPersonaPanel(host) {
|
|
| 91 |
elm.contentEditable = 'true'
|
| 92 |
elm.spellcheck = false
|
| 93 |
if (single) elm.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); elm.blur() } })
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
elm.addEventListener('blur', () => {
|
| 95 |
if (!lastPersona) return
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
lastPersona[field] = v
|
| 99 |
-
autosave()
|
| 100 |
-
if (field === 'quote' || field === 'voice') updateVoiceUI() // may go stale → badge
|
| 101 |
})
|
| 102 |
}
|
| 103 |
editable(nameEl, 'name', { single: true })
|
|
@@ -122,43 +124,39 @@ export function mountPersonaPanel(host) {
|
|
| 122 |
updateVoiceUI()
|
| 123 |
}
|
| 124 |
|
| 125 |
-
//
|
| 126 |
-
|
|
|
|
|
|
|
| 127 |
if (working || !lastPersona) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
if (!lastPersona.voice) { status.textContent = 'add a voice design first'; return }
|
| 129 |
autosave() // ensure an id to key the audio
|
| 130 |
-
|
| 131 |
-
working = true;
|
| 132 |
-
const prev = status.textContent
|
|
|
|
| 133 |
try {
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
lastPersona.voiceQuote = line; lastPersona.voiceDesignUsed = lastPersona.voice
|
| 137 |
hasVoice = true; autosave()
|
| 138 |
try { await playWav(wav.slice(0)) } catch { /* autoplay blocked — ▶ still works */ }
|
| 139 |
status.textContent = prev
|
| 140 |
} catch (e) { status.textContent = `voice failed: ${e.message || e}` }
|
| 141 |
-
finally { working = false; createBtn.classList.remove('busy'); createBtn.disabled = false; updateVoiceUI() }
|
| 142 |
-
}
|
| 143 |
-
createBtn.addEventListener('click', createVoice)
|
| 144 |
-
|
| 145 |
-
// ▶ Play — plays the cached file. If the quote/voice changed since (badge), re-render
|
| 146 |
-
// the new line by CLONING the last voice (keeps the same timbre), then save over it.
|
| 147 |
-
async function play() {
|
| 148 |
-
if (working || !hasVoice || !savedId) return
|
| 149 |
-
const blob = await getAudio(savedId)
|
| 150 |
-
if (!blob) { hasVoice = false; updateVoiceUI(); return }
|
| 151 |
-
if (!isDirty()) { try { await playWav(await blob.arrayBuffer()) } catch { /* ignore */ } return }
|
| 152 |
-
working = true; playBtn.classList.add('busy'); playBtn.disabled = true
|
| 153 |
-
const prev = status.textContent; status.textContent = 'updating the voice…'
|
| 154 |
-
try {
|
| 155 |
-
const line = lineFor(lastPersona)
|
| 156 |
-
const newWav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
|
| 157 |
-
try { await playWav(newWav.slice(0)) } catch { /* ignore */ }
|
| 158 |
-
await putAudio(savedId, new Blob([newWav], { type: 'audio/wav' })) // save over
|
| 159 |
-
lastPersona.voiceQuote = line; lastPersona.voiceDesignUsed = lastPersona.voice
|
| 160 |
-
autosave(); status.textContent = prev
|
| 161 |
-
} catch (e) { status.textContent = `voice update failed: ${e.message || e}` }
|
| 162 |
finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
|
| 163 |
}
|
| 164 |
playBtn.addEventListener('click', play)
|
|
@@ -209,8 +207,8 @@ export function mountPersonaPanel(host) {
|
|
| 209 |
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 210 |
nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
|
| 211 |
quoteEl.textContent = ''; voiceEl.textContent = ''
|
| 212 |
-
|
| 213 |
-
stopPreview()
|
| 214 |
thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
|
| 215 |
let acc = ''
|
| 216 |
try {
|
|
|
|
| 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 voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
|
| 40 |
+
// ▶ play sits on the Quote heading and does everything: it (re)creates the voice when
|
| 41 |
+
// needed and replays it otherwise. A pulsing badge shows when there's no voice yet, or
|
| 42 |
+
// the quote/voice was edited since the last one was made.
|
| 43 |
+
const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play 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' },
|
|
|
|
| 60 |
nameEl, tagsEl,
|
| 61 |
secHead('About'), aboutEl,
|
| 62 |
secHead('Quote', playBtn), quoteEl,
|
| 63 |
+
secHead('Voice design'), voiceEl,
|
| 64 |
thinkWrap,
|
| 65 |
])
|
| 66 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
|
|
|
| 74 |
const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A soldier'} reporting for duty.`
|
| 75 |
// Cached audio is stale if the line or the voice design changed since it was made.
|
| 76 |
const isDirty = () => hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || ''))
|
| 77 |
+
// Badge when there's a persona but no current voice (none yet, or it went stale).
|
| 78 |
function updateVoiceUI() {
|
| 79 |
+
const needs = !!lastPersona && (!hasVoice || isDirty())
|
| 80 |
+
playBtn.classList.toggle('badged', needs)
|
| 81 |
+
playBtn.title = !lastPersona ? 'Play voice' : (!hasVoice ? 'Create voice' : (isDirty() ? 'Update & play' : 'Play voice'))
|
|
|
|
| 82 |
}
|
| 83 |
|
| 84 |
function autosave() {
|
|
|
|
| 92 |
elm.contentEditable = 'true'
|
| 93 |
elm.spellcheck = false
|
| 94 |
if (single) elm.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); elm.blur() } })
|
| 95 |
+
// Update the badge immediately as you type; persist on blur.
|
| 96 |
+
if (field === 'quote' || field === 'voice') {
|
| 97 |
+
elm.addEventListener('input', () => { if (lastPersona) { lastPersona[field] = elm.textContent.trim(); updateVoiceUI() } })
|
| 98 |
+
}
|
| 99 |
elm.addEventListener('blur', () => {
|
| 100 |
if (!lastPersona) return
|
| 101 |
+
lastPersona[field] = elm.textContent.trim()
|
| 102 |
+
autosave(); updateVoiceUI()
|
|
|
|
|
|
|
|
|
|
| 103 |
})
|
| 104 |
}
|
| 105 |
editable(nameEl, 'name', { single: true })
|
|
|
|
| 124 |
updateVoiceUI()
|
| 125 |
}
|
| 126 |
|
| 127 |
+
// ▶ The one voice button: if the cached voice is current, just replay it. If there's
|
| 128 |
+
// no voice yet → DESIGN one from the description. If the quote/voice changed (badge) →
|
| 129 |
+
// CLONE the last voice (same timbre, new words). Then cache + save over + clear badge.
|
| 130 |
+
async function play() {
|
| 131 |
if (working || !lastPersona) return
|
| 132 |
+
const line = lineFor(lastPersona)
|
| 133 |
+
|
| 134 |
+
// Up-to-date voice exists → just replay the cached file.
|
| 135 |
+
if (hasVoice && !isDirty()) {
|
| 136 |
+
const blob = savedId ? await getAudio(savedId) : null
|
| 137 |
+
if (blob) { try { await playWav(await blob.arrayBuffer()) } catch { /* ignore */ } return }
|
| 138 |
+
hasVoice = false // cache vanished — fall through to re-make it
|
| 139 |
+
}
|
| 140 |
if (!lastPersona.voice) { status.textContent = 'add a voice design first'; return }
|
| 141 |
autosave() // ensure an id to key the audio
|
| 142 |
+
|
| 143 |
+
working = true; playBtn.classList.add('busy'); playBtn.disabled = true
|
| 144 |
+
const prev = status.textContent
|
| 145 |
+
status.textContent = hasVoice ? 'updating the voice…' : 'designing the voice…'
|
| 146 |
try {
|
| 147 |
+
let wav
|
| 148 |
+
if (hasVoice) {
|
| 149 |
+
const blob = await getAudio(savedId)
|
| 150 |
+
wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
|
| 151 |
+
} else {
|
| 152 |
+
wav = await createVoiceWav(lastPersona.voice, line)
|
| 153 |
+
}
|
| 154 |
+
await putAudio(savedId, new Blob([wav], { type: 'audio/wav' })) // save over
|
| 155 |
lastPersona.voiceQuote = line; lastPersona.voiceDesignUsed = lastPersona.voice
|
| 156 |
hasVoice = true; autosave()
|
| 157 |
try { await playWav(wav.slice(0)) } catch { /* autoplay blocked — ▶ still works */ }
|
| 158 |
status.textContent = prev
|
| 159 |
} catch (e) { status.textContent = `voice failed: ${e.message || e}` }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
|
| 161 |
}
|
| 162 |
playBtn.addEventListener('click', play)
|
|
|
|
| 207 |
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 208 |
nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
|
| 209 |
quoteEl.textContent = ''; voiceEl.textContent = ''
|
| 210 |
+
lastPersona = null; savedId = null; hasVoice = false
|
| 211 |
+
stopPreview(); updateVoiceUI()
|
| 212 |
thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
|
| 213 |
let acc = ''
|
| 214 |
try {
|
web/shell/persona.css
CHANGED
|
@@ -62,15 +62,15 @@
|
|
| 62 |
font-size: 17px; line-height: 1.6; max-width: 60ch; color: var(--p-ink);
|
| 63 |
white-space: pre-wrap;
|
| 64 |
}
|
| 65 |
-
/* ── Section headers
|
| 66 |
-
.
|
| 67 |
-
|
| 68 |
-
margin-top: 20px; padding-top: 9px; border-top: 1px solid var(--p-ink);
|
| 69 |
-
}
|
| 70 |
.persona-sec-title {
|
| 71 |
-
|
|
|
|
| 72 |
text-transform: uppercase; color: var(--p-transmit);
|
| 73 |
}
|
|
|
|
| 74 |
.persona-voice-desc {
|
| 75 |
font-family: var(--p-mono); font-size: 12px; line-height: 1.5; color: var(--p-muted);
|
| 76 |
max-width: 60ch; margin-top: 8px; font-style: italic;
|
|
@@ -83,17 +83,24 @@
|
|
| 83 |
.persona-quote:not(:empty)::before { content: '“'; }
|
| 84 |
.persona-quote:not(:empty)::after { content: '”'; }
|
| 85 |
|
| 86 |
-
/* Simple icon
|
| 87 |
.persona-ico {
|
| 88 |
position: relative; cursor: pointer; flex-shrink: 0; line-height: 1;
|
| 89 |
-
font-size:
|
| 90 |
-
border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important; padding:
|
| 91 |
}
|
| 92 |
.persona-ico:hover { background: var(--p-paper-2) !important; }
|
| 93 |
.persona-ico.busy { opacity: .55; cursor: default; }
|
| 94 |
-
|
|
|
|
| 95 |
content: ''; position: absolute; top: -4px; right: -4px; width: 9px; height: 9px;
|
| 96 |
background: var(--p-transmit); border: 1.5px solid var(--p-card); border-radius: 50%;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
/* Click-to-edit fields (name / about / quote / voice) — auto-saved on blur. */
|
|
|
|
| 62 |
font-size: 17px; line-height: 1.6; max-width: 60ch; color: var(--p-ink);
|
| 63 |
white-space: pre-wrap;
|
| 64 |
}
|
| 65 |
+
/* ── Section headers — like the sidebar: a short ink line, red heading, with the
|
| 66 |
+
action button anchored right after the title (not at the page edge). ───────── */
|
| 67 |
+
.persona-sec { display: flex; align-items: center; gap: 10px; margin-top: 22px; }
|
|
|
|
|
|
|
| 68 |
.persona-sec-title {
|
| 69 |
+
display: flex; align-items: center; gap: 8px;
|
| 70 |
+
font-family: var(--p-mono); font-size: 10px; font-weight: 500; letter-spacing: .2em;
|
| 71 |
text-transform: uppercase; color: var(--p-transmit);
|
| 72 |
}
|
| 73 |
+
.persona-sec-title::before { content: ''; height: 2px; width: 18px; background: var(--p-ink); flex-shrink: 0; }
|
| 74 |
.persona-voice-desc {
|
| 75 |
font-family: var(--p-mono); font-size: 12px; line-height: 1.5; color: var(--p-muted);
|
| 76 |
max-width: 60ch; margin-top: 8px; font-style: italic;
|
|
|
|
| 83 |
.persona-quote:not(:empty)::before { content: '“'; }
|
| 84 |
.persona-quote:not(:empty)::after { content: '”'; }
|
| 85 |
|
| 86 |
+
/* Simple icon button anchored after a section heading. */
|
| 87 |
.persona-ico {
|
| 88 |
position: relative; cursor: pointer; flex-shrink: 0; line-height: 1;
|
| 89 |
+
font-size: 12px !important; color: var(--p-ink) !important; background: var(--p-card) !important;
|
| 90 |
+
border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important; padding: 3px 9px !important;
|
| 91 |
}
|
| 92 |
.persona-ico:hover { background: var(--p-paper-2) !important; }
|
| 93 |
.persona-ico.busy { opacity: .55; cursor: default; }
|
| 94 |
+
/* Badge = "no voice yet, or the quote/voice changed — tap to (re)make it." Pulses. */
|
| 95 |
+
.persona-play.badged::after {
|
| 96 |
content: ''; position: absolute; top: -4px; right: -4px; width: 9px; height: 9px;
|
| 97 |
background: var(--p-transmit); border: 1.5px solid var(--p-card); border-radius: 50%;
|
| 98 |
+
animation: tac-badge-pulse 1.3s ease-out infinite;
|
| 99 |
+
}
|
| 100 |
+
@keyframes tac-badge-pulse {
|
| 101 |
+
0% { box-shadow: 0 0 0 0 rgba(216, 39, 26, .6); }
|
| 102 |
+
70% { box-shadow: 0 0 0 7px rgba(216, 39, 26, 0); }
|
| 103 |
+
100% { box-shadow: 0 0 0 0 rgba(216, 39, 26, 0); }
|
| 104 |
}
|
| 105 |
|
| 106 |
/* Click-to-edit fields (name / about / quote / voice) — auto-saved on blur. */
|