A stumble should not colour the desk red for the rest of the service

Build 121. Three things a single 16 s stall did that it should not have.

captionOnlyFinals is a running total for the whole service, and the verdict
tested it for more than zero — so one stale sentence pinned the light red until
the next restart. MEASURED: a campus Mac stalled once at 14:25, recovered
immediately to a 421 ms lag with an empty queue, and still showed red half an
hour later, with the operator quite reasonably reporting it as behind. The
totals still travel for the record; the verdict now asks when it last happened,
inside a two-minute window. A light that cannot go back to green teaches the
desk to ignore it.

That sentence also had its translations skipped. Dropping them is how the app
catches up — the listener has moved on and the time is better spent on what is
behind it. But that reasoning needs a backlog, and the queue was EMPTY: the room
lost a line of the sermon to a catch-up that had nothing to catch up on. It now
skips only when something is actually waiting, and says plainly when it chooses
to translate a late sentence anyway.

The stall itself was not the app. Two Screen Sharing sessions were taking as
much of the machine as the app was, with WindowServer on top — watching a campus
Mac during a service measurably degrades it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-24 14:32:25 -04:00
co-authored by Claude Opus 5
parent 39dea80433
commit a06cf35cbb
3 changed files with 55 additions and 8 deletions
+29 -4
View File
@@ -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
}
+2 -2
View File
@@ -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": [
{
+24 -2
View File
@@ -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)