Spaces:
Running
Running
Fix gen UX: elapsed-time feedback + disable broken Qwen3 voice default
Browse filesRoot 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>
- web/elapsed.js +10 -0
- web/heroCreator.js +8 -6
- web/skillForge.js +2 -2
- 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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),
|