Spaces:
Running
Running
Diagnostics: timeouts + structured logging on all model/media calls
Browse filesRoot cause of 'stuck at loading': none of the image/voice/skill/text fetches had a
timeout, so a slow/hung backend spun forever. Added web/diag.js — diagFetch (timeout +
timing + console log + ring buffer) and diagStream (connect + idle timeouts for SSE):
- image /portrait: 120s ceiling
- voice /qwen-tts + /voxcpm (synth + clone): 90s
- skill + text /text/generate/stream: 30s connect, 60s idle-stall
Calls now FAIL LOUDLY with a clear timing-stamped error instead of hanging. Every call is
logged ([tinyarmy:kind] ✓/✗ in Ns) and recorded; dump with tinyDiag() in the console, or
open Settings → Diagnostics for a live readout. Skill-forge icon failures now warn too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/codingModel.js +1 -1
- web/diag.js +71 -0
- web/engineServer.js +1 -1
- web/imagenServer.js +5 -2
- web/settingsPanel.js +26 -0
- web/skillForge.js +1 -1
- web/sseText.js +48 -30
- web/ttsQwen3.js +5 -4
- web/ttsVoxcpm.js +5 -4
web/codingModel.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function streamCoding(system, user, { maxTokens = 512, temperature
|
|
| 49 |
temperature,
|
| 50 |
think,
|
| 51 |
}, {
|
| 52 |
-
signal,
|
| 53 |
onEvent(evt, parsed) {
|
| 54 |
if (evt !== 'delta') return
|
| 55 |
const piece = parsed?.content || ''
|
|
|
|
| 49 |
temperature,
|
| 50 |
think,
|
| 51 |
}, {
|
| 52 |
+
signal, kind: 'skill', label: (get(_sel) && get(_sel).label) || _sel,
|
| 53 |
onEvent(evt, parsed) {
|
| 54 |
if (evt !== 'delta') return
|
| 55 |
const piece = parsed?.content || ''
|
web/diag.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Call diagnostics for the model/media backends (image, voice, skill/text generation).
|
| 2 |
+
// Every call gets: a timeout (so a hung backend FAILS LOUDLY instead of spinning forever),
|
| 3 |
+
// timing, and a structured console log + a ring buffer you can dump with `tinyDiag()` in the
|
| 4 |
+
// devtools console. This is the troubleshooting surface for "stuck at loading" / silent failures.
|
| 5 |
+
|
| 6 |
+
const RING = []
|
| 7 |
+
const MAX = 80
|
| 8 |
+
const listeners = new Set()
|
| 9 |
+
const nowMs = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now())
|
| 10 |
+
const fmt = (ms) => (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : Math.round(ms) + 'ms')
|
| 11 |
+
|
| 12 |
+
export function onDiag(fn) { listeners.add(fn); return () => listeners.delete(fn) }
|
| 13 |
+
export function recentCalls() { return RING.slice() }
|
| 14 |
+
|
| 15 |
+
function record(e) {
|
| 16 |
+
RING.push(e); if (RING.length > MAX) RING.shift()
|
| 17 |
+
for (const fn of listeners) { try { fn(e) } catch { /* ignore */ } }
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const tagOf = (kind) => `[tinyarmy:${kind}]`
|
| 21 |
+
|
| 22 |
+
// Wrap a non-streaming fetch (image / voice) with a timeout + logging. Returns the Response.
|
| 23 |
+
// Throws a clear, timing-stamped error on failure/timeout so callers can surface it.
|
| 24 |
+
export async function diagFetch(kind, url, opts = {}, { timeoutMs = 90000, label } = {}) {
|
| 25 |
+
const ctrl = new AbortController()
|
| 26 |
+
if (opts.signal) { if (opts.signal.aborted) ctrl.abort(); else opts.signal.addEventListener('abort', () => ctrl.abort(), { once: true }) }
|
| 27 |
+
const timer = setTimeout(() => ctrl.abort(new DOMException('timeout', 'TimeoutError')), timeoutMs)
|
| 28 |
+
const t0 = nowMs(); const tag = tagOf(kind); const what = `${opts.method || 'GET'} ${url}${label ? ` · ${label}` : ''}`
|
| 29 |
+
console.info(`${tag} → ${what}`)
|
| 30 |
+
try {
|
| 31 |
+
const res = await fetch(url, { ...opts, signal: ctrl.signal })
|
| 32 |
+
const ms = nowMs() - t0
|
| 33 |
+
if (!res.ok) {
|
| 34 |
+
console.warn(`${tag} ✗ ${res.status} in ${fmt(ms)} · ${url}`)
|
| 35 |
+
record({ kind, url, label, ok: false, status: res.status, ms, ts: Date.now() })
|
| 36 |
+
} else {
|
| 37 |
+
console.info(`${tag} ✓ ${res.status} in ${fmt(ms)} · ${url}`)
|
| 38 |
+
record({ kind, url, label, ok: true, status: res.status, ms, ts: Date.now() })
|
| 39 |
+
}
|
| 40 |
+
return res
|
| 41 |
+
} catch (e) {
|
| 42 |
+
const ms = nowMs() - t0
|
| 43 |
+
const timedOut = e && (e.name === 'TimeoutError' || e.name === 'AbortError')
|
| 44 |
+
const msg = timedOut ? `TIMEOUT after ${fmt(ms)}` : (e && e.message ? e.message : String(e))
|
| 45 |
+
console.error(`${tag} ✗ ${msg} · ${url}`)
|
| 46 |
+
record({ kind, url, label, ok: false, error: msg, timedOut, ms, ts: Date.now() })
|
| 47 |
+
if (timedOut) throw new Error(`${kind} timed out after ${fmt(ms)} — the backend (${url}) is slow or unresponsive`)
|
| 48 |
+
throw e
|
| 49 |
+
} finally { clearTimeout(timer) }
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Lifecycle logging for a streaming call (SSE). Returns handles the streamer calls as it goes.
|
| 53 |
+
// Logs start / first-token latency / done / error with timing, and records the outcome.
|
| 54 |
+
export function diagStream(kind, url, label) {
|
| 55 |
+
const t0 = nowMs(); const tag = tagOf(kind); const what = `${url}${label ? ` · ${label}` : ''}`
|
| 56 |
+
let first = null
|
| 57 |
+
console.info(`${tag} ⇢ stream ${what}`)
|
| 58 |
+
return {
|
| 59 |
+
firstToken() { if (first == null) { first = nowMs() - t0; console.info(`${tag} · first token in ${fmt(first)} · ${url}`) } },
|
| 60 |
+
done(extra) { const ms = nowMs() - t0; console.info(`${tag} ✓ stream done in ${fmt(ms)}${extra ? ' · ' + extra : ''} · ${url}`); record({ kind, url, label, ok: true, ms, firstMs: first, ts: Date.now() }) },
|
| 61 |
+
fail(e, timedOut) { const ms = nowMs() - t0; const msg = timedOut ? `TIMEOUT after ${fmt(ms)}` : (e && e.message ? e.message : String(e)); console.error(`${tag} ✗ stream ${msg} · ${url}`); record({ kind, url, label, ok: false, error: msg, timedOut, ms, firstMs: first, ts: Date.now() }) },
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Dump recent calls to the console (call `tinyDiag()` in devtools to troubleshoot).
|
| 66 |
+
function dump() {
|
| 67 |
+
const rows = RING.map((e) => ({ when: new Date(e.ts).toLocaleTimeString(), kind: e.kind, ok: e.ok ? '✓' : '✗', status: e.status || (e.timedOut ? 'TIMEOUT' : (e.error ? 'ERR' : '')), ms: fmt(e.ms || 0), detail: e.label || e.error || e.url }))
|
| 68 |
+
try { console.table(rows) } catch { console.log(rows) }
|
| 69 |
+
return RING.slice()
|
| 70 |
+
}
|
| 71 |
+
try { if (typeof window !== 'undefined') window.tinyDiag = dump } catch { /* ignore */ }
|
web/engineServer.js
CHANGED
|
@@ -22,7 +22,7 @@ async function stream(id, system, user, { maxTokens = 200, temperature = 0.8, on
|
|
| 22 |
max_tokens: maxTokens,
|
| 23 |
temperature,
|
| 24 |
}, {
|
| 25 |
-
signal,
|
| 26 |
onEvent(evt, parsed) {
|
| 27 |
if (evt !== 'delta') return
|
| 28 |
const piece = parsed?.content || ''
|
|
|
|
| 22 |
max_tokens: maxTokens,
|
| 23 |
temperature,
|
| 24 |
}, {
|
| 25 |
+
signal, kind: 'text', label: m.id,
|
| 26 |
onEvent(evt, parsed) {
|
| 27 |
if (evt !== 'delta') return
|
| 28 |
const piece = parsed?.content || ''
|
web/imagenServer.js
CHANGED
|
@@ -2,15 +2,18 @@
|
|
| 2 |
// • zimage-local — Z-Image-Turbo on YOUR GPU (TINY_IMAGE_MODE=local). Localhost only.
|
| 3 |
// • flux / flux-dev — FLUX via NVIDIA NIM (or HF Inference) in the cloud.
|
| 4 |
// Mirrors ttsQwen3.js (engine + engineLocal). generate() returns a PNG Blob.
|
|
|
|
| 5 |
|
| 6 |
export const isLocalhost = () => {
|
| 7 |
try { return /^(localhost|127\.0\.0\.1|\[?::1\]?|0\.0\.0\.0)$/i.test(location.hostname) } catch { return false }
|
| 8 |
}
|
| 9 |
|
|
|
|
|
|
|
| 10 |
async function postPortrait(body) {
|
| 11 |
-
const resp = await
|
| 12 |
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
| 13 |
-
})
|
| 14 |
if (!resp.ok) throw new Error(`portrait ${resp.status}: ${(await resp.text()).slice(0, 160)}`)
|
| 15 |
return resp.blob() // image/png
|
| 16 |
}
|
|
|
|
| 2 |
// • zimage-local — Z-Image-Turbo on YOUR GPU (TINY_IMAGE_MODE=local). Localhost only.
|
| 3 |
// • flux / flux-dev — FLUX via NVIDIA NIM (or HF Inference) in the cloud.
|
| 4 |
// Mirrors ttsQwen3.js (engine + engineLocal). generate() returns a PNG Blob.
|
| 5 |
+
import { diagFetch } from '/web/diag.js'
|
| 6 |
|
| 7 |
export const isLocalhost = () => {
|
| 8 |
try { return /^(localhost|127\.0\.0\.1|\[?::1\]?|0\.0\.0\.0)$/i.test(location.hostname) } catch { return false }
|
| 9 |
}
|
| 10 |
|
| 11 |
+
// 2-minute ceiling: image gen on a cold ZeroGPU can take a while, but past this it's hung —
|
| 12 |
+
// fail loudly (logged via diag) instead of spinning the loader forever.
|
| 13 |
async function postPortrait(body) {
|
| 14 |
+
const resp = await diagFetch('image', '/portrait', {
|
| 15 |
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
| 16 |
+
}, { timeoutMs: 120000, label: `${body.engine || ''}${body.provider ? '/' + body.provider : ''}` })
|
| 17 |
if (!resp.ok) throw new Error(`portrait ${resp.status}: ${(await resp.text()).slice(0, 160)}`)
|
| 18 |
return resp.blob() // image/png
|
| 19 |
}
|
web/settingsPanel.js
CHANGED
|
@@ -12,6 +12,7 @@ import { mountImagenBar } from '/web/imagenBar.js'
|
|
| 12 |
import { mountPersonaPromptBar } from '/web/personaPromptBar.js'
|
| 13 |
import { mountQualityBar } from '/web/qualityBar.js'
|
| 14 |
import { mountCodingModelBar } from '/web/codingModelBar.js'
|
|
|
|
| 15 |
|
| 16 |
function el(tag, props = {}, kids = []) {
|
| 17 |
const n = document.createElement(tag)
|
|
@@ -47,6 +48,28 @@ function mountGameplayBar(host) {
|
|
| 47 |
host.append(lab)
|
| 48 |
}
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
export function mountSettingsPanel() {
|
| 51 |
const tryInject = () => {
|
| 52 |
const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
|
|
@@ -74,6 +97,9 @@ export function mountSettingsPanel() {
|
|
| 74 |
'Nemotron 3 Nano (NVIDIA) runs via NVIDIA NIM; Mellum2 (JetBrains) runs as a ' +
|
| 75 |
'ZeroGPU sidecar and falls back to Nemotron (NIM) if its sidecar is unavailable.',
|
| 76 |
mountCodingModelBar)
|
|
|
|
|
|
|
|
|
|
| 77 |
injectSection(sample, 'tac-gameplay-settings', 'Gameplay',
|
| 78 |
'On-map flourishes during the Game. Emotes pop little reaction bubbles above heroes ' +
|
| 79 |
'and enemies when they take a hit, get afflicted, drop low, or score a kill.', mountGameplayBar)
|
|
|
|
| 12 |
import { mountPersonaPromptBar } from '/web/personaPromptBar.js'
|
| 13 |
import { mountQualityBar } from '/web/qualityBar.js'
|
| 14 |
import { mountCodingModelBar } from '/web/codingModelBar.js'
|
| 15 |
+
import { recentCalls, onDiag } from '/web/diag.js'
|
| 16 |
|
| 17 |
function el(tag, props = {}, kids = []) {
|
| 18 |
const n = document.createElement(tag)
|
|
|
|
| 48 |
host.append(lab)
|
| 49 |
}
|
| 50 |
|
| 51 |
+
// Diagnostics: a live readout of recent model/media calls (image, voice, skill, text) with
|
| 52 |
+
// status + timing — for troubleshooting "stuck loading" / silent failures without devtools.
|
| 53 |
+
function mountDiagnosticsBar(host) {
|
| 54 |
+
const fmt = (ms) => (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : Math.round(ms || 0) + 'ms')
|
| 55 |
+
const list = el('div', { style: 'font:11px ui-monospace,monospace;max-height:240px;overflow:auto;border:1px solid #2a3340;border-radius:8px;padding:8px;background:rgba(20,24,33,.5)' })
|
| 56 |
+
const render = () => {
|
| 57 |
+
const calls = recentCalls().slice(-25).reverse()
|
| 58 |
+
list.replaceChildren()
|
| 59 |
+
if (!calls.length) { list.append(el('div', { style: 'color:#9aa4b2' }, 'No model/media calls yet — generate a portrait, voice or skill.')); return }
|
| 60 |
+
for (const c of calls) {
|
| 61 |
+
const mark = c.ok ? '✓' : (c.timedOut ? '⏱ TIMEOUT' : '✗')
|
| 62 |
+
const detail = c.label || c.error || (c.status ? 'HTTP ' + c.status : '') || c.url || ''
|
| 63 |
+
const row = el('div', { style: `padding:3px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:${c.ok ? '#8fe388' : '#ff8a8a'}` },
|
| 64 |
+
`${new Date(c.ts).toLocaleTimeString()} ${c.kind} ${mark} ${fmt(c.ms)} ${detail}`)
|
| 65 |
+
list.append(row)
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
const hint = el('p', { class: 'tac-set-intro', style: 'margin:0 0 6px' }, 'Tip: also run tinyDiag() in the browser console for the full table.')
|
| 69 |
+
host.append(hint, list)
|
| 70 |
+
render(); onDiag(render)
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
export function mountSettingsPanel() {
|
| 74 |
const tryInject = () => {
|
| 75 |
const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
|
|
|
|
| 97 |
'Nemotron 3 Nano (NVIDIA) runs via NVIDIA NIM; Mellum2 (JetBrains) runs as a ' +
|
| 98 |
'ZeroGPU sidecar and falls back to Nemotron (NIM) if its sidecar is unavailable.',
|
| 99 |
mountCodingModelBar)
|
| 100 |
+
injectSection(sample, 'tac-diagnostics-settings', 'Diagnostics',
|
| 101 |
+
'Recent model/media calls (image, voice, skill, text) with status + timing. Use this to ' +
|
| 102 |
+
'troubleshoot generation that is stuck loading or failing — timeouts now surface as ⏱.', mountDiagnosticsBar)
|
| 103 |
injectSection(sample, 'tac-gameplay-settings', 'Gameplay',
|
| 104 |
'On-map flourishes during the Game. Emotes pop little reaction bubbles above heroes ' +
|
| 105 |
'and enemies when they take a hit, get afflicted, drop low, or score a kill.', mountGameplayBar)
|
web/skillForge.js
CHANGED
|
@@ -86,7 +86,7 @@ export async function forgeSkillForHero(personaId, request, { onStatus, onToken,
|
|
| 86 |
try {
|
| 87 |
iconBlob = await generatePortrait(iconPrompt(p, skill), { seed: skill.id })
|
| 88 |
if (iconBlob) await putSkillIcon(skill.id, iconBlob)
|
| 89 |
-
} catch { /
|
| 90 |
|
| 91 |
const fresh = getPersona(p.id) || p
|
| 92 |
patchPersona(p.id, {
|
|
|
|
| 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 |
|
| 91 |
const fresh = getPersona(p.id) || p
|
| 92 |
patchPersona(p.id, {
|
web/sseText.js
CHANGED
|
@@ -2,37 +2,55 @@
|
|
| 2 |
// Parses the `event:`/`data:` wire format and dispatches each event to onEvent.
|
| 3 |
// Used by both engineServer.js (the model-bar "Server / ZeroGPU" engine) and
|
| 4 |
// codingModel.js (the Skill Forge coding-model picker) so the parser lives once.
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
let
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
const
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
-
const data = dataLines.join('\n')
|
| 31 |
-
if (!data) continue
|
| 32 |
-
let parsed = null
|
| 33 |
-
try { parsed = JSON.parse(data) } catch { /* ignore */ }
|
| 34 |
-
if (evt === 'error') throw new Error(parsed?.error || data)
|
| 35 |
-
onEvent?.(evt, parsed, data)
|
| 36 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
}
|
|
|
|
| 2 |
// Parses the `event:`/`data:` wire format and dispatches each event to onEvent.
|
| 3 |
// Used by both engineServer.js (the model-bar "Server / ZeroGPU" engine) and
|
| 4 |
// codingModel.js (the Skill Forge coding-model picker) so the parser lives once.
|
| 5 |
+
//
|
| 6 |
+
// Hardened with a CONNECT timeout (no response headers) + an IDLE timeout (stream stalls with
|
| 7 |
+
// no new chunk), so a hung/unresponsive backend FAILS LOUDLY instead of spinning forever, and
|
| 8 |
+
// every call is logged (see diag.js / `tinyDiag()`).
|
| 9 |
+
import { diagStream } from '/web/diag.js'
|
| 10 |
+
|
| 11 |
+
export async function streamSse(url, body, { onEvent, signal, kind = 'text', label, connectMs = 30000, idleMs = 60000 } = {}) {
|
| 12 |
+
const ctrl = new AbortController()
|
| 13 |
+
if (signal) { if (signal.aborted) ctrl.abort(); else signal.addEventListener('abort', () => ctrl.abort(), { once: true }) }
|
| 14 |
+
let timedOut = false
|
| 15 |
+
let timer = setTimeout(() => { timedOut = true; ctrl.abort() }, connectMs)
|
| 16 |
+
const arm = (ms) => { clearTimeout(timer); timer = setTimeout(() => { timedOut = true; ctrl.abort() }, ms) }
|
| 17 |
+
const log = diagStream(kind, url, label)
|
| 18 |
+
try {
|
| 19 |
+
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: ctrl.signal })
|
| 20 |
+
if (!res.ok || !res.body) { const txt = res.body ? await res.text().catch(() => '') : ''; throw new Error(`HTTP ${res.status}${txt ? ': ' + txt.slice(0, 140) : ''}`) }
|
| 21 |
+
arm(idleMs) // connected → switch to the idle (stall) timeout, reset on every chunk
|
| 22 |
+
const reader = res.body.getReader()
|
| 23 |
+
const decoder = new TextDecoder()
|
| 24 |
+
let buf = ''
|
| 25 |
+
while (true) {
|
| 26 |
+
const { value, done } = await reader.read()
|
| 27 |
+
if (done) break
|
| 28 |
+
arm(idleMs)
|
| 29 |
+
buf += decoder.decode(value, { stream: true })
|
| 30 |
+
const events = buf.split(/\n\n/)
|
| 31 |
+
buf = events.pop() ?? ''
|
| 32 |
+
for (const evChunk of events) {
|
| 33 |
+
const lines = evChunk.split('\n')
|
| 34 |
+
let evt = 'message'
|
| 35 |
+
const dataLines = []
|
| 36 |
+
for (const line of lines) {
|
| 37 |
+
if (line.startsWith('event:')) evt = line.slice(6).trim()
|
| 38 |
+
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
|
| 39 |
+
}
|
| 40 |
+
const data = dataLines.join('\n')
|
| 41 |
+
if (!data) continue
|
| 42 |
+
let parsed = null
|
| 43 |
+
try { parsed = JSON.parse(data) } catch { /* ignore */ }
|
| 44 |
+
if (evt === 'error') throw new Error(parsed?.error || data)
|
| 45 |
+
if (evt === 'delta') log.firstToken()
|
| 46 |
+
onEvent?.(evt, parsed, data)
|
| 47 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
+
clearTimeout(timer); log.done()
|
| 50 |
+
} catch (e) {
|
| 51 |
+
clearTimeout(timer)
|
| 52 |
+
log.fail(e, timedOut)
|
| 53 |
+
if (timedOut) throw new Error(`${kind} timed out — the generation backend is slow or unresponsive`)
|
| 54 |
+
throw e
|
| 55 |
}
|
| 56 |
}
|
web/ttsQwen3.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
// language DESCRIPTION (the persona's `voice` field, or a preset), used as DashScope's
|
| 4 |
// `voice_prompt`. NETWORKED — not local-first (clearly labeled). mode 'pcm'.
|
| 5 |
import { decodeAudio } from '/web/ttsAudio.js'
|
|
|
|
| 6 |
|
| 7 |
// Endpoint: default is our Space backend (/qwen-tts → DashScope). A `?tts=<base>` query
|
| 8 |
// param (persisted to localStorage) points it at a self-run local server instead —
|
|
@@ -38,10 +39,10 @@ export const isLocalhost = () => {
|
|
| 38 |
// POST to `${base}/qwen-tts` → raw WAV ArrayBuffer. base '' = same-origin.
|
| 39 |
async function postSynthWav(base, text, voiceId) {
|
| 40 |
const instruct = (get(voiceId).desc() || '').trim()
|
| 41 |
-
const resp = await
|
| 42 |
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 43 |
body: JSON.stringify({ text, instruct, language: 'English' }),
|
| 44 |
-
})
|
| 45 |
if (!resp.ok) throw new Error(`Qwen3-TTS ${resp.status}: ${(await resp.text()).slice(0, 140)}`)
|
| 46 |
return resp.arrayBuffer()
|
| 47 |
}
|
|
@@ -55,12 +56,12 @@ function abToB64(ab) {
|
|
| 55 |
return btoa(s)
|
| 56 |
}
|
| 57 |
async function postClone(base, text, refAb, refText, instruct) {
|
| 58 |
-
const resp = await
|
| 59 |
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 60 |
// instruct lets prod (DashScope, no clone model) gracefully re-design from the
|
| 61 |
// description instead of cloning; local mode uses ref_audio to clone the timbre.
|
| 62 |
body: JSON.stringify({ text, ref_audio: abToB64(refAb), ref_text: refText || '', instruct: instruct || '', language: 'English' }),
|
| 63 |
-
})
|
| 64 |
if (!resp.ok) throw new Error(`Qwen3-TTS clone ${resp.status}: ${(await resp.text()).slice(0, 140)}`)
|
| 65 |
return resp.arrayBuffer()
|
| 66 |
}
|
|
|
|
| 3 |
// language DESCRIPTION (the persona's `voice` field, or a preset), used as DashScope's
|
| 4 |
// `voice_prompt`. NETWORKED — not local-first (clearly labeled). mode 'pcm'.
|
| 5 |
import { decodeAudio } from '/web/ttsAudio.js'
|
| 6 |
+
import { diagFetch } from '/web/diag.js'
|
| 7 |
|
| 8 |
// Endpoint: default is our Space backend (/qwen-tts → DashScope). A `?tts=<base>` query
|
| 9 |
// param (persisted to localStorage) points it at a self-run local server instead —
|
|
|
|
| 39 |
// POST to `${base}/qwen-tts` → raw WAV ArrayBuffer. base '' = same-origin.
|
| 40 |
async function postSynthWav(base, text, voiceId) {
|
| 41 |
const instruct = (get(voiceId).desc() || '').trim()
|
| 42 |
+
const resp = await diagFetch('voice', `${base}/qwen-tts`, {
|
| 43 |
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 44 |
body: JSON.stringify({ text, instruct, language: 'English' }),
|
| 45 |
+
}, { timeoutMs: 90000, label: 'qwen3 synth' })
|
| 46 |
if (!resp.ok) throw new Error(`Qwen3-TTS ${resp.status}: ${(await resp.text()).slice(0, 140)}`)
|
| 47 |
return resp.arrayBuffer()
|
| 48 |
}
|
|
|
|
| 56 |
return btoa(s)
|
| 57 |
}
|
| 58 |
async function postClone(base, text, refAb, refText, instruct) {
|
| 59 |
+
const resp = await diagFetch('voice', `${base}/qwen-tts`, {
|
| 60 |
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 61 |
// instruct lets prod (DashScope, no clone model) gracefully re-design from the
|
| 62 |
// description instead of cloning; local mode uses ref_audio to clone the timbre.
|
| 63 |
body: JSON.stringify({ text, ref_audio: abToB64(refAb), ref_text: refText || '', instruct: instruct || '', language: 'English' }),
|
| 64 |
+
}, { timeoutMs: 90000, label: 'qwen3 clone' })
|
| 65 |
if (!resp.ok) throw new Error(`Qwen3-TTS clone ${resp.status}: ${(await resp.text()).slice(0, 140)}`)
|
| 66 |
return resp.arrayBuffer()
|
| 67 |
}
|
web/ttsVoxcpm.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
// tokens and sidecar details stay server-side. Like Qwen3, this designs a voice
|
| 3 |
// from each hero's free-form voice description.
|
| 4 |
import { decodeAudio } from '/web/ttsAudio.js'
|
|
|
|
| 5 |
|
| 6 |
let _desc = ''
|
| 7 |
const VOICES = [
|
|
@@ -15,10 +16,10 @@ const get = (id) => VOICES.find((v) => v.id === id) || VOICES[0]
|
|
| 15 |
|
| 16 |
async function postSynthWav(text, voiceId) {
|
| 17 |
const instruct = (get(voiceId).desc() || '').trim()
|
| 18 |
-
const resp = await
|
| 19 |
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 20 |
body: JSON.stringify({ text, instruct, language: 'English' }),
|
| 21 |
-
})
|
| 22 |
if (!resp.ok) throw new Error(`VoxCPM ${resp.status}: ${(await resp.text()).slice(0, 140)}`)
|
| 23 |
return resp.arrayBuffer()
|
| 24 |
}
|
|
@@ -30,10 +31,10 @@ function abToB64(ab) {
|
|
| 30 |
return btoa(s)
|
| 31 |
}
|
| 32 |
async function postCloneWav(text, refAb, refText, instruct) {
|
| 33 |
-
const resp = await
|
| 34 |
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 35 |
body: JSON.stringify({ text, ref_audio: abToB64(refAb), ref_text: refText || '', instruct: instruct || '', language: 'English' }),
|
| 36 |
-
})
|
| 37 |
if (!resp.ok) throw new Error(`VoxCPM clone ${resp.status}: ${(await resp.text()).slice(0, 140)}`)
|
| 38 |
return resp.arrayBuffer()
|
| 39 |
}
|
|
|
|
| 2 |
// tokens and sidecar details stay server-side. Like Qwen3, this designs a voice
|
| 3 |
// from each hero's free-form voice description.
|
| 4 |
import { decodeAudio } from '/web/ttsAudio.js'
|
| 5 |
+
import { diagFetch } from '/web/diag.js'
|
| 6 |
|
| 7 |
let _desc = ''
|
| 8 |
const VOICES = [
|
|
|
|
| 16 |
|
| 17 |
async function postSynthWav(text, voiceId) {
|
| 18 |
const instruct = (get(voiceId).desc() || '').trim()
|
| 19 |
+
const resp = await diagFetch('voice', '/voxcpm-tts', {
|
| 20 |
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 21 |
body: JSON.stringify({ text, instruct, language: 'English' }),
|
| 22 |
+
}, { timeoutMs: 90000, label: 'voxcpm synth' })
|
| 23 |
if (!resp.ok) throw new Error(`VoxCPM ${resp.status}: ${(await resp.text()).slice(0, 140)}`)
|
| 24 |
return resp.arrayBuffer()
|
| 25 |
}
|
|
|
|
| 31 |
return btoa(s)
|
| 32 |
}
|
| 33 |
async function postCloneWav(text, refAb, refText, instruct) {
|
| 34 |
+
const resp = await diagFetch('voice', '/voxcpm-clone', {
|
| 35 |
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 36 |
body: JSON.stringify({ text, ref_audio: abToB64(refAb), ref_text: refText || '', instruct: instruct || '', language: 'English' }),
|
| 37 |
+
}, { timeoutMs: 90000, label: 'voxcpm clone' })
|
| 38 |
if (!resp.ok) throw new Error(`VoxCPM clone ${resp.status}: ${(await resp.text()).slice(0, 140)}`)
|
| 39 |
return resp.arrayBuffer()
|
| 40 |
}
|