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

Fix gen UX: elapsed-time feedback + disable broken Qwen3 voice default

Browse files

Root cause of 'stuck loading / frontend not updating': generation WORKS but is slow on
ZeroGPU (~9-13s) and serializes — the static 'painting…' message with no progress read as
stuck, and Qwen3-TTS (no DashScope key) 503'd as the saved default.
- web/elapsed.js: a live elapsed-seconds counter; wired into the portrait + voice paint
(heroCreator) and skill-icon (skillForge) statuses → 'painting via klein… 8s' ticks, so
slow ≠ stuck. Verified the portrait blob does render (1024x1024) after ~12s.
- Qwen3-TTS now reports available()=false (needs a server key / ?tts= bridge), so it's never
the auto-selected default and a saved-on-qwen3 hero falls back to VoxCPM (which works).

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

Files changed (4) hide show
  1. web/elapsed.js +10 -0
  2. web/heroCreator.js +8 -6
  3. web/skillForge.js +2 -2
  4. web/ttsQwen3.js +4 -1
web/elapsed.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ // Show a live elapsed-seconds counter in a status while a slow call runs, so a 12s ZeroGPU
2
+ // generation reads as "working (8s)" instead of a frozen "painting…" that looks stuck. The
3
+ // counter ticks while the promise is pending and clears when it settles.
4
+ export function withElapsed(setText, prefix, promise) {
5
+ const t0 = Date.now()
6
+ const tick = () => { try { setText(`${prefix} ${Math.round((Date.now() - t0) / 1000)}s`) } catch { /* ignore */ } }
7
+ tick()
8
+ const iv = setInterval(tick, 500)
9
+ return Promise.resolve(promise).finally(() => clearInterval(iv))
10
+ }
web/heroCreator.js CHANGED
@@ -17,6 +17,7 @@ import {
17
  } from '/web/tts.js'
18
  import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js'
19
  import { generatePortrait, imageBackendLabel, imageNeedsDownload, ensureImage } from '/web/imagen.js'
 
20
 
21
  const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
22
  const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
@@ -357,8 +358,8 @@ export function mountHeroCreator(host, opts = {}) {
357
  portraitStatus.textContent = 'loading model…'
358
  await ensureImage((f) => { portraitStatus.textContent = `downloading model… ${Math.round(f * 100)}%` })
359
  }
360
- portraitStatus.textContent = `painting via ${imageBackendLabel()}…`
361
- const blob = await generatePortrait(`${appearance}. ${PORTRAIT_STYLE}`, { seed: 42 })
362
  await putPortrait(savedId, blob)
363
  lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
364
  hasPortrait = true; setPortrait(blob); autosave()
@@ -410,16 +411,17 @@ export function mountHeroCreator(host, opts = {}) {
410
  const reclone = design && hasVoice && !designChanged()
411
  working = true; playBtn.classList.add('busy'); playBtn.disabled = true
412
  const verb = reclone ? 'updating' : (design ? 'designing' : 'generating')
413
- voiceStatus.textContent = `${verb} voice via ${ttsBackendLabel()}…`
 
414
  let wav = null
415
  try {
416
  if (design && reclone) {
417
  const blob = await getAudio(savedId)
418
- wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
419
  } else if (design) {
420
- wav = await createVoiceWav(lastPersona.voice, line)
421
  } else {
422
- wav = await synthVoiceWav(lastPersona.voiceId || '', line)
423
  }
424
  await putAudio(savedId, new Blob([wav], { type: 'audio/wav' }))
425
  lastPersona.voiceQuote = line
 
17
  } from '/web/tts.js'
18
  import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js'
19
  import { generatePortrait, imageBackendLabel, imageNeedsDownload, ensureImage } from '/web/imagen.js'
20
+ import { withElapsed } from '/web/elapsed.js'
21
 
22
  const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
23
  const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
 
358
  portraitStatus.textContent = 'loading model…'
359
  await ensureImage((f) => { portraitStatus.textContent = `downloading model… ${Math.round(f * 100)}%` })
360
  }
361
+ // Ticking elapsed counter so a slow (~12s) ZeroGPU paint reads as "working", not stuck.
362
+ const blob = await withElapsed((t) => { portraitStatus.textContent = t }, `painting via ${imageBackendLabel()}…`, generatePortrait(`${appearance}. ${PORTRAIT_STYLE}`, { seed: 42 }))
363
  await putPortrait(savedId, blob)
364
  lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
365
  hasPortrait = true; setPortrait(blob); autosave()
 
411
  const reclone = design && hasVoice && !designChanged()
412
  working = true; playBtn.classList.add('busy'); playBtn.disabled = true
