diff --git a/app/engine.js b/app/engine.js index 0ad2d4d..48625d2 100644 --- a/app/engine.js +++ b/app/engine.js @@ -195,6 +195,23 @@ const state = { degradedAt: 0 }; +/* + * THE SHAPE OF A LISTENER ROW. The server sends { code, count } — see + * listenerCounts() in server.js — and this file read { lang, n } in five + * places, so every set built from the rows was EMPTY: no language ever counted + * as being listened to. Mostly harmless, except in the one place it mattered. + * When a sentence arrives late, speech is kept only for the languages someone + * is actually on, and "someone is on" was never true for any of them — so + * under load the app dropped the speech of EVERY language, for everyone, + * instead of only the languages nobody had chosen. Late became silent. + * MEASURED on a campus Mac, 2026-10-15 15:11-15:12: 26 sentences dropped + * across five languages during a stall, every one of them with listeners + * present. Both spellings are read, so a server one build older or newer + * still counts. + */ +function rowLang(r) { return r ? (r.code || r.lang || '') : ''; } +function rowCount(r) { return r ? (Number(r.count != null ? r.count : r.n) || 0) : 0; } + /* * FOLLOW THE BOTTOM ONLY WHILE THE OPERATOR IS AT THE BOTTOM. * @@ -1070,10 +1087,15 @@ function preloadNewVoices() { const want = state.targets.filter((l) => state.spokenLanguages.indexOf(l) !== -1 && !preloadedVoices.has(l)); if (!want.length) return; - want.forEach((l) => preloadedVoices.add(l)); /* One at a time in the child anyway; cap the request so a burst of joins - cannot queue up a re-download marathon. */ - window.engineHost.ttsPreload(want.slice(0, 4)); + cannot queue up a re-download marathon. + Only what is SENT counts as warmed. Every wanted language used to be + marked and only the first four sent, so a fifth was never preloaded and + built its engine on its first sentence instead — por_PT, at all five + starts on the Mini on 2026-10-15. Six is the voice process's own cap. */ + const batch = want.slice(0, 6); + batch.forEach((l) => preloadedVoices.add(l)); + window.engineHost.ttsPreload(batch); } /* The translation model reports its download from the main process. */ @@ -1084,7 +1106,10 @@ if (window.engineHost && window.engineHost.onTtsReady) { state.spokenDefaults = info.defaults || state.spokenDefaults || {}; log('built-in voices ready — ' + state.spokenLanguages.length + ' languages', 'ok'); /* Warm whatever people are already reading, so the first sentence of a - service is not the one that pays for the download. */ + service is not the one that pays for the download. A voice process + that has just (re)started holds no engines, whatever this page warmed + in the last one — so forget that first. */ + preloadedVoices.clear(); preloadNewVoices(); }); window.engineHost.onTtsProgress((p) => log('voice ' + p.bundle + ': ' + p.status)); @@ -1778,9 +1803,9 @@ async function pumpPartial() { * still gets a preview. */ const heard = (state.byLanguage || []) - .filter((r) => r && r.lang && r.lang !== state.source && (r.n || 0) > 0) + .filter((r) => r && rowLang(r) && rowLang(r) !== state.source && rowCount(r) > 0) .sort((a, b) => (b.n || 0) - (a.n || 0)) - .map((r) => r.lang) + .map(rowLang) .filter((l) => state.targets.indexOf(l) !== -1); const want = (heard.length ? heard : orderedTargets()).slice(0, PARTIAL_MAX_LANGS); /* Never queue behind a finished sentence — a final in flight is worth more @@ -1806,7 +1831,7 @@ async function pumpPartial() { */ function orderedTargets() { const counts = {}; - for (const row of state.byLanguage || []) counts[row.lang] = row.n || 1; + for (const row of state.byLanguage || []) counts[rowLang(row)] = rowCount(row) || 1; return state.targets .filter((t) => t !== state.source) .sort((a, b) => (counts[b] || 0) - (counts[a] || 0)); @@ -1969,7 +1994,7 @@ async function pump() { /* Late, but nothing is behind it: say so, and translate it anyway. */ log('audio ' + ageSec.toFixed(0) + 's late, but nothing is queued — translating it anyway', 'warn'); } else if (ageSec > 6) { - const listening = new Set((state.byLanguage || []).map((r) => r.lang)); + const listening = new Set((state.byLanguage || []).map(rowLang)); const before = targets.length; targets = targets.filter((t) => listening.has(t)); if (targets.length < before) { state.degradedFinals++; state.degradedAt = Date.now(); } @@ -2169,7 +2194,7 @@ setInterval(() => { }, 30000); function translateSentence(cid, sentence, targets, gainDb, silent) { - const listening = new Set((state.byLanguage || []).map((r) => r.lang)); + const listening = new Set((state.byLanguage || []).map(rowLang)); for (const lang of targets) { if ((mtDepth[lang] || 0) > 6 && !listening.has(lang)) { state.degradedFinals++; @@ -4643,6 +4668,40 @@ function renderVoiceList() { } } +/* + * WHERE AN AUDITION PLAYS: never on the card that feeds the room. + * + * An