Author SHA1 Message Date
Waters ChurchandClaude Opus 5 92e86e415f Step down when losing ground, not when slower than a constant
Build 117.

A campus M4 Pro sat in the red with nobody listening and no language being
translated — so transcription alone. Measured over SSH: sentences 4.2-4.7 s
against roughly 4-5 s of audio, queue 46 deep, lag 152 s and still climbing.

The mechanism built to catch exactly this could not fire. It triggers past 8 s,
on the reasoning that 8 s means slower than people talk. That held when a chunk
could be 8 s long; chunks are capped at HARD_CUT_MAX, 5.5 s. A 4.4 s sentence is
already losing and never comes near 8 s, so the ladder never stepped down and
the backlog grew without bound.

It now compares against the audio the chunk actually held. Two details are
measured rather than chosen: the margin is 0.85, because the Mini's 4.4 s
against 4.5 s was NOMINALLY faster than real time while still losing — the work
outside that timer (segmenting, the preview model sharing the GPU, the
scheduler) loses on its own. And it requires a backlog of five, so one slow
sentence on a healthy machine touches nothing. Checked against eight cases
including the Mini's real numbers and a healthy M2 Max.

The old absolute rule stays for a model that is genuinely hung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:24:05 -04:00
Waters ChurchandClaude Opus 5 73ec7f1436 Report the app version on /healthz
Build 116.

/healthz has always carried a 'build' field, but it is a hash of the listener
page's assets — a cache-busting token, not a version. So the only way to tell
what a campus Mac was actually running was to install something and watch the
hash move, which is how the last three updates were confirmed. With an updater
in the field, what version a machine is on is the first question anyone asks.

It now answers plainly: version and buildNumber, passed to the server child from
the running bundle. Checking a campus is a single unauthenticated request.