413
  const verb = reclone ? 'updating' : (design ? 'designing' : 'generating')
414
+ const prefix = `${verb} voice via ${ttsBackendLabel()}…`
415
+ const setVS = (t) => { voiceStatus.textContent = t }
416
  let wav = null
417
  try {
418
  if (design && reclone) {
419
  const blob = await getAudio(savedId)
420
+ wav = await withElapsed(setVS, prefix, cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || ''))
421
  } else if (design) {
422
+ wav = await withElapsed(setVS, prefix, createVoiceWav(lastPersona.voice, line))
423
  } else {
424
+ wav = await withElapsed(setVS, prefix, synthVoiceWav(lastPersona.voiceId || '', line))
425
  }
426
  await putAudio(savedId, new Blob([wav], { type: 'audio/wav' }))
427
  lastPersona.voiceQuote = line
web/skillForge.js CHANGED
@@ -9,6 +9,7 @@ import { validateSkill, SAFE_OPS, SAFE_CONDITIONS, ELEMENTS, SHAPES } from '/web
9
  import { generatePortrait } from '/web/imagen.js'
10
  import { getPersona, patchPersona, putSkillIcon } from '/web/personaStore.js'
11
  import { appendEvent } from '/web/progression.js'
 
12
 
13
  export const FORGE_SYSTEM = [
14
  'You are the Skill Forge for a fantasy auto-battler. Author ONE POWERFUL signature skill for a',
@@ -81,10 +82,9 @@ export async function forgeSkillForHero(personaId, request, { onStatus, onToken,
81
  if (!v.ok) return { ok: false, error: v.errors.join('; ') }
82
 
83
  const skill = v.skill
84
- onStatus && onStatus('painting icon…')
85
  let iconBlob = null
86
  try {
87
- iconBlob = await generatePortrait(iconPrompt(p, skill), { seed: skill.id })
88
  if (iconBlob) await putSkillIcon(skill.id, iconBlob)
89
  } catch (e) { console.warn('[tinyarmy:skill] icon generation failed (skill still learned):', (e && e.message) || e) } // logged via diag; art is optional
90
 
 
9
  import { generatePortrait } from '/web/imagen.js'
10
  import { getPersona, patchPersona, putSkillIcon } from '/web/personaStore.js'
11
  import { appendEvent } from '/web/progression.js'
12
+ import { withElapsed } from '/web/elapsed.js'
13
 
14
  export const FORGE_SYSTEM = [
15
  'You are the Skill Forge for a fantasy auto-battler. Author ONE POWERFUL signature skill for a',
 
82
  if (!v.ok) return { ok: false, error: v.errors.join('; ') }
83
 
84
  const skill = v.skill
 
85
  let iconBlob = null
86
  try {
87
+ iconBlob = await withElapsed((t) => onStatus && onStatus(t), 'painting icon…', generatePortrait(iconPrompt(p, skill), { seed: skill.id }))
88
  if (iconBlob) await putSkillIcon(skill.id, iconBlob)
89
  } catch (e) { console.warn('[tinyarmy:skill] icon generation failed (skill still learned):', (e && e.message) || e) } // logged via diag; art is optional
90
 
web/ttsQwen3.js CHANGED
@@ -79,7 +79,10 @@ export const engine = {
79
  ...common,
80
  id: 'qwen3',
81
  label: 'Qwen3-TTS · Voice Design (cloud)',
82
- available: () => true,
 
 
 
83
  synth: (text, voiceId) => postSynth(ttsBase(), text, voiceId),
84
  synthWav: (text, voiceId) => postSynthWav(ttsBase(), text, voiceId),
85
  cloneWav: (text, refAb, refText, instruct) => postClone(ttsBase(), text, refAb, refText, instruct),
 
79
  ...common,
80
  id: 'qwen3',
81
  label: 'Qwen3-TTS · Voice Design (cloud)',
82
+ // Needs a server-side DashScope key (or a `?tts=` local bridge). Without one the backend 503s,
83
+ // so don't claim availability — that keeps it from being the auto-selected default and lets a
84
+ // saved-on-qwen3 hero fall back to a working engine (VoxCPM). Pick it explicitly if you have a key.
85
+ available: () => { try { return new URLSearchParams(location.search).has('tts') } catch { return false } },
86
  synth: (text, voiceId) => postSynth(ttsBase(), text, voiceId),
87
  synthWav: (text, voiceId) => postSynthWav(ttsBase(), text, voiceId),
88
  cloneWav: (text, refAb, refText, instruct) => postClone(ttsBase(), text, refAb, refText, instruct),