diff --git a/app/engine.js b/app/engine.js index 7b5b7a6..8bd6f05 100644 --- a/app/engine.js +++ b/app/engine.js @@ -190,7 +190,9 @@ const state = { /* health counters, reported to the operator page */ degradedFinals: 0, // finals that shipped with fewer languages (stale) - captionOnlyFinals: 0 // finals that shipped with no translations at all + captionOnlyFinals: 0, // finals that shipped with no translations at all + captionOnlyAt: 0, // when the last one happened + degradedAt: 0 }; // ────────────────────────────────────────────────────────── logging @@ -1891,15 +1893,33 @@ async function pump() { const sentences = collapseLoops(splitSentences(text)); let targets = orderedTargets(); - if (ageSec > 12) { + /* + * SKIPPING A TRANSLATION ONLY BUYS SOMETHING IF THERE IS A QUEUE TO DRAIN. + * + * Dropping the translations of stale audio is how the app catches up: the + * listener has moved on, so the work is wasted and the time is better spent on + * the sentences behind it. That reasoning needs a backlog. With nothing waiting + * behind it, skipping costs the room a sentence and saves nothing at all. + * + * MEASURED: a campus Mac took one 16 s stall — two Screen Sharing sessions + * competing for the machine — and skipped that sentence's translations with an + * EMPTY queue. The room lost a line of the sermon to a catch-up that had + * nothing to catch up on. + */ + if (ageSec > 12 && queue.length > 0) { targets = []; state.captionOnlyFinals++; - log('audio ' + ageSec.toFixed(0) + 's stale — caption only, translations skipped', 'err'); + state.captionOnlyAt = Date.now(); + log('audio ' + ageSec.toFixed(0) + 's stale with ' + queue.length + ' waiting — ' + + 'caption only, translations skipped', 'err'); + } else if (ageSec > 12) { + /* 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 before = targets.length; targets = targets.filter((t) => listening.has(t)); - if (targets.length < before) state.degradedFinals++; + if (targets.length < before) { state.degradedFinals++; state.degradedAt = Date.now(); } } /* The segment's own loudness, measured once and shared by every sentence @@ -2758,6 +2778,7 @@ function goLive(on) { state.live = on; if (on) { state.degradedFinals = 0; state.captionOnlyFinals = 0; state.fragmentFinals = 0; + state.captionOnlyAt = 0; state.degradedAt = 0; /* The watchdog measures from the start of the service, not from the last sentence of the previous one. */ state.lastFinalAt = Date.now(); @@ -2832,6 +2853,10 @@ function reportStatus() { queue: queue.length, degradedFinals: state.degradedFinals, captionOnlyFinals: state.captionOnlyFinals, + /* WHEN, not just how many — the verdict needs to know whether this is + happening now or happened once an hour ago. */ + captionOnlyAt: state.captionOnlyAt || 0, + degradedAt: state.degradedAt || 0, fragmentFinals: state.fragmentFinals || 0, cloud: state.cloudStats || null } diff --git a/app/package.json b/app/package.json index f818dc7..cb1a4ca 100644 --- a/app/package.json +++ b/app/package.json @@ -2,7 +2,7 @@ "name": "church-translate-engine", "productName": "Church Translate", "version": "3.23.2", - "buildNumber": 120, + "buildNumber": 121, "private": true, "description": "Live sermon translation — self-contained: captures audio, transcribes, translates, and serves the congregation page itself", "main": "main.js", @@ -106,7 +106,7 @@ ] }, "asar": false, - "buildVersion": "120", + "buildVersion": "121", "win": { "target": [ { diff --git a/app/server/server.js b/app/server/server.js index ab5083d..ecb6baa 100644 --- a/app/server/server.js +++ b/app/server/server.js @@ -510,6 +510,11 @@ const ENGINE_STALE_MS = 12000; * A campus reported "constantly behind, in the red" and the cause took an * afternoon to find; it was eight languages on hardware tuned against one. */ +/* How long a stumble keeps colouring the verdict. Long enough that a real + problem stays visible while someone walks to the desk, short enough that a + one-off does not outlive the sentence after it. */ +const TROUBLE_WINDOW_MS = 120000; + function loadNote() { const n = activeLanguages().length; if (n < 2) return ''; @@ -523,10 +528,27 @@ function healthVerdict(connected, stale) { if (stale) return { level: 'red', detail: 'The translation app has stopped responding.' }; if (!state.live) return { level: 'green', detail: 'Ready.' }; if (!es.modelsReady) return { level: 'amber', detail: 'Models are still loading.' }; - if ((es.lagMs || 0) > 12000 || (es.captionOnlyFinals || 0) > 0) + /* + * RECENTLY, not ever. + * + * captionOnlyFinals is a running total for the whole service, and testing it + * for "more than zero" meant a single stale sentence pinned the desk's light + * red until the next restart. MEASURED: a campus Mac took one 16 s stall at + * 14:25 — caused by two Screen Sharing sessions competing for the machine — + * recovered immediately to a 421 ms lag with an empty queue, and still showed + * red half an hour later. An operator who looks up and sees red wants to know + * what is wrong NOW; a light that cannot go back to green teaches them to + * ignore it. + * + * So the totals still travel, for the record and for the operator page, but + * the verdict asks when it last happened. + */ + const recentCaptionOnly = es.captionOnlyAt && (Date.now() - es.captionOnlyAt) < TROUBLE_WINDOW_MS; + if ((es.lagMs || 0) > 12000 || recentCaptionOnly) return { level: 'red', detail: 'Falling badly behind — some sentences are captions only.' + loadNote() }; - if ((es.lagMs || 0) > 6000 || (es.queue || 0) >= 2 || (es.degradedFinals || 0) > 0) + const recentDegraded = es.degradedAt && (Date.now() - es.degradedAt) < TROUBLE_WINDOW_MS; + if ((es.lagMs || 0) > 6000 || (es.queue || 0) >= 2 || recentDegraded) return { level: 'amber', detail: 'Running behind — idle languages may be trimmed to catch up.' + loadNote() }; if (es.cloud && es.cloud.parked)