Spaces:
Running
Running
Persona voice: re-DESIGN when the voice description changes (not clone)
Browse filesEditing the voice design now regenerates a fresh timbre instead of cloning the
old voice. play() cloned whenever a cached voice existed, so a changed design
just re-spoke the old voice. Now it clones only when the timbre should be kept
(voice unchanged, new words) and designs otherwise (new/edited description).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/personaPanel.js +23 -12
- web/shell/persona.css +1 -1
web/personaPanel.js
CHANGED
|
@@ -26,7 +26,8 @@ const CLASS_SLUG = {
|
|
| 26 |
Cleric: 'true-heroes-ii-cleric',
|
| 27 |
Knight: 'rts-humans-knight',
|
| 28 |
}
|
| 29 |
-
const ICON_PX =
|
|
|
|
| 30 |
const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/') // sheets serve at /sprites
|
| 31 |
|
| 32 |
function el(tag, props = {}, kids = []) {
|
|
@@ -40,9 +41,11 @@ function el(tag, props = {}, kids = []) {
|
|
| 40 |
return n
|
| 41 |
}
|
| 42 |
|
| 43 |
-
// Idle sheets are 4 rows (facings) × N frame columns; cell = height/4.
|
| 44 |
-
// front-right
|
| 45 |
-
//
|
|
|
|
|
|
|
| 46 |
function animateIdleIcon(box, idleUrl) {
|
| 47 |
box.getAnimations?.().forEach((a) => a.cancel())
|
| 48 |
box.style.backgroundImage = ''
|
|
@@ -51,11 +54,13 @@ function animateIdleIcon(box, idleUrl) {
|
|
| 51 |
const cell = (img.naturalHeight / 4) || img.naturalHeight
|
| 52 |
const cols = Math.max(1, Math.round(img.naturalWidth / cell))
|
| 53 |
const rows = Math.max(1, Math.round(img.naturalHeight / cell))
|
|
|
|
|
|
|
| 54 |
box.style.backgroundImage = `url("${idleUrl}")`
|
| 55 |
-
box.style.backgroundSize = `${cols *
|
| 56 |
-
|
| 57 |
box.animate(
|
| 58 |
-
[{ backgroundPosition:
|
| 59 |
{ duration: cols * 110, iterations: Infinity, easing: `steps(${cols})` },
|
| 60 |
)
|
| 61 |
}
|
|
@@ -204,6 +209,9 @@ export function mountPersonaPanel(host) {
|
|
| 204 |
const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A hero'} reporting for duty.`
|
| 205 |
// Cached audio is stale if the line or the voice design changed since it was made.
|
| 206 |
const isDirty = () => hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || ''))
|
|
|
|
|
|
|
|
|
|
| 207 |
// Badge when there's a persona but no current voice (none yet, or it went stale).
|
| 208 |
function updateVoiceUI() {
|
| 209 |
const needs = !!lastPersona && (!hasVoice || isDirty())
|
|
@@ -256,9 +264,9 @@ export function mountPersonaPanel(host) {
|
|
| 256 |
updateVoiceUI(); refreshVisibility()
|
| 257 |
}
|
| 258 |
|
| 259 |
-
// ▶ The one voice button: if the cached voice is current, just replay it. If
|
| 260 |
-
// no voice yet → DESIGN
|
| 261 |
-
// CLONE the last voice (same timbre, new words).
|
| 262 |
async function play() {
|
| 263 |
if (playing) { stopVoice(); return } // second click while sounding → stop
|
| 264 |
if (working || !lastPersona) return
|
|
@@ -273,13 +281,16 @@ export function mountPersonaPanel(host) {
|
|
| 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 =
|
| 280 |
let wav = null
|
| 281 |
try {
|
| 282 |
-
if (
|
| 283 |
const blob = await getAudio(savedId)
|
| 284 |
wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
|
| 285 |
} else {
|
|
|
|
| 26 |
Cleric: 'true-heroes-ii-cleric',
|
| 27 |
Knight: 'rts-humans-knight',
|
| 28 |
}
|
| 29 |
+
const ICON_PX = 30 // on-screen size of the class icon box
|
| 30 |
+
const ICON_ZOOM = 2.4 // sheets pad the character inside each cell — zoom in so it fills the box
|
| 31 |
const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/') // sheets serve at /sprites
|
| 32 |
|
| 33 |
function el(tag, props = {}, kids = []) {
|
|
|
|
| 41 |
return n
|
| 42 |
}
|
| 43 |
|
| 44 |
+
// Idle sheets are 4 rows (facings) × N frame columns; cell = height/4. We render
|
| 45 |
+
// the front-right row (row 0) and step across the columns to loop the idle — no
|
| 46 |
+
// canvas, just a sized background + the Web Animations API. Each cell is scaled to
|
| 47 |
+
// ICON_ZOOM × the box and the box is centred on it, so the padded-in character
|
| 48 |
+
// fills the icon instead of floating tiny in a sea of transparent cell.
|
| 49 |
function animateIdleIcon(box, idleUrl) {
|
| 50 |
box.getAnimations?.().forEach((a) => a.cancel())
|
| 51 |
box.style.backgroundImage = ''
|
|
|
|
| 54 |
const cell = (img.naturalHeight / 4) || img.naturalHeight
|
| 55 |
const cols = Math.max(1, Math.round(img.naturalWidth / cell))
|
| 56 |
const rows = Math.max(1, Math.round(img.naturalHeight / cell))
|
| 57 |
+
const cellPx = ICON_PX * ICON_ZOOM // zoomed on-screen cell size
|
| 58 |
+
const off = (cellPx - ICON_PX) / 2 // inset to centre the box on a cell
|
| 59 |
box.style.backgroundImage = `url("${idleUrl}")`
|
| 60 |
+
box.style.backgroundSize = `${cols * cellPx}px ${rows * cellPx}px`
|
| 61 |
+
const y = `-${off}px` // row 0, vertically centred
|
| 62 |
box.animate(
|
| 63 |
+
[{ backgroundPosition: `-${off}px ${y}` }, { backgroundPosition: `-${cols * cellPx + off}px ${y}` }],
|
| 64 |
{ duration: cols * 110, iterations: Infinity, easing: `steps(${cols})` },
|
| 65 |
)
|
| 66 |
}
|
|
|
|
| 209 |
const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A hero'} reporting for duty.`
|
| 210 |
// Cached audio is stale if the line or the voice design changed since it was made.
|
| 211 |
const isDirty = () => hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || ''))
|
| 212 |
+
// The voice DESIGN text itself changed since the cached voice was made → we must
|
| 213 |
+
// re-DESIGN a new timbre. (Cloning would just re-speak the OLD voice — the bug.)
|
| 214 |
+
const designChanged = () => hasVoice && lastPersona && (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || '')
|
| 215 |
// Badge when there's a persona but no current voice (none yet, or it went stale).
|
| 216 |
function updateVoiceUI() {
|
| 217 |
const needs = !!lastPersona && (!hasVoice || isDirty())
|
|
|
|
| 264 |
updateVoiceUI(); refreshVisibility()
|
| 265 |
}
|
| 266 |
|
| 267 |
+
// ▶ The one voice button: if the cached voice is current, just replay it. If the voice
|
| 268 |
+
// DESIGN text changed (or there's no voice yet) → DESIGN a fresh timbre. If only the
|
| 269 |
+
// spoken line changed → CLONE the last voice (same timbre, new words). Cache + save over.
|
| 270 |
async function play() {
|
| 271 |
if (playing) { stopVoice(); return } // second click while sounding → stop
|
| 272 |
if (working || !lastPersona) return
|
|
|
|
| 281 |
if (!lastPersona.voice) { status.textContent = 'add a voice design first'; return }
|
| 282 |
autosave() // ensure an id to key the audio
|
| 283 |
|
| 284 |
+
// Clone only when the timbre should be preserved (voice unchanged, just new words);
|
| 285 |
+
// otherwise design a new voice from the (possibly edited) description.
|
| 286 |
+
const reclone = hasVoice && !designChanged()
|
| 287 |
// Generating → the ▶ becomes a loading spinner (.busy) until the WAV is ready.
|
| 288 |
working = true; playBtn.classList.add('busy'); playBtn.disabled = true
|
| 289 |
const prev = status.textContent
|
| 290 |
+
status.textContent = reclone ? 'updating the voice…' : 'designing the voice…'
|
| 291 |
let wav = null
|
| 292 |
try {
|
| 293 |
+
if (reclone) {
|
| 294 |
const blob = await getAudio(savedId)
|
| 295 |
wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
|
| 296 |
} else {
|
web/shell/persona.css
CHANGED
|
@@ -41,7 +41,7 @@
|
|
| 41 |
.persona-classdrop-chev { color: var(--p-muted); font-size: 11px; transition: transform .12s; }
|
| 42 |
.persona-classdrop.open .persona-classdrop-chev { transform: rotate(180deg); }
|
| 43 |
.persona-class-ico {
|
| 44 |
-
width:
|
| 45 |
background-repeat: no-repeat; background-position: 0 0; image-rendering: pixelated;
|
| 46 |
}
|
| 47 |
.persona-classdrop-menu {
|
|
|
|
| 41 |
.persona-classdrop-chev { color: var(--p-muted); font-size: 11px; transition: transform .12s; }
|
| 42 |
.persona-classdrop.open .persona-classdrop-chev { transform: rotate(180deg); }
|
| 43 |
.persona-class-ico {
|
| 44 |
+
width: 30px; height: 30px; flex-shrink: 0; display: inline-block; overflow: hidden;
|
| 45 |
background-repeat: no-repeat; background-position: 0 0; image-rendering: pixelated;
|
| 46 |
}
|
| 47 |
.persona-classdrop-menu {
|