Spaces:
Running
Running
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>
- web/personaPanel.js +34 -4
- 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) {
|
| 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 |
-
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|