Fix the speak status tracker and null option handling

This commit is contained in:
Fisto
2026-09-21 18:00:41 +02:00
parent 7701664bc6
commit 15901849a1
3 changed files with 64 additions and 17 deletions
+7
View File
@@ -40,6 +40,13 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [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`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/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 - [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 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 the player hit EOF and exited before the first word landed. The wrapper now touches
+40
View File
@@ -1715,6 +1715,45 @@ function fillDeviceSelect(select, devices, defaultLabel) {
if ([...select.options].some(option => option.value === current)) select.value = current; 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() { async function refreshAudio() {
try { try {
const status = await VK.api.get("/api/audio"); const status = await VK.api.get("/api/audio");
@@ -2344,6 +2383,7 @@ async function speakText() {
$("#tts-status").textContent = "queued"; $("#tts-status").textContent = "queued";
toast("Queued for speech", "ok"); toast("Queued for speech", "ok");
void refreshAudio(); void refreshAudio();
void trackSpeech();
} catch (error) { } catch (error) {
toast(error.message, "error"); toast(error.message, "error");
} }
+17 -17
View File
@@ -754,17 +754,21 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
? requested ? requested
: prefs.tts.provider; : prefs.tts.provider;
const voice = req.body?.voice ? String(req.body.voice) : prefs.tts.voice ?? undefined; 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; // Number(null) is 0: null, undefined, and "" all mean "unset".
const pitch = req.body?.pitch !== undefined ? Number(req.body.pitch) : prefs.tts.pitch ?? undefined; const optional = (value: unknown): number | undefined => {
const expression = if (value === undefined || value === null || value === "") return undefined;
req.body?.expression !== undefined ? Number(req.body.expression) : prefs.tts.expression ?? 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 profile = req.body?.profile && typeof req.body.profile === "object" ? req.body.profile : prefs.tts.profile;
const highpass = const highpass = optional(req.body?.highpass) ?? prefs.tts.highpass ?? undefined;
req.body?.highpass !== undefined ? Number(req.body.highpass) : prefs.tts.highpass ?? undefined; const echo = optional(req.body?.echo) ?? prefs.tts.echo ?? undefined;
const echo = req.body?.echo !== undefined ? Number(req.body.echo) : prefs.tts.echo ?? undefined;
const words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words; const words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words;
const wordGapMs = const wordGapMs = optional(req.body?.wordGapMs) ?? prefs.tts.wordGapMs ?? undefined;
req.body?.wordGapMs !== undefined ? Number(req.body.wordGapMs) : prefs.tts.wordGapMs ?? undefined;
// The Half-Life VOX voice carries its prosody with it. // The Half-Life VOX voice carries its prosody with it.
const vox = const vox =
(req.body?.vox !== undefined ? req.body.vox === true : prefs.tts.vox) || voice === "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 === "" : req.body?.preset === null || req.body?.preset === ""
? null ? null
: prefs.tts.preset; : prefs.tts.preset;
const echoDelayMs = const echoDelayMs = optional(req.body?.echoDelayMs) ?? prefs.tts.echoDelayMs ?? undefined;
req.body?.echoDelayMs !== undefined ? Number(req.body.echoDelayMs) : prefs.tts.echoDelayMs ?? undefined; const echoLevel = optional(req.body?.echoLevel) ?? prefs.tts.echoLevel ?? undefined;
const echoLevel =
req.body?.echoLevel !== undefined ? Number(req.body.echoLevel) : prefs.tts.echoLevel ?? undefined;
const crunch = const crunch =
["hl90", "hl90hq", "telephone", "adpcm", "crush"].includes(String(req.body?.crunch ?? "")) ["hl90", "hl90hq", "telephone", "adpcm", "crush"].includes(String(req.body?.crunch ?? ""))
? (String(req.body.crunch) as "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush") ? (String(req.body.crunch) as "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush")
: req.body?.crunch === null || req.body?.crunch === "" || req.body?.crunch === "off" : req.body?.crunch === null || req.body?.crunch === "" || req.body?.crunch === "off"
? undefined ? undefined
: prefs.tts.crunch ?? undefined; : prefs.tts.crunch ?? undefined;
const crunchBits = const crunchBits = optional(req.body?.crunchBits) ?? prefs.tts.crunchBits ?? undefined;
req.body?.crunchBits !== undefined ? Number(req.body.crunchBits) : prefs.tts.crunchBits ?? undefined; const crunchRate = optional(req.body?.crunchRate) ?? prefs.tts.crunchRate ?? undefined;
const crunchRate =
req.body?.crunchRate !== undefined ? Number(req.body.crunchRate) : prefs.tts.crunchRate ?? undefined;
const streamWords = const streamWords =
req.body?.streamWords !== undefined ? req.body.streamWords !== false : prefs.tts.streamWords; req.body?.streamWords !== undefined ? req.body.streamWords !== false : prefs.tts.streamWords;