polats Claude Opus 4.8 (1M context) commited on
Commit
ba238a8
·
1 Parent(s): 26cb5c0

Persona voice: play↔stop toggle, loading spinner, auto-stop on nav/new pick

Browse files

- ▶ becomes ⏹ while a voice is sounding; clicking again stops it.
- While a voice is being generated the button shows a spinning loading ring
(CSS .busy::before) — the spinner clears the instant the WAV is ready, then
the button flips to ⏹ for playback.
- Voice auto-stops when: navigating away from the Personas tab (Intersection
Observer on the hidden stage), picking another hero from the barracks, and
starting a new generation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. web/personaPanel.js +34 -4
  2. web/shell/persona.css +9 -1
web/personaPanel.js CHANGED
@@ -173,6 +173,24 @@ export function mountPersonaPanel(host) {
173
  let hasVoice = false // a cached voice file exists for this persona
174
  let working = false
175
  let busy = false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
  // Hide the hero fields until a hero is generated or picked from the barracks.
178
  function refreshVisibility() {
@@ -190,6 +208,7 @@ export function mountPersonaPanel(host) {
190
  function updateVoiceUI() {
191
  const needs = !!lastPersona && (!hasVoice || isDirty())
192
  playBtn.classList.toggle('badged', needs)
 
193
  playBtn.title = !lastPersona ? 'Play voice' : (!hasVoice ? 'Create voice' : (isDirty() ? 'Update & play' : 'Play voice'))
194
  }
195
 
@@ -225,6 +244,7 @@ export function mountPersonaPanel(host) {
225
  }
226
 
227
  async function showPersona(p, opts = {}) {
 
228
  lastPersona = { ...p }
229
  savedId = opts.savedId || null
230
  nameEl.textContent = p.name || ''
@@ -240,23 +260,25 @@ export function mountPersonaPanel(host) {
240
  // no voice yet → DESIGN one from the description. If the quote/voice changed (badge) →
241
  // CLONE the last voice (same timbre, new words). Then cache + save over + clear badge.
242
  async function play() {
 
243
  if (working || !lastPersona) return
244
  const line = lineFor(lastPersona)
245
 
246
  // Up-to-date voice exists → just replay the cached file.
247
  if (hasVoice && !isDirty()) {
248
  const blob = savedId ? await getAudio(savedId) : null
249
- if (blob) { try { await playWav(await blob.arrayBuffer()) } catch { /* ignore */ } return }
250
  hasVoice = false // cache vanished — fall through to re-make it
251
  }
252
  if (!lastPersona.voice) { status.textContent = 'add a voice design first'; return }
253
  autosave() // ensure an id to key the audio
254
 
 
255
  working = true; playBtn.classList.add('busy'); playBtn.disabled = true
256
  const prev = status.textContent
257
  status.textContent = hasVoice ? 'updating the voice…' : 'designing the voice…'
 
258
  try {
259
- let wav
260
  if (hasVoice) {
261
  const blob = await getAudio(savedId)
262
  wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
@@ -266,13 +288,21 @@ export function mountPersonaPanel(host) {
266
  await putAudio(savedId, new Blob([wav], { type: 'audio/wav' })) // save over
267
  lastPersona.voiceQuote = line; lastPersona.voiceDesignUsed = lastPersona.voice
268
  hasVoice = true; autosave()
269
- try { await playWav(wav.slice(0)) } catch { /* autoplay blocked — ▶ still works */ }
270
  status.textContent = prev
271
  } catch (e) { status.textContent = `voice failed: ${e.message || e}` }
272
  finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
 
273
  }
274
  playBtn.addEventListener('click', play)
275
 
 
 
 
 
 
 
 
 
276
  // ── Barracks roster (saved heroes) ──────────────────────────────────────
277
  function renderRoster(personas) {
278
  if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No heroes saved yet.')); return }
@@ -319,7 +349,7 @@ export function mountPersonaPanel(host) {
319
  nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
320
  quoteEl.textContent = ''; voiceEl.textContent = ''
321
  lastPersona = null; savedId = null; hasVoice = false
322
- stopPreview(); updateVoiceUI()
323
  thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
324
  let acc = ''
325
  try {
 
173
  let hasVoice = false // a cached voice file exists for this persona
174
  let working = false
175
  let busy = false
176
+ let playing = false // audio is currently sounding (▶ becomes ⏹)
177
+
178
+ // ▶ ⇄ ⏹: reflect playback state on the button so a second click stops it.
179
+ function setPlaying(on) {
180
+ playing = on
181
+ playBtn.classList.toggle('playing', on)
182
+ playBtn.textContent = on ? '⏹' : '▶'
183
+ if (on) playBtn.title = 'Stop'
184
+ else updateVoiceUI()
185
+ }
186
+ // Stop any sounding voice and reset the button (used on toggle, nav-away, new pick).
187
+ function stopVoice() { stopPreview(); setPlaying(false) }
188
+ // Play a WAV buffer with the button reflecting play→stop→idle, even if it's cut short.
189
+ async function playBuf(arrayBuffer) {
190
+ setPlaying(true)
191
+ try { await playWav(arrayBuffer) } catch { /* autoplay blocked / cut short */ }
192
+ finally { setPlaying(false) }
193
+ }
194
 
195
  // Hide the hero fields until a hero is generated or picked from the barracks.
196
  function refreshVisibility() {
 
208
  function updateVoiceUI() {
209
  const needs = !!lastPersona && (!hasVoice || isDirty())
210
  playBtn.classList.toggle('badged', needs)
211
+ if (playing) return // 'Stop' title owned by setPlaying while sounding
212
  playBtn.title = !lastPersona ? 'Play voice' : (!hasVoice ? 'Create voice' : (isDirty() ? 'Update & play' : 'Play voice'))
213
  }
214
 
 
244
  }
245
 
246
  async function showPersona(p, opts = {}) {
247
+ stopVoice() // picking another hero cuts the current voice
248
  lastPersona = { ...p }
249
  savedId = opts.savedId || null
250
  nameEl.textContent = p.name || ''
 
260
  // no voice yet → DESIGN one from the description. If the quote/voice changed (badge) →
261
  // CLONE the last voice (same timbre, new words). Then cache + save over + clear badge.
262
  async function play() {
263
+ if (playing) { stopVoice(); return } // second click while sounding → stop
264
  if (working || !lastPersona) return
265
  const line = lineFor(lastPersona)
266
 
267
  // Up-to-date voice exists → just replay the cached file.
268
  if (hasVoice && !isDirty()) {
269
  const blob = savedId ? await getAudio(savedId) : null
270
+ if (blob) { await playBuf(await blob.arrayBuffer()); return }
271
  hasVoice = false // cache vanished — fall through to re-make it
272
  }
273
  if (!lastPersona.voice) { status.textContent = 'add a voice design first'; return }
274
  autosave() // ensure an id to key the audio
275
 
276
+ // Generating → the ▶ becomes a loading spinner (.busy) until the WAV is ready.
277
  working = true; playBtn.classList.add('busy'); playBtn.disabled = true
278
  const prev = status.textContent
279
  status.textContent = hasVoice ? 'updating the voice…' : 'designing the voice…'
280
+ let wav = null
281
  try {
 
282
  if (hasVoice) {
283
  const blob = await getAudio(savedId)
284
  wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
 
288
  await putAudio(savedId, new Blob([wav], { type: 'audio/wav' })) // save over
289
  lastPersona.voiceQuote = line; lastPersona.voiceDesignUsed = lastPersona.voice
290
  hasVoice = true; autosave()
 
291
  status.textContent = prev
292
  } catch (e) { status.textContent = `voice failed: ${e.message || e}` }
293
  finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
294
+ if (wav) await playBuf(wav.slice(0)) // spinner cleared → now toggles to ⏹ while sounding
295
  }
296
  playBtn.addEventListener('click', play)
297
 
298
+ // Navigating to another tab hides this stage (Gradio sets display:none) → the host
299
+ // stops intersecting; cut the voice so it doesn't keep playing off-screen.
300
+ try {
301
+ new IntersectionObserver((entries) => {
302
+ for (const e of entries) if (!e.isIntersecting && playing) stopVoice()
303
+ }).observe(host)
304
+ } catch { /* no IntersectionObserver — playback just won't auto-stop on nav */ }
305
+
306
  // ── Barracks roster (saved heroes) ──────────────────────────────────────
307
  function renderRoster(personas) {
308
  if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No heroes saved yet.')); return }
 
349
  nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
350
  quoteEl.textContent = ''; voiceEl.textContent = ''
351
  lastPersona = null; savedId = null; hasVoice = false
352
+ stopVoice(); updateVoiceUI()
353
  thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
354
  let acc = ''
355
  try {
web/shell/persona.css CHANGED
@@ -119,7 +119,15 @@
119
  border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important; padding: 3px 9px !important;
120
  }
121
  .persona-ico:hover { background: var(--p-paper-2) !important; }
122
- .persona-ico.busy { opacity: .55; cursor: default; }
 
 
 
 
 
 
 
 
123
  /* Badge = "no voice yet, or the quote/voice changed — tap to (re)make it." Pulses. */
124
  .persona-play.badged::after {
125
  content: ''; position: absolute; top: -4px; right: -4px; width: 9px; height: 9px;
 
119
  border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important; padding: 3px 9px !important;
120
  }
121
  .persona-ico:hover { background: var(--p-paper-2) !important; }
122
+ .persona-ico.busy { cursor: default; }
123
+ /* Generating a voice → hide the glyph and spin a small ring in its place. */
124
+ .persona-play.busy { color: transparent !important; }
125
+ .persona-play.busy::before {
126
+ content: ''; position: absolute; inset: 0; margin: auto; width: 11px; height: 11px;
127
+ border: 2px solid var(--p-paper-2); border-top-color: var(--p-transmit); border-radius: 50%;
128
+ animation: tac-spin .7s linear infinite;
129
+ }
130
+ @keyframes tac-spin { to { transform: rotate(360deg); } }
131
  /* Badge = "no voice yet, or the quote/voice changed — tap to (re)make it." Pulses. */
132
  .persona-play.badged::after {
133
  content: ''; position: absolute; top: -4px; right: -4px; width: 9px; height: 9px;