polats Claude Opus 4.8 (1M context) commited on
Commit
ea860c1
·
1 Parent(s): 72160ec

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>

Files changed (2) hide show
  1. web/personaPanel.js +41 -43
  2. 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; 🎙 create sits on the Voice design heading.
41
- const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice', style: 'display:none' }, '▶')
42
- const createBtn = el('button', { class: 'persona-ico persona-create', type: 'button', title: 'Create voice', style: 'display:none' }, '🎙')
 
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', createBtn), voiceEl,
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
- playBtn.style.display = hasVoice ? '' : 'none'
78
- playBtn.classList.toggle('badged', isDirty())
79
- createBtn.style.display = lastPersona ? '' : 'none'
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
- const v = elm.textContent.trim()
97
- if ((lastPersona[field] || '') === v) return
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
- // 🎙 Create / Recreate voice DESIGN a fresh voice from the description and cache it.
126
- async function createVoice() {
 
 
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
- const line = lineFor(lastPersona)
131
- working = true; createBtn.classList.add('busy'); createBtn.disabled = true
132
- const prev = status.textContent; status.textContent = 'designing the voice…'
 
133
  try {
134
- const wav = await createVoiceWav(lastPersona.voice, line)
135
- await putAudio(savedId, new Blob([wav], { type: 'audio/wav' }))
 
 
 
 
 
 
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
- createBtn.style.display = 'none'; playBtn.style.display = 'none'; lastPersona = null; savedId = null; hasVoice = false
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 (line + red heading, action on the right) ─────────────── */
66
- .persona-sec {
67
- display: flex; align-items: center; justify-content: space-between; gap: 10px;
68
- margin-top: 20px; padding-top: 9px; border-top: 1px solid var(--p-ink);
69
- }
70
  .persona-sec-title {
71
- font-family: var(--p-mono); font-size: 11px; font-weight: 500; letter-spacing: .2em;
 
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 buttons on the section headers. */
87
  .persona-ico {
88
  position: relative; cursor: pointer; flex-shrink: 0; line-height: 1;
89
- font-size: 13px !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: 5px 10px !important;
91
  }
92
  .persona-ico:hover { background: var(--p-paper-2) !important; }
93
  .persona-ico.busy { opacity: .55; cursor: default; }
94
- .persona-play.badged::after { /* "voice changed — tap to refresh" badge */
 
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. */