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

Diagnostics: timeouts + structured logging on all model/media calls

Browse files

Root 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 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 fetch('/portrait', {
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 { /* art is optional */ }
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
- export async function streamSse(url, body, { onEvent, signal } = {}) {
6
- const res = await fetch(url, {
7
- method: 'POST',
8
- headers: { 'Content-Type': 'application/json' },
9
- body: JSON.stringify(body),
10
- signal,
11
- })
12
- if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
13
- const reader = res.body.getReader()
14
- const decoder = new TextDecoder()
15
- let buf = ''
16
- while (true) {
17
- const { value, done } = await reader.read()
18
- if (done) break
19
- buf += decoder.decode(value, { stream: true })
20
- const events = buf.split(/\n\n/)
21
- buf = events.pop() ?? ''
22
- for (const evChunk of events) {
23
- const lines = evChunk.split('\n')
24
- let evt = 'message'
25
- const dataLines = []
26
- for (const line of lines) {
27
- if (line.startsWith('event:')) evt = line.slice(6).trim()
28
- else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 fetch(`${base}/qwen-tts`, {
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 fetch(`${base}/qwen-tts`, {
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 fetch('/voxcpm-tts', {
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 fetch('/voxcpm-clone', {
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
  }