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

Persona voice: re-DESIGN when the voice description changes (not clone)

Browse files

Editing 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>

Files changed (2) hide show
  1. web/personaPanel.js +23 -12
  2. 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 = 26
 
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. Show one
44
- // front-right cell (row 0) scaled to ICON_PX and step across the columns to loop
45
- // the idle animation — no canvas, just a sized background + the Web Animations API.
 
 
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 * ICON_PX}px ${rows * ICON_PX}px`
56
- box.style.backgroundPosition = '0 0'
57
  box.animate(
58
- [{ backgroundPosition: '0 0' }, { backgroundPosition: `-${cols * ICON_PX}px 0` }],
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 there's
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
@@ -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 = 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 || '')
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: 26px; height: 26px; flex-shrink: 0; display: inline-block;
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 {