It found something immediately: this development Mac reported appBuild 112 while
the source tree was at 115, because deploying loose files over an installed
bundle leaves its package.json behind. A packaged install reports correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:04:46 -04:00
4 changed files with 61 additions and 5 deletions
+38 -2
View File
@@ -1794,6 +1794,7 @@ async function pump() {
state.busy = true; state.busy = true;
const started = Date.now(); const started = Date.now();
let jobAudioMs = 0;
try { try {
/* job.at is when the audio ENDED. Live translation of stale audio is /* job.at is when the audio ENDED. Live translation of stale audio is
worthless, but the WORDS are not: however old the job, it still gets worthless, but the WORDS are not: however old the job, it still gets
@@ -1805,6 +1806,9 @@ async function pump() {
/* Near-silence telemetry (log-only this release): a chunk whose average /* Near-silence telemetry (log-only this release): a chunk whose average
level barely clears the noise floor is hallucination fuel. */ level barely clears the noise floor is hallucination fuel. */
const audio = flatten(job.frames); const audio = flatten(job.frames);
/* How much speech this chunk actually holds. The step-down below compares
against it, because "slow" only means anything relative to the audio. */
jobAudioMs = (audio.length / SAMPLE_RATE) * 1000;
let sum = 0; let sum = 0;
for (let i = 0; i < audio.length; i += 4) sum += audio[i] * audio[i]; for (let i = 0; i < audio.length; i += 4) sum += audio[i] * audio[i];
const meanRms = Math.sqrt(sum / Math.ceil(audio.length / 4)); const meanRms = Math.sqrt(sum / Math.ceil(audio.length / 4));
@@ -1908,11 +1912,43 @@ async function pump() {
* talk, and the backlog only grows — step down before the service is lost * talk, and the backlog only grows — step down before the service is lost
* rather than after. Measured on the Intel laptop: 12-25 s, every sentence. * rather than after. Measured on the Intel laptop: 12-25 s, every sentence.
*/ */
if (took > 8000) { /*
* SLOWER THAN THE AUDIO IT COVERS — not slower than some constant.
*
* This used to fire only past 8 s, on the reasoning that 8 s meant slower
* than people talk. That was true when a chunk could be 8 s long. Chunks
* are capped at HARD_CUT_MAX, 5.5 s, so a 4.5 s chunk taking 4.3 s is
* already losing ground and never came near the old threshold.
*
* MEASURED on a campus M4 Pro: every sentence 4.2-4.7 s against roughly
* 4-5 s of audio, a queue 46 deep and a lag of 152 s still climbing — with
* nobody listening and no language being translated, so this was
* transcription alone. The one mechanism built to catch exactly that could
* not fire, because 4.4 s is not 8 s.
*
* The queue test is what separates "this machine cannot keep up" from one
* slow sentence: a backlog that is three deep and being fed faster than it
* drains does not recover on its own.
*/
/*
* 0.85 and a backlog of five, both measured rather than chosen.
*
* The Mini's sentences ran 4.4 s against 4.5 s of audio — nominally FASTER
* than real time, while the queue sat at 46 and the lag climbed past 150 s.
* Per-sentence time alone therefore misses it: the work outside this timer
* (segmenting, the preview model sharing the GPU, the scheduler) is enough
* to lose on its own. The margin catches "near enough to real time to be
* losing"; the queue depth is the proof that it actually is, and keeps one
* slow sentence on a healthy machine from touching anything.
*/
const losingGround = jobAudioMs > 0 && took > jobAudioMs * 0.85 && queue.length >= 5;
if (took > 8000 || losingGround) {
state.slowRun = (state.slowRun || 0) + 1; state.slowRun = (state.slowRun || 0) + 1;
if (state.slowRun >= 3 && !respawning && lighterAsr(state.asrModelInUse || state.cfg.asrModel)) { if (state.slowRun >= 3 && !respawning && lighterAsr(state.asrModelInUse || state.cfg.asrModel)) {
state.slowRun = 0; state.slowRun = 0;
log('transcription is slower than people are speaking — stepping down to a lighter model', 'err'); log('transcription is slower than people are speaking (' + took + ' ms for ' +
Math.round(jobAudioMs) + ' ms of audio, ' + queue.length + ' waiting) — ' +
'stepping down to a lighter model', 'err');
respawnWorker(state.asrWorker); respawnWorker(state.asrWorker);
} }
} else state.slowRun = 0; } else state.slowRun = 0;
+5
View File
@@ -440,6 +440,11 @@ function startServer() {
execPath: nodeBin, execPath: nodeBin,
env: Object.assign({}, process.env, { env: Object.assign({}, process.env, {
CT_FFMPEG: ffmpegPathForChildren(), CT_FFMPEG: ffmpegPathForChildren(),
/* So /healthz can report the APP’s version rather than only an asset
hash. A campus Mac’s build was otherwise unknowable from the network,
which made confirming an update a matter of inference. */
CT_APP_VERSION: appBuild().version,
CT_APP_BUILD: appBuild().build,
PORT: String(port), PORT: String(port),
DATA_DIR: dataDir(), DATA_DIR: dataDir(),
/* Where the spoken clips land, so the server can hand them to phones. */ /* Where the spoken clips land, so the server can hand them to phones. */
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "church-translate-engine", "name": "church-translate-engine",
"productName": "Church Translate", "productName": "Church Translate",
"version": "3.23.2", "version": "3.23.2",
"buildNumber": 115, "buildNumber": 117,
"private": true, "private": true,
"description": "Live sermon translation — self-contained: captures audio, transcribes, translates, and serves the congregation page itself", "description": "Live sermon translation — self-contained: captures audio, transcribes, translates, and serves the congregation page itself",
"main": "main.js", "main": "main.js",
@@ -106,7 +106,7 @@
] ]
}, },
"asar": false, "asar": false,
"buildVersion": "115", "buildVersion": "117",
"win": { "win": {
"target": [ "target": [
{ {
+16 -1
View File
@@ -1095,7 +1095,22 @@ const server = http.createServer(async (req, res) => {
} }
if (req.method === 'OPTIONS') { res.writeHead(origin ? 204 : 405).end(); return; } if (req.method === 'OPTIONS') { res.writeHead(origin ? 204 : 405).end(); return; }
if (p === '/healthz') { sendJSON(res, 200, { ok: true, live: state.live, build: BUILD }); return; } /*
* The APP's version, plainly, to anyone who can reach this port.
*
* `build` here has always been a hash of the listener-page assets — a
* cache-busting token, not a version — so the only way to tell what a campus
* Mac was running was to install something and watch the hash move. With an
* updater in the field that is the first question anyone asks, so answer it.
*/
if (p === '/healthz') {
sendJSON(res, 200, {
ok: true, live: state.live, build: BUILD,
version: process.env.CT_APP_VERSION || '',
appBuild: process.env.CT_APP_BUILD || ''
});
return;
}
// --- Companion / Stream Deck control ------------------------------- // --- Companion / Stream Deck control -------------------------------
// GET or POST, token in ?key= or the X-Control-Token header. // GET or POST, token in ?key= or the X-Control-Token header.