diff --git a/CURRENT.md b/CURRENT.md index c2e09de..f649e18 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -40,6 +40,13 @@ mobile draft at 390px. - [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`: `previewed: 2`; thumbs served over `/api/media///thumb` (200). + - [x] C70 Speak panel honesty. Two bugs behind "speak stays queued": the panel never updated + `#tts-status` after queuing (now it polls the daemon and shows `speaking…`, `queued (N)`, + `done`, then `idle`), and `/api/audio/speak` turned the panel's `null` option fields into 0 via + `Number(null)`, so the wrapper clamped `crunchBits` to 1 bit and set the echo level to 0. Null, + undefined, and "" now all mean "unset" for every optional numeric speak field. Verified: the + UI payload plays a healthy stream (-8.7 dB peak, 7 word gaps), and HL90 at 8 bits differs + clearly from the old accidental 1-bit crush. - [x] C69 Streaming silence fix. Word mode with streaming on played nothing but the hum: the daemon started ffplay as soon as the streamed file passed 44 bytes (the bare WAV header), so the player hit EOF and exited before the first word landed. The wrapper now touches diff --git a/poc/public/app.js b/poc/public/app.js index c84d370..3f751f6 100644 --- a/poc/public/app.js +++ b/poc/public/app.js @@ -1715,6 +1715,45 @@ function fillDeviceSelect(select, devices, defaultLabel) { if ([...select.options].some(option => option.value === current)) select.value = current; } +// The daemon plays asynchronously; keep the panel honest about queued vs speaking vs done. +let speechTracker = 0; + +async function trackSpeech() { + const token = ++speechTracker; + + for (let attempt = 0; attempt < 1200; attempt++) { + await new Promise(resolve => setTimeout(resolve, 500)); + if (token !== speechTracker) return; + + try { + const status = await VK.api.get("/api/audio"); + if (token !== speechTracker) return; + + const label = $("#tts-status"); + if (status?.playing === true) { + label.textContent = "speaking\u2026"; + continue; + } + + const queue = Number(status?.queue ?? 0); + if (queue > 0) { + label.textContent = `queued (${queue})`; + continue; + } + + label.textContent = "done"; + setTimeout(() => { + if (token === speechTracker && $("#tts-status").textContent === "done") { + $("#tts-status").textContent = "idle"; + } + }, 4000); + return; + } catch { + return; + } + } +} + async function refreshAudio() { try { const status = await VK.api.get("/api/audio"); @@ -2344,6 +2383,7 @@ async function speakText() { $("#tts-status").textContent = "queued"; toast("Queued for speech", "ok"); void refreshAudio(); + void trackSpeech(); } catch (error) { toast(error.message, "error"); } diff --git a/poc/src/webui.ts b/poc/src/webui.ts index ee20bed..350885c 100644 --- a/poc/src/webui.ts +++ b/poc/src/webui.ts @@ -754,17 +754,21 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle { ? requested : prefs.tts.provider; const voice = req.body?.voice ? String(req.body.voice) : prefs.tts.voice ?? undefined; - const speed = req.body?.speed !== undefined ? Number(req.body.speed) : prefs.tts.speed ?? undefined; - const pitch = req.body?.pitch !== undefined ? Number(req.body.pitch) : prefs.tts.pitch ?? undefined; - const expression = - req.body?.expression !== undefined ? Number(req.body.expression) : prefs.tts.expression ?? undefined; + // Number(null) is 0: null, undefined, and "" all mean "unset". + const optional = (value: unknown): number | undefined => { + if (value === undefined || value === null || value === "") return undefined; + const number = Number(value); + return Number.isFinite(number) ? number : undefined; + }; + + const speed = optional(req.body?.speed) ?? prefs.tts.speed ?? undefined; + const pitch = optional(req.body?.pitch) ?? prefs.tts.pitch ?? undefined; + const expression = optional(req.body?.expression) ?? prefs.tts.expression ?? undefined; const profile = req.body?.profile && typeof req.body.profile === "object" ? req.body.profile : prefs.tts.profile; - const highpass = - req.body?.highpass !== undefined ? Number(req.body.highpass) : prefs.tts.highpass ?? undefined; - const echo = req.body?.echo !== undefined ? Number(req.body.echo) : prefs.tts.echo ?? undefined; + const highpass = optional(req.body?.highpass) ?? prefs.tts.highpass ?? undefined; + const echo = optional(req.body?.echo) ?? prefs.tts.echo ?? undefined; const words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words; - const wordGapMs = - req.body?.wordGapMs !== undefined ? Number(req.body.wordGapMs) : prefs.tts.wordGapMs ?? undefined; + const wordGapMs = optional(req.body?.wordGapMs) ?? prefs.tts.wordGapMs ?? undefined; // The Half-Life VOX voice carries its prosody with it. const vox = (req.body?.vox !== undefined ? req.body.vox === true : prefs.tts.vox) || voice === "vox"; @@ -775,20 +779,16 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle { : req.body?.preset === null || req.body?.preset === "" ? null : prefs.tts.preset; - const echoDelayMs = - req.body?.echoDelayMs !== undefined ? Number(req.body.echoDelayMs) : prefs.tts.echoDelayMs ?? undefined; - const echoLevel = - req.body?.echoLevel !== undefined ? Number(req.body.echoLevel) : prefs.tts.echoLevel ?? undefined; + const echoDelayMs = optional(req.body?.echoDelayMs) ?? prefs.tts.echoDelayMs ?? undefined; + const echoLevel = optional(req.body?.echoLevel) ?? prefs.tts.echoLevel ?? undefined; const crunch = ["hl90", "hl90hq", "telephone", "adpcm", "crush"].includes(String(req.body?.crunch ?? "")) ? (String(req.body.crunch) as "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush") : req.body?.crunch === null || req.body?.crunch === "" || req.body?.crunch === "off" ? undefined : prefs.tts.crunch ?? undefined; - const crunchBits = - req.body?.crunchBits !== undefined ? Number(req.body.crunchBits) : prefs.tts.crunchBits ?? undefined; - const crunchRate = - req.body?.crunchRate !== undefined ? Number(req.body.crunchRate) : prefs.tts.crunchRate ?? undefined; + const crunchBits = optional(req.body?.crunchBits) ?? prefs.tts.crunchBits ?? undefined; + const crunchRate = optional(req.body?.crunchRate) ?? prefs.tts.crunchRate ?? undefined; const streamWords = req.body?.streamWords !== undefined ? req.body.streamWords !== false : prefs.tts.streamWords;