Files
church-translate/app/server/server.js
T
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

2112 lines
88 KiB
JavaScript

/*
* Church Translate — server (runs in Docker on the Synology NAS).
*
* This box never touches audio and never runs a model. It is the venue:
* • serves the listener page that the QR code points at
* • serves the operator page
* • exposes a plain-HTTP control API for Bitfocus Companion / Stream Deck
* • relays text from the Mac engine out to every listener
*
* Because only text crosses the wire, a thousand listeners cost the NAS about
* as much as one.
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { WebSocketServer } = require('ws');
const QRCode = require('qrcode');
const Languages = require('./public/languages.js');
/* Which languages this Mac can actually speak — text reaches ~200, speech 31. */
let Voices = { hasVoice: () => false, spokenLanguages: () => [], sampleRateFor: () => 22050 };
try { Voices = require('../voices.js'); } catch (_) { /* running standalone */ }
const PORT = Number(process.env.PORT || 3100);
/*
* How long the service survives with no engine attached. Long enough to cover
* a socket re-open (milliseconds) and a renderer reload (a second or two),
* short enough that a genuinely dead engine is reported while the room still
* connects the two events.
*/
const ENGINE_GRACE_MS = 8000;
let enginePause = null;
/*
* NEVER default inside the bundle. electron-builder replaces
* Contents/Resources/app wholesale on every install, so a server started
* without DATA_DIR would put the config, the transcripts and the PIN file
* somewhere an update deletes — producing exactly the "my settings reset after
* every update" report, deterministically. The app always sets this; the
* fallback is for standalone runs and points somewhere durable.
*/
const DATA_DIR = process.env.DATA_DIR || (process.platform === 'win32'
? path.join(process.env.LOCALAPPDATA ||
path.join(require('os').homedir(), 'AppData', 'Local'), 'Church Translate', 'data')
: path.join(require('os').homedir(), 'Library', 'Application Support', 'Church Translate', 'data'));
const PUBLIC_DIR = path.join(__dirname, 'public');
const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
/* Where the voice process writes its clips. Set by the app when it forks us. */
const CLIP_DIR = process.env.CLIP_DIR || path.join(require('os').tmpdir(), 'church-translate-audio');
const { LiveAudio } = require('./live-audio.js');
const { execFileSync } = require('child_process');
/* One continuous stream per language. See live-audio.js for why per-sentence
clips cannot survive a locked phone. */
const liveAudio = new LiveAudio((m) => log(m));
/*
* Read-along: the moment a sentence's frames start leaving the queue, tell the
* listeners on that language, so their page can highlight the caption the
* voice is actually saying rather than the newest one. The phone compensates
* for its own player lag before lighting it up.
*/
/*
* A dropped sentence is recorded on its transcript row and counted — the one
* thing the morning of the failure did not have. Not a rewrite of what was
* said (the caption stands); a note that this language's voice skipped it.
*/
let state_dropTotal = 0;
liveAudio.onDropped = (lang, ids, secs) => {
state_dropTotal += ids.length;
for (const id of ids) {
const row = state.scrollback.find((r) => r.id === id);
if (row) { if (!row.audioDropped) row.audioDropped = []; if (row.audioDropped.indexOf(lang) === -1) row.audioDropped.push(lang); }
}
log('live audio ' + lang + ': dropped ' + ids.length + ' stale sentence(s), ' + secs.toFixed(1) + 's (' +
state_dropTotal + ' this service)');
broadcastOperators();
notifyCompanion();
};
liveAudio.onSentenceStart = (lang, id, secs) => {
const frame = JSON.stringify({ t: 'nowSpeaking', id, seconds: secs });
const to = (only) => {
for (const [ws, meta] of state.listeners) {
if (ws.readyState !== 1) continue;
if (!only(meta)) continue;
if ((meta.lang || config.sourceLanguage) !== lang) continue;
try { ws.send(frame); } catch (_) {}
}
};
const ms = streamDelayMs();
if (ms === 0) { to(ALL); return; }
/* This fires when frames leave the queue at the LIVE edge. A delayed
listener is that far back in the window, so the highlight has to wait the
same amount or it lights up a caption they have not reached yet. */
to(LOCAL);
const t = setTimeout(() => { delayTimers.delete(t); if (state.live) to(REMOTE); }, ms);
if (t.unref) t.unref();
delayTimers.add(t);
};
/*
* The interpreter's feedback loop: every couple of seconds, tell the engine how
* far each language's voice is behind its text. The voice process quickens its
* delivery when it is falling behind — which is what a human interpreter does —
* so the catch-up logic almost never has to drop a sentence.
*/
setInterval(() => {
if (!state.live || !state.engine) return;
const backlog = liveAudio.backlog();
if (Object.keys(backlog).length) sendToEngine({ t: 'lag', backlog });
}, 2000).unref();
/* How many finalized lines a late arrival sees when they join mid-sermon. */
const SCROLLBACK = 40;
// ---------------------------------------------------------------- config
const DEFAULT_CONFIG = {
serviceName: 'Waters Church',
welcome: 'Choose your language',
sourceLanguage: 'eng_Latn',
featured: Languages.DEFAULT_FEATURED.slice(),
/* Translated every service whether or not anyone is listening. Use this when
you know a group is coming and want zero warm-up on the first sentence. */
pinned: [],
/*
* Seconds to hold everything LISTENER-facing (captions, previews, spoken
* audio) so it lines up with a livestream's own latency — a YouTube viewer's
* video runs 10-40s behind the room, and captions arriving live would spoil
* the sermon ahead of the video. Operators in the room always see live.
*/
streamDelaySec: 0,
allowAllLanguages: true,
publicUrl: '',
/* Optional. If set, the server pushes live/ready state into a Companion
custom variable so the Stream Deck button can colour itself. Without it
the button still works, it just won't reflect state. */
companionUrl: '',
companionVariable: 'translate_state',
operatorPin: '',
controlToken: '',
engineKey: '',
/* `fixes` are corrections applied to the transcript — each line is
"what it heard => what it should say". `overrides` pin a term's
translation in a specific language. Both are plain find-and-replace,
which is exactly why they are dependable. */
glossary: {
fixes: [
'have a cook => Habakkuk',
'water church => Waters Church'
],
overrides: {}
}
};
function ensureDataDir() {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
/*
* Settings are the operator's WORK — pinned languages, the corrections list
* built up over months of services. Losing them is not a glitch, it is asking
* someone to redo an afternoon.
*
* The failure this guards against is real and was reproduced: a config file
* that will not parse (a crash mid-write, a full disk, anything) used to fall
* through to {}, merge the defaults over it, and SAVE — permanently replacing
* every setting AND minting a new operator PIN, with nothing logged. One bad
* read destroyed everything.
*
* So: snapshot before every write, restore from the newest good snapshot when
* the live file is unreadable, and NEVER overwrite an unreadable file with
* defaults — keep it for forensics instead.
*/
const BACKUP_DIR = () => path.join(DATA_DIR, 'config-backups');
const KEEP_BACKUPS = 30;
/*
* The listener-facing delay line. Every broadcast to LISTENERS funnels through
* delayed(); operators bypass it entirely. Stopping the service cancels every
* pending timer — nothing may fire into a stopped room.
*/
/*
* WHO a broadcast is for.
*
* The stream delay used to be one global timer: turning it on to protect a
* livestream also delayed the people sitting in the room, which is the one
* group that must never be delayed — they can hear the preacher with their own
* ears, and captions arriving twenty seconds late are worse than none.
*
* `proxied` is recorded per listener at connection time (it is true when the
* request carried x-forwarded-for, i.e. it came through the tunnel), so the
* room and the internet can be served from the same broadcast at different
* times. A service reachable on the LAN and through a tunnel at once is the
* normal case, not an edge case.
*/
const ALL = () => true;
const LOCAL = (meta) => !meta.proxied;
const REMOTE = (meta) => !!meta.proxied;
function streamDelayMs() {
return Math.max(0, Number(config.streamDelaySec) || 0) * 1000;
}
const delayTimers = new Set();
function delayed(fn) {
/*
* Gated at ENQUEUE, not at fire time, and that is the whole design. With a
* stream delay the words a listener is hearing right now were spoken N
* seconds ago — so what must be suppressed is what is being said FROM THE
* MOMENT THE BUTTON IS PRESSED, which is exactly what is being enqueued now.
* Anything already on the delay line was said before the hold and is safe;
* cancelling it would chop the stream off mid-sentence for no reason.
*/
if (state.hold) return;
const ms = streamDelayMs();
/* No delay set: one pass, everybody, exactly as before. */
if (ms === 0) { fn(ALL); return; }
fn(LOCAL);
const t = setTimeout(() => { delayTimers.delete(t); if (state.live) fn(REMOTE); }, ms);
if (t.unref) t.unref();
delayTimers.add(t);
}
function cancelDelayed() {
for (const t of delayTimers) clearTimeout(t);
delayTimers.clear();
}
/*
* Pass the content to snapshot, or nothing to snapshot whatever is on disk.
*
* This is called AFTER a successful write, not before it. Snapshotting the old
* content first sounds safer but means the newest snapshot is always one edit
* behind — so a restore silently reverted the very change the operator made
* last. The newest snapshot should BE the last known-good config.
*/
function backupConfig(content) {
try {
let raw = content;
if (raw === undefined) {
if (!fs.existsSync(CONFIG_PATH)) return;
raw = fs.readFileSync(CONFIG_PATH, 'utf8');
}
JSON.parse(raw); // never snapshot a broken file
const dir = BACKUP_DIR();
fs.mkdirSync(dir, { recursive: true });
/* Identical content is not worth a new file — it just pushes real history
out of the 30 we keep. */
const existing = fs.readdirSync(dir).filter((f) => /^config-.*\.json$/.test(f)).sort();
if (existing.length) {
try {
if (fs.readFileSync(path.join(dir, existing[existing.length - 1]), 'utf8') === raw) return;
} catch (_) {}
}
/* Second resolution, not minute. At minute resolution only the FIRST state
in any given minute was ever captured, so a restore quietly reverted
every edit made since — a backup that loses the work it exists to protect
is worse than none. 30 snapshots of a file this size costs nothing. */
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const file = path.join(dir, 'config-' + stamp + '.json');
if (!fs.existsSync(file)) fs.writeFileSync(file, raw);
const all = fs.readdirSync(dir).filter((f) => /^config-.*\.json$/.test(f)).sort();
for (const old of all.slice(0, Math.max(0, all.length - KEEP_BACKUPS))) {
try { fs.unlinkSync(path.join(dir, old)); } catch (_) {}
}
} catch (_) { /* a backup must never be the reason a save fails */ }
}
function newestBackup() {
try {
const dir = BACKUP_DIR();
const all = fs.readdirSync(dir).filter((f) => /^config-.*\.json$/.test(f)).sort();
for (let i = all.length - 1; i >= 0; i--) {
try {
const raw = fs.readFileSync(path.join(dir, all[i]), 'utf8');
return { cfg: JSON.parse(raw), file: all[i] };
} catch (_) { /* try the one before it */ }
}
} catch (_) {}
return null;
}
function loadConfig() {
ensureDataDir();
let cfg = {};
let recovered = false;
const existed = fs.existsSync(CONFIG_PATH);
try {
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
/* Snapshot every good config we manage to read. Backing up only on save
meant a server that booted fine and was never edited had NO history —
so the first corruption had nothing to fall back to. Boot is exactly
when we know the file is good and nothing is about to change it. */
backupConfig();
} catch (err) {
cfg = {};
if (existed) {
/* The file is there but unreadable. Do NOT let defaults win. */
const back = newestBackup();
if (back) {
cfg = back.cfg;
recovered = true;
log('config.json was unreadable (' + err.message + ') — RESTORED from ' + back.file);
} else {
log('config.json is unreadable (' + err.message + ') and there is no backup to restore');
}
/* Keep the damaged file rather than silently overwriting the evidence. */
try {
const kept = CONFIG_PATH + '.broken-' + Date.now();
fs.copyFileSync(CONFIG_PATH, kept);
log('kept the damaged file at ' + path.basename(kept));
} catch (_) {}
}
}
const merged = Object.assign({}, DEFAULT_CONFIG, cfg);
merged.glossary = Object.assign({}, DEFAULT_CONFIG.glossary, cfg.glossary || {});
/* Secrets are generated once, on first boot, and then persist. The page is
reachable from the public internet through the tunnel, so "no password"
is not an option we offer. */
let dirty = false;
if (!merged.controlToken) { merged.controlToken = crypto.randomBytes(9).toString('base64url'); dirty = true; }
if (!merged.engineKey) { merged.engineKey = crypto.randomBytes(18).toString('base64url'); dirty = true; }
/* Four digits: short enough to type at a desk in the dark. What keeps it safe
is that the operator page is LAN-only (see operatorRemote), not its length. */
if (!merged.operatorPin) { merged.operatorPin = String(crypto.randomInt(1000, 9999)); dirty = true; }
if (dirty || recovered || !fs.existsSync(CONFIG_PATH)) {
saveConfig(merged);
/* Written in the clear next to the data so it can be read over SMB without
shelling into the NAS. */
try {
fs.writeFileSync(path.join(DATA_DIR, 'OPERATOR-PIN.txt'),
'Operator PIN: ' + merged.operatorPin + '\n' +
'Companion control token: ' + merged.controlToken + '\n' +
'Engine key: ' + merged.engineKey + '\n\n' +
'Change the PIN on the operator page. Keep this file off the projector.\n');
} catch (_) {}
}
return merged;
}
/*
* Returns true on success. NEVER throws: this is called straight from the HTTP
* handler, and an exception there (full disk, read-only share, permissions)
* killed the whole server process — the operator saw a page that simply stopped
* responding, tried again, and killed it again.
*/
function saveConfig(cfg) {
try {
return writeConfig(cfg);
} catch (err) {
log('COULD NOT SAVE config: ' + err.message);
return false;
}
}
function writeConfig(cfg) {
ensureDataDir();
const tmp = CONFIG_PATH + '.tmp';
const body = JSON.stringify(cfg, null, 2);
/* Parse what we are about to write. A bug upstream that produced a circular
or half-built object should fail HERE, with the good file still in place,
rather than after the rename. */
JSON.parse(body);
const fd = fs.openSync(tmp, 'w');
try {
fs.writeSync(fd, body);
/* Force it to the platter before the rename. Without this a power cut
between write and rename can leave a zero-length config — the exact
state that used to wipe every setting on the next boot. */
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, CONFIG_PATH);
backupConfig(body); // newest snapshot == what we just saved
return true;
}
let config = loadConfig();
// ---------------------------------------------------------------- state
const state = {
live: false, // is the engine actively transcribing
/*
* The cough button. Translation keeps running and the operator keeps seeing
* every word; nothing reaches a LISTENER while this is on. For the moment a
* pastor names someone in a prayer request, or a conversation happens on a
* live mic that was never meant to leave the room. Stopping the service
* would work too, but it drops every listener, empties the delay line and
* makes everyone re-open the page — for fifteen seconds of privacy.
*/
hold: false,
engine: null, // the Mac engine's socket, if connected
engineStatus: {}, // last status frame from the engine
engineStatusAt: 0, // when that frame arrived
startedAt: null,
listeners: new Map(), // ws -> { lang, voice, joinedAt }
/*
* Which alternate voices listeners may pick, per language, as pushed by the
* renderer (which owns the setting). In memory only and deliberately so —
* the app config is the single owner, and a copy in the server's own
* config.json would be a second truth to drift out of sync.
*
* Empty until the engine connects, which is correct: with no engine there is
* nothing synthesising alternates anyway.
*/
voiceOptions: {}, // lang -> [voice key, ...]
operators: new Set(),
scrollback: [], // recent finals, for late joiners
partial: '', // in-flight sentence, source language
seq: 0
};
/* The set of languages the engine should actually spend GPU on: whatever a
listener currently has open, plus anything pinned. Nobody listening in
Icelandic means nobody pays for Icelandic. */
/*
* Does anyone want the live-microphone English feed? Source-language listeners
* who have speech ON. An idle English tab reading captions costs nothing.
*/
function micWanted() {
if (!state.live) return false;
for (const meta of state.listeners.values()) {
if ((meta.lang || config.sourceLanguage) === config.sourceLanguage && meta.speaking) return true;
}
return false;
}
function activeLanguages() {
const set = new Set(config.pinned || []);
for (const meta of state.listeners.values()) {
if (meta.lang && meta.lang !== config.sourceLanguage) set.add(meta.lang);
}
const langs = Array.from(set);
/* A language nobody is listening to should not cost a timer or a byte. The
source language rides along while the microphone feed is wanted — its
stream carries the room audio at the mic's own 16k, not a voice's rate. */
const keep = micWanted() ? langs.concat([config.sourceLanguage]) : langs;
if (state.live) {
liveAudio.retain(keep, (l) => l === config.sourceLanguage ? 16000 : Voices.sampleRateFor(l));
} else liveAudio.stopAll();
return langs;
}
let langPushTimer = null;
function pushLanguagesToEngine() {
if (langPushTimer) return;
/* Debounced: a crowd arriving at once would otherwise retune the engine
dozens of times in a second. */
langPushTimer = setTimeout(() => {
langPushTimer = null;
/* The counts ride along with the language list — the app shows the operator
who is actually reading, without needing a second channel. */
sendToEngine({
t: 'langs',
list: activeLanguages(),
micWanted: micWanted(),
source: config.sourceLanguage,
/* The glossary rides along too. Without it, an operator who fixes a
misheard name mid-service gets a "saved" toast while the engine keeps
using the old table until the next stop/start — the UI lies. */
glossary: config.glossary,
listeners: state.listeners.size,
byLanguage: listenerCounts(),
/* Debounced on the same timer as the language list, so a crowd all
switching voice at once retunes the engine once, not per phone. */
voiceDemand: voiceDemand(),
detail: listenerDetail()
});
}, 250);
}
function sendToEngine(msg) {
if (state.engine && state.engine.readyState === 1) {
try { state.engine.send(JSON.stringify(msg)); } catch (_) {}
}
}
/* The engine reports every 3 seconds. A socket that is still open but has gone
quiet means the Mac app is wedged — which looks exactly like "working fine"
unless we say otherwise. */
const ENGINE_STALE_MS = 12000;
/*
* One traffic light a volunteer can act on. "27153 ms behind" on a status line
* means nothing to someone running sound for the first time; red/amber/green
* with one plain sentence does. Computed here so every operator surface —
* in-app pane, browser page — shows the same verdict.
*/
/*
* WHEN IT IS BEHIND, SAY WHAT IS LOADING IT.
*
* Translation cost is roughly linear in the number of active languages, through
* ONE NLLB process — measured, five languages in 1.9 s. So the single most
* useful fact about a machine that is falling behind is how many languages it
* is carrying, and that number was computed one function away and never shown.
* 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.
*/
function loadNote() {
const n = activeLanguages().length;
if (n < 2) return '';
return ' Carrying ' + n + ' languages' +
(config.allowAllLanguages ? ', and listeners may add more' : '') + '.';
}
function healthVerdict(connected, stale) {
const es = state.engineStatus || {};
if (!connected) return { level: 'red', detail: 'The translation app is not connected.' };
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)
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)
return { level: 'amber',
detail: 'Running behind — idle languages may be trimmed to catch up.' + loadNote() };
if (es.cloud && es.cloud.parked)
return { level: 'amber', detail: 'Natural voices are down — the built-in voices have taken over.' };
/* A fallback in the last minute means one language just changed voice
mid-service; a drop in the last minute means the voice skipped a sentence
the captions kept. Both were invisible before. */
if (es.cloud && es.cloud.fallbackAt && Date.now() - es.cloud.fallbackAt < 60000)
return { level: 'amber', detail: 'A natural voice fell back to the built-in voice a moment ago.' };
const audio = liveAudio.stats();
const recent = Object.keys(audio).reduce((n, l) => n + (audio[l].recent || 0), 0);
if (recent > 0)
return { level: 'amber', detail: 'The voice fell behind and skipped ' + recent + ' sentence group(s) in the last minute.' };
/* The voice can sit several seconds behind its own captions with nothing
dropped — say so, or "healthy" hides a 9 s late voice. */
const bl = liveAudio.backlog();
const worst = Object.keys(bl).reduce((m, l) => Math.max(m, bl[l] || 0), 0);
const voiceNote = worst > 3 ? ' Voice about ' + Math.round(worst) + 's behind the captions.' : '';
return { level: worst > 7 ? 'amber' : 'green',
detail: (worst > 7 ? 'Voice running late — ' : 'Healthy — ') + 'about ' +
Math.max(1, Math.round((es.lagMs || 1500) / 1000)) + 's behind the speaker.' + voiceNote };
}
function statusSnapshot() {
const connected = !!(state.engine && state.engine.readyState === 1);
const stale = connected && state.engineStatusAt > 0 &&
(Date.now() - state.engineStatusAt) > ENGINE_STALE_MS;
return {
health: healthVerdict(connected, stale),
live: state.live,
hold: state.hold,
engineConnected: connected,
engineStale: stale,
engineStatus: state.engineStatus,
listeners: state.listeners.size,
listenersByLanguage: listenerCounts(),
listenerDetail: listenerDetail(),
activeLanguages: activeLanguages(),
startedAt: state.startedAt,
/* What the voice pipeline is doing to the words, per language. */
audio: liveAudio.stats(),
backlog: liveAudio.backlog(),
droppedTotal: state_dropTotal,
serviceName: config.serviceName,
sourceLanguage: config.sourceLanguage
};
}
/*
* How many people are reading in each language, highest first.
*
* A bare total answers "is anyone out there"; the breakdown answers the
* question a church actually has — which languages are worth keeping, and
* whether the Haitian Creole nobody asked for is being used after all.
*/
/* A phone's address on the church network, which is as close to a device
identity as we get — and enough to tell two tabs on one iPad apart from two
different people. */
function listenerIp(req) {
const fwd = req.headers['x-forwarded-for'];
if (fwd) return String(fwd).split(',')[0].trim();
const ip = (req.socket && req.socket.remoteAddress) || '';
return ip.replace(/^::ffff:/, '');
}
/* Enough of the user-agent to recognise your own device in a list, and nothing
more. Nobody needs the full string. */
function describeDevice(ua) {
const s = String(ua || '');
let os = 'Unknown';
if (/iPad/.test(s)) os = 'iPad';
else if (/iPhone/.test(s)) os = 'iPhone';
else if (/Android/.test(s)) os = 'Android';
else if (/Macintosh|Mac OS X/.test(s)) os = 'Mac';
else if (/Windows/.test(s)) os = 'Windows';
else if (/Linux/.test(s)) os = 'Linux';
let browser = '';
if (/CriOS|Chrome\//.test(s) && !/Edg|OPR/.test(s)) browser = 'Chrome';
else if (/EdgiOS|Edg\//.test(s)) browser = 'Edge';
else if (/FxiOS|Firefox/.test(s)) browser = 'Firefox';
else if (/Safari/.test(s)) browser = 'Safari';
return browser ? os + ' · ' + browser : os;
}
function listenerCounts() {
const counts = {};
for (const meta of state.listeners.values()) {
const lang = meta.lang || config.sourceLanguage;
counts[lang] = (counts[lang] || 0) + 1;
}
return Object.keys(counts)
.sort((a, b) => counts[b] - counts[a])
.map((code) => ({ code, count: counts[code] }));
}
/*
* Which alternate voices are actually being LISTENED to right now.
*
* This is the whole reason the feature is affordable. The engine synthesises
* the operator's default voice always (the hardware outputs and every listener
* who never touched the chooser need it) plus exactly the alternates that
* appear here — so an alternate nobody selected costs nothing at all, and the
* cost scales with how many people actually switched rather than with how many
* voices the operator opened up.
*/
function voiceDemand() {
const want = {};
for (const meta of state.listeners.values()) {
if (!meta.voice) continue; // on the default
const lang = meta.lang || config.sourceLanguage;
const allowed = state.voiceOptions[lang] || [];
if (!allowed.some((r) => r.key === meta.voice)) continue; // no longer offered
if (!want[lang]) want[lang] = [];
if (want[lang].indexOf(meta.voice) === -1) want[lang].push(meta.voice);
}
return want;
}
/* What THIS listener may choose for their language, for the page's chooser. */
function voiceOptionsFor(lang) {
return (state.voiceOptions[lang] || []).slice();
}
/* Every connected listener, so a forgotten tab can be found and closed. */
function listenerDetail() {
const out = [];
for (const meta of state.listeners.values()) {
out.push({
lang: meta.lang,
ip: meta.ip,
device: meta.device,
speaking: !!meta.speaking,
remote: !!meta.proxied,
minutes: Math.floor((Date.now() - meta.joinedAt) / 60000)
});
}
return out.sort((a, b) => (a.ip || '').localeCompare(b.ip || ''));
}
function broadcastState() {
const frame = JSON.stringify({ t: 'state', live: state.live, listeners: state.listeners.size });
for (const ws of state.listeners.keys()) {
if (ws.readyState === 1) { try { ws.send(frame); } catch (_) {} }
}
broadcastOperators();
}
function broadcastOperators() {
/*
* The config rides along with the status. Both operator surfaces build their
* next save from a cached copy, and that cache used to be taken once when the
* pane opened and never refreshed — so whichever surface saved second silently
* deleted what the first had just added. Pushing the truth on every change
* keeps the caches honest.
*/
const frame = JSON.stringify({ t: 'status', status: statusSnapshot(), config });
for (const ws of state.operators) {
if (ws.readyState === 1) { try { ws.send(frame); } catch (_) {} }
}
}
function setLive(live, who) {
live = !!live;
if (state.live === live) return false;
state.live = live;
state.startedAt = live ? Date.now() : null;
if (live) {
state.partial = '';
state_dropTotal = 0;
/* Never begin a service muted. A hold left on from last week is silent,
invisible from the back of the room, and indistinguishable from the
whole thing being broken. */
state.hold = false;
sendToEngine({ t: 'start', langs: activeLanguages(), source: config.sourceLanguage, glossary: config.glossary });
} else {
sendToEngine({ t: 'stop' });
cancelDelayed();
}
log((live ? 'START' : 'STOP') + ' translation (' + (who || 'unknown') + ')');
broadcastState();
notifyCompanion();
return true;
}
/* Same clamp the settings page uses (0-120s), so the desk cannot put the
service into a state the UI would refuse to show. */
function setStreamDelay(sec, who) {
const n = Math.max(0, Math.min(120, Math.round(Number(sec) || 0)));
const was = Number(config.streamDelaySec) || 0;
if (was === n) return false;
config.streamDelaySec = n;
/* Put it back if it could not be written, rather than run a service on a
value that will be gone at the next restart and that no screen agrees on. */
if (!saveConfig(config)) {
config.streamDelaySec = was;
log('stream delay NOT changed — config could not be written');
return false;
}
/* The window has to still hold what a delayed listener is about to ask for. */
liveAudio.reserve(n + 5);
log('stream delay ' + was + 's -> ' + n + 's (' + (who || 'unknown') + ')');
broadcastState();
notifyCompanion();
return true;
}
function setHold(hold, who) {
hold = !!hold;
if (state.hold === hold) return false;
state.hold = hold;
log((hold ? 'HOLD' : 'RESUME') + ' listener output (' + (who || 'unknown') + ')');
broadcastState();
notifyCompanion();
return true;
}
function log(msg) {
console.log('[' + new Date().toISOString() + '] ' + msg);
}
/*
* Tell Companion what state we are in, by writing one of its custom variables.
*
* Pushing beats polling here: the Stream Deck button turns red the instant
* translation actually starts, rather than up to a poll interval later, and it
* stays correct even when translation was started from the operator page or
* stopped by the engine dropping out.
*/
function companionPush(name, value) {
const base = (config.companionUrl || '').replace(/\/+$/, '');
if (!base) return;
const url = base + '/api/custom-variable/' + encodeURIComponent(name) +
'/value?value=' + encodeURIComponent(value);
try {
const mod = url.startsWith('https:') ? require('https') : require('http');
const req = mod.request(url, { method: 'POST', timeout: 2000 }, (res) => res.resume());
/* Companion being down must never hold up a service. */
req.on('error', () => {});
req.on('timeout', () => req.destroy());
req.end();
} catch (_) {}
}
/*
* The base variable keeps its exact old meaning — live / ready / offline —
* because existing buttons compare against those three strings and a held
* service is still live. Hold, delay and the listener count go in variables of
* their own, so a button can show "12 listening" or turn amber while held
* without anything that already works changing behaviour.
*/
let lastCompanion = '';
function notifyCompanion() {
if (!config.companionUrl) return;
const name = config.companionVariable || 'translate_state';
const value = state.live ? 'live' : (state.engine ? 'ready' : 'offline');
const hold = state.hold ? 'held' : 'open';
const delay = String(Math.max(0, Number(config.streamDelaySec) || 0));
const listeners = String(state.listeners.size);
const es = state.engineStatus || {};
const c = es.cloud || {};
/* natural = the chosen voices; builtin = parked on Piper; mixed = a recent fallback */
const voicesState = c.parked ? 'builtin'
: (c.fallbackAt && Date.now() - c.fallbackAt < 60000) ? 'mixed' : 'natural';
const dropped = String(state_dropTotal);
/* Listener joins and leaves call this too, and a full service of arrivals
would otherwise be a steady drip of pointless HTTP. */
const sig = [value, hold, delay, listeners, voicesState, dropped].join('|');
if (sig === lastCompanion) return;
lastCompanion = sig;
companionPush(name, value);
companionPush(name + '_hold', hold);
companionPush(name + '_delay', delay);
companionPush(name + '_listeners', listeners);
/* Additive: existing buttons see nothing new. */
companionPush(name + '_voices', voicesState);
companionPush(name + '_dropped', dropped);
}
// ---------------------------------------------------------------- http
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json'
};
function sendJSON(res, code, obj) {
const body = JSON.stringify(obj);
res.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store'
});
res.end(body);
}
function readBody(req, limit = 256 * 1024) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (c) => {
size += c.length;
if (size > limit) { reject(new Error('body too large')); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw) return resolve({});
try { resolve(JSON.parse(raw)); } catch (e) { reject(e); }
});
req.on('error', reject);
});
}
/* Constant-time compare so a token can't be recovered by timing the response. */
function secretEquals(a, b) {
const ba = Buffer.from(String(a || ''));
const bb = Buffer.from(String(b || ''));
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
/*
* ───────────────────────── failed-secret throttle ─────────────────────────
*
* The operator PIN is six digits — 900,000 possibilities — and until now there
* was no rate limiting anywhere in this server. On a LAN that is fine. Reachable
* from the internet it is minutes of scripted guessing, and whoever gets in can
* stop translation in the middle of a sermon.
*
* Two counters, because they defend against different things:
*
* • PER-KEY, exponential. Stops one address grinding away, and is the one an
* honest operator with a fat thumb will ever meet (five tries, then a
* couple of seconds).
*
* • GLOBAL, but it only counts failures that arrived through the tunnel and
* only ever locks requests that arrived through the tunnel. Behind a proxy
* every attacker shares one address and `x-forwarded-for` is theirs to
* write, so a per-key limit alone is trivially evaded by rotating it. The
* asymmetry is deliberate: the global lock must not be a way for someone on
* the internet to lock the operator out of a desk they are standing at.
*/
const AUTH_TRIES = 5;
const AUTH_BASE_MS = 2000;
const AUTH_CAP_MS = 10 * 60 * 1000;
const AUTH_GLOBAL_TRIES = 20;
/* Quiet for this long and the internet-side counter forgets. */
const AUTH_DECAY_MS = 15 * 60 * 1000;
const authFails = new Map(); // key -> { n, until }
let globalFails = 0;
let globalUntil = 0;
let globalLastFail = 0;
/*
* The global counter DECAYS with time; it is not reset by a success.
*
* Resetting it on success was wrong in this building: a Companion button polls
* /api/status.txt with a valid token every second, so every one of those
* successes would have wiped an attacker's tally a second after it was made
* and the global lock could never engage at all.
*/
function decayGlobal() {
if (globalFails && Date.now() - globalLastFail > AUTH_DECAY_MS) {
globalFails = 0;
globalUntil = 0;
}
}
function authKey(req) { return listenerIp(req) || 'unknown'; }
function viaTunnel(req) { return !!req.headers['x-forwarded-for']; }
function backoff(n, tries) {
return Math.min(AUTH_CAP_MS, AUTH_BASE_MS * Math.pow(2, Math.max(0, n - tries)));
}
/* Seconds remaining, or 0 when not locked. */
function authLocked(req) {
decayGlobal();
const now = Date.now();
if (viaTunnel(req) && now < globalUntil) return Math.ceil((globalUntil - now) / 1000);
const e = authFails.get(authKey(req));
if (e && now < e.until) return Math.ceil((e.until - now) / 1000);
return 0;
}
function authFailed(req, what) {
const now = Date.now();
const key = authKey(req);
const e = authFails.get(key) || { n: 0, until: 0 };
e.n += 1;
if (e.n >= AUTH_TRIES) e.until = now + backoff(e.n, AUTH_TRIES);
authFails.set(key, e);
if (viaTunnel(req)) {
decayGlobal();
globalFails += 1;
globalLastFail = now;
if (globalFails >= AUTH_GLOBAL_TRIES) {
globalUntil = now + backoff(globalFails, AUTH_GLOBAL_TRIES);
log('WARNING: ' + globalFails + ' failed ' + what + ' attempts from the internet — ' +
'remote attempts locked for ' + Math.ceil((globalUntil - now) / 1000) + 's');
}
}
/* Unbounded growth is its own denial of service. */
if (authFails.size > 500) {
for (const [k, v] of authFails) { if (now > v.until && v.n < AUTH_TRIES) authFails.delete(k); }
if (authFails.size > 500) authFails.clear();
}
}
function authPassed(req) {
/* Only this address. The internet-side tally is left to decay on its own —
see decayGlobal for why a success must not clear it. */
authFails.delete(authKey(req));
}
function controlAuthorized(req, url) {
if (authLocked(req)) return false;
const given = url.searchParams.get('key') || req.headers['x-control-token'] || '';
const ok = secretEquals(given, config.controlToken);
/* Only a WRONG answer counts. An absent one is a probe or a page that has
not been told the secret yet, and counting those would lock the operator
out on the app's own first request. */
if (ok) authPassed(req);
else if (given) authFailed(req, 'control-token');
return ok;
}
/*
* The desk is a local instrument. Full stop — there is no setting to turn this
* off, on the owner's instruction.
*
* Anything that arrived through the tunnel is refused the operator page and
* everything behind it, whatever PIN it presents. That is what makes a short,
* memorable four-digit PIN the right choice rather than a liability: it is only
* ever offered to someone already inside the building.
*
* Deliberately NOT relying on the tunnel's ingress rules. Those should block
* /operator too, but a rule in a config file on someone else's service is not
* a thing the safety of a live service should rest on — and an ingress rule
* that was right last year is exactly the sort of thing that quietly stops
* being right.
*/
function operatorReachable(req) {
return !viaTunnel(req);
}
function operatorAuthorized(req, url) {
if (!operatorReachable(req)) return false;
if (authLocked(req)) return false;
const given = url.searchParams.get('pin') || req.headers['x-operator-pin'] || '';
const ok = secretEquals(given, config.operatorPin);
if (ok) authPassed(req);
else if (given) authFailed(req, 'operator-PIN');
return ok;
}
function publicBase(req) {
if (config.publicUrl) return config.publicUrl.replace(/\/+$/, '');
const proto = (req.headers['x-forwarded-proto'] || 'http').split(',')[0].trim();
const host = req.headers['x-forwarded-host'] || req.headers.host || ('localhost:' + PORT);
return proto + '://' + host;
}
/*
* A fingerprint over the front-end files, recomputed each boot — so a deploy
* changes it and nothing else does. Asset URLs in the HTML get ?v=BUILD
* appended, which lets us cache them hard while still shipping updates the
* instant the container restarts. Without this, a phone that loaded the page
* on Sunday keeps running last week's JavaScript.
*/
const BUILD = (function () {
try {
const files = fs.readdirSync(PUBLIC_DIR)
.filter((f) => /\.(js|css|html|webmanifest|svg)$/.test(f))
.sort();
const sig = files.map((f) => {
const st = fs.statSync(path.join(PUBLIC_DIR, f));
return f + ':' + st.size + ':' + Math.floor(st.mtimeMs);
}).join('|');
return crypto.createHash('sha1').update(sig).digest('hex').slice(0, 10);
} catch (_) {
return 'dev';
}
})();
const ASSET_REF = /\b(href|src)="(\/[^"?]+\.(?:css|js))"/g;
function serveStatic(req, res, urlPath, url) {
const rel = urlPath === '/' ? '/index.html' : urlPath;
/* Resolve, then confirm the result is still inside PUBLIC_DIR. Guards
against ../ walking out of the web root. */
const full = path.resolve(PUBLIC_DIR, '.' + rel);
if (full !== PUBLIC_DIR && !full.startsWith(PUBLIC_DIR + path.sep)) {
res.writeHead(403).end('Forbidden');
return;
}
const ext = path.extname(full).toLowerCase();
fs.readFile(full, (err, buf) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found'); return; }
if (ext === '.html') {
/* HTML is never cached, and carries the current build stamp into every
asset it references. */
const html = buf.toString('utf8').replace(ASSET_REF, '$1="$2?v=' + BUILD + '"');
const out = Buffer.from(html, 'utf8');
res.writeHead(200, {
'Content-Type': MIME['.html'],
'Content-Length': out.length,
'Cache-Control': 'no-store'
});
res.end(out);
return;
}
/* A stamped asset can never go stale — the URL changes when the file does.
An unstamped one might, so it revalidates. */
const stamped = !!(url && url.searchParams.get('v'));
res.writeHead(200, {
'Content-Type': MIME[ext] || 'application/octet-stream',
'Content-Length': buf.length,
'Cache-Control': stamped ? 'public, max-age=31536000, immutable' : 'no-cache'
});
res.end(buf);
});
}
const server = http.createServer(async (req, res) => {
let url;
try {
url = new URL(req.url, 'http://' + (req.headers.host || 'localhost'));
} catch (_) {
res.writeHead(400).end('Bad request');
return;
}
const p = url.pathname;
/*
* The app window and this server are different origins — the page is served
* from the app's own port and the API lives here — so operator mode's fetches
* are cross-origin, and the X-Operator-Pin header makes them preflighted.
* Without these the browser refuses before the request is ever sent, which is
* what left operator mode showing "None" for everything.
*
* Restricted to loopback on purpose: this API can change what the whole
* congregation sees, and the PIN is the only other thing guarding it. A page
* on the open internet has no business reaching it, whatever it knows.
*/
const origin = req.headers.origin;
if (origin && /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Operator-Pin, X-Control-Token');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Max-Age', '600');
}
if (req.method === 'OPTIONS') { res.writeHead(origin ? 204 : 405).end(); 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 -------------------------------
// GET or POST, token in ?key= or the X-Control-Token header.
if (p.startsWith('/api/control/')) {
if (!controlAuthorized(req, url)) { sendJSON(res, 401, { ok: false, error: 'bad key' }); return; }
const action = p.slice('/api/control/'.length).replace(/\/+$/, '');
let changed = false;
if (action === 'start') changed = setLive(true, 'companion');
else if (action === 'stop') changed = setLive(false, 'companion');
else if (action === 'toggle') changed = setLive(!state.live, 'companion');
/* The cough button — see state.hold. */
else if (action === 'hold') changed = setHold(true, 'companion');
else if (action === 'resume') changed = setHold(false, 'companion');
else if (action === 'hold-toggle') changed = setHold(!state.hold, 'companion');
/*
* Stream delay from the desk. Lining captions up with a livestream is done
* by WATCHING the stream and nudging until they match, which is a poor fit
* for a settings page on another screen — and it is the one number that
* genuinely needs changing mid-service, because the stream's own latency
* drifts. ?by= sets the step, so one button can be ±1s and another ±5s.
*/
else if (action === 'delay-up' || action === 'delay-down') {
const by = Math.abs(Number(url.searchParams.get('by')) || 1);
const step = action === 'delay-up' ? by : -by;
changed = setStreamDelay((Number(config.streamDelaySec) || 0) + step, 'companion');
} else if (action === 'delay-set') {
/* Reject junk rather than reading it as zero. `s=` misspelled, or a
Companion variable that has not been filled in yet, would otherwise
turn the stream delay OFF mid-service and look like it worked.
delay-off exists for when zero is what you meant. */
const raw = url.searchParams.get('s');
if (raw === null || raw.trim() === '' || !isFinite(Number(raw))) {
sendJSON(res, 400, { ok: false, error: 'delay-set needs ?s=<seconds>' });
return;
}
changed = setStreamDelay(Number(raw), 'companion');
} else if (action === 'delay-off') {
changed = setStreamDelay(0, 'companion');
}
else if (action === 'status') { /* read-only */ }
else { sendJSON(res, 404, { ok: false, error: 'unknown action' }); return; }
sendJSON(res, 200, Object.assign({ ok: true, changed }, statusSnapshot()));
return;
}
/* One word, no JSON parsing required — for Companion modules and any
other control surface that can only match on plain text. */
if (p === '/api/status.txt') {
if (!controlAuthorized(req, url)) { res.writeHead(401).end('unauthorized'); return; }
const body = state.live ? 'live' : (state.engine ? 'ready' : 'offline');
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(body);
return;
}
/*
* Hold and delay as one word each, alongside /api/status.txt.
*
* status.txt deliberately still answers 'live' while held — a held service
* IS live, and the installer refuses to replace the app when it reads 'live'.
* Folding hold into that string would have quietly disarmed that guard.
*/
if (p === '/api/hold.txt' || p === '/api/delay.txt') {
if (!controlAuthorized(req, url)) { res.writeHead(401).end('unauthorized'); return; }
const body = p === '/api/hold.txt'
? (state.hold ? 'held' : 'open')
: String(Math.max(0, Number(config.streamDelaySec) || 0));
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(body);
return;
}
if (p === '/api/status') {
if (!controlAuthorized(req, url) && !operatorAuthorized(req, url)) {
sendJSON(res, 401, { ok: false, error: 'unauthorized' }); return;
}
sendJSON(res, 200, Object.assign({ ok: true }, statusSnapshot()));
return;
}
// --- what the listener page needs to render the picker -------------
if (p === '/api/languages') {
sendJSON(res, 200, {
ok: true,
serviceName: config.serviceName,
welcome: config.welcome,
sourceLanguage: config.sourceLanguage,
featured: config.featured,
allowAll: config.allowAllLanguages !== false,
live: state.live
});
return;
}
// --- operator config ------------------------------------------------
if (p === '/api/config') {
const wait = authLocked(req);
if (wait) {
res.setHeader('Retry-After', String(wait));
sendJSON(res, 429, { ok: false, error: 'too many failed attempts — try again in ' + wait + 's' });
return;
}
if (!operatorAuthorized(req, url)) { sendJSON(res, 401, { ok: false, error: 'bad pin' }); return; }
if (req.method === 'GET') {
sendJSON(res, 200, { ok: true, config, status: statusSnapshot(), publicBase: publicBase(req) });
return;
}
if (req.method === 'POST') {
let body;
try { body = await readBody(req); }
catch (e) { sendJSON(res, 400, { ok: false, error: String(e.message) }); return; }
/* A body that is null, a bare string or an array is valid JSON, and the
`in` operator below throws on all of them — which took the whole
process down rather than returning 400. */
if (!body || typeof body !== 'object' || Array.isArray(body)) {
sendJSON(res, 400, { ok: false, error: 'expected a JSON object' }); return;
}
/*
* Validate EVERYTHING before assigning anything. Previously a wrong-typed
* value was written to disk first and only crashed afterwards — so the
* bad value was durable, and it then killed the server on every boot.
* Nothing here mutates config until every field has passed.
*/
const isCodeList = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string' && /^[a-z]{3}_[A-Za-z]+$/.test(x));
const staged = {};
const allowed = ['serviceName', 'welcome', 'sourceLanguage', 'featured',
'pinned', 'allowAllLanguages', 'publicUrl', 'glossary', 'operatorPin',
'companionUrl', 'companionVariable', 'streamDelaySec'];
for (const k of allowed) {
if (!(k in body)) continue;
const v = body[k];
if (k === 'operatorPin') {
const pin = String(v || '').trim();
/*
* Four characters, by the owner's decision, and that is defensible
* for the reason below rather than in spite of it: the operator page
* is refused outright to anything arriving through the tunnel (see
* operatorRemote). A four-digit PIN therefore only ever faces someone
* already inside the building, and a memorable PIN that the person at
* the desk can actually type beats a strong one taped to the console.
*/
if (pin.length < 4) { sendJSON(res, 400, { ok: false, error: 'PIN must be at least 4 characters' }); return; }
staged.operatorPin = pin;
} else if (k === 'featured' || k === 'pinned') {
if (!isCodeList(v)) { sendJSON(res, 400, { ok: false, error: k + ' must be a list of language codes' }); return; }
/* De-duplicate: the same code twice is always a client bug, and it
renders as a doubled chip the operator cannot remove cleanly. */
staged[k] = Array.from(new Set(v));
} else if (k === 'glossary') {
if (!v || typeof v !== 'object' || Array.isArray(v)) { sendJSON(res, 400, { ok: false, error: 'glossary must be an object' }); return; }
const fixes = v.fixes === undefined ? (config.glossary.fixes || []) : v.fixes;
const overrides = v.overrides === undefined ? (config.glossary.overrides || {}) : v.overrides;
if (!Array.isArray(fixes) || !fixes.every((x) => typeof x === 'string')) { sendJSON(res, 400, { ok: false, error: 'glossary.fixes must be a list of strings' }); return; }
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) { sendJSON(res, 400, { ok: false, error: 'glossary.overrides must be an object' }); return; }
staged.glossary = { fixes, overrides };
} else if (k === 'allowAllLanguages') {
staged[k] = !!v;
} else if (k === 'streamDelaySec') {
const n = Number(v);
if (!Number.isFinite(n) || n < 0 || n > 120) {
sendJSON(res, 400, { ok: false, error: 'streamDelaySec must be 0-120 seconds' }); return;
}
staged.streamDelaySec = Math.round(n);
} else {
if (typeof v !== 'string') { sendJSON(res, 400, { ok: false, error: k + ' must be text' }); return; }
staged[k] = v;
}
}
Object.assign(config, staged);
/* The settings page writes streamDelaySec directly rather than through
setStreamDelay(), so the audio window has to be kept in step here too
— otherwise a delay set from this page leaves remote listeners asking
for segments that have already rotated out. */
if ('streamDelaySec' in staged) liveAudio.reserve(staged.streamDelaySec + 5);
if (!saveConfig(config)) {
sendJSON(res, 500, { ok: false, error: 'the settings could not be written to disk — nothing was saved' });
return;
}
pushLanguagesToEngine();
broadcastOperators();
sendJSON(res, 200, { ok: true, config });
return;
}
sendJSON(res, 405, { ok: false, error: 'method not allowed' });
return;
}
/*
* --- spoken audio -------------------------------------------------
*
* One clip per sentence per language, produced by the voice process on this
* Mac. Everyone reading the same language fetches the same file, so a room of
* a hundred people costs one synthesis and a hundred cheap cached GETs.
*
* Immutable: the name carries a sequence number and the bytes never change,
* so a phone that fetches it twice pays once.
*/
/*
* Live HLS, one playlist per language. The media stack fetches these on its
* own schedule, with no JavaScript involved — which is exactly why it keeps
* working after iOS suspends the page's scripts.
*/
if (p.indexOf('/live/') === 0) {
const rest = p.slice('/live/'.length);
const m3u8 = rest.match(/^([a-z]{3}_[A-Za-z]+)\.m3u8$/);
if (m3u8) {
/*
* The same signal the WebSocket uses: a request that arrived with
* x-forwarded-for came through the tunnel, so it is on the stream delay
* and gets a playlist that starts that far back in the window.
*/
const back = req.headers['x-forwarded-for'] ? streamDelayMs() / 1000 : 0;
const body = liveAudio.playlist(m3u8[1], back);
if (!body) { res.writeHead(404).end(); return; }
res.writeHead(200, {
'Content-Type': 'application/vnd.apple.mpegurl',
/* The playlist changes every second; a cached one strands the player
at a live edge that has already moved on. */
'Cache-Control': 'no-store',
'Content-Length': Buffer.byteLength(body)
});
res.end(body);
return;
}
const seg = rest.match(/^([a-z]{3}_[A-Za-z]+)\/(\d+)\.aac$/);
if (seg) {
const body = liveAudio.segment(seg[1], Number(seg[2]));
if (!body) { res.writeHead(404).end(); return; }
res.writeHead(200, {
'Content-Type': 'audio/aac',
/* Segment bytes never change once written, and the sequence number is
in the name, so a phone that refetches one pays nothing. */
'Cache-Control': 'public, max-age=60, immutable',
'Content-Length': body.length
});
res.end(body);
return;
}
res.writeHead(404).end();
return;
}
if (p.indexOf('/audio/') === 0) {
const name = path.basename(decodeURIComponent(p.slice('/audio/'.length)));
/*
* basename() already strips any traversal, but the name is generated by us
* and matching the shape it takes costs nothing.
*
* ANY single-letter prefix, deliberately. tts-service's clipPrefix() stamps
* the engine that spoke the clip -- c cloud, k Kokoro, p Piper, q local --
* and this pattern was written when c was the only one there was. So every
* clip a natural voice or a Piper fallback produced 404'd here, and the
* hardware outputs went silent while the phones kept working: the phone
* stream reads CLIP_DIR off disk (see liveAudio.push) and never passes
* through this route at all. The prefix letter guards nothing -- basename()
* does -- so match the SHAPE, not the roster, and the next engine added
* cannot break this again.
*/
if (!/^[a-z]\d+-[a-z]{3}_[A-Za-z]+\.aac$/.test(name)) { res.writeHead(404).end(); return; }
const full = path.join(CLIP_DIR, name);
fs.stat(full, (err, st) => {
if (err) { res.writeHead(404).end(); return; }
/*
* NEVER CACHED, deliberately, and this is the second lock on that door.
*
* These files live about five minutes (sweepClips) and every URL is
* fetched once. `immutable, max-age=3600` promised the opposite: that
* this exact URL would hold this exact audio for an hour. When the clip
* counter restarted at 1 on every app launch, that promise turned into
* the bug — the renderer and every listener's phone replayed the PREVIOUS
* service's sentence under the reused name, for an hour, with no way to
* tell from the app that anything was wrong.
*
* Names are unique per run now, so this header alone would be harmless.
* It is set to no-store anyway: nothing here benefits from caching, and
* a cache is only ever as correct as the naming scheme feeding it.
*/
res.writeHead(200, {
'Content-Type': 'audio/aac',
'Content-Length': st.size,
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
'Accept-Ranges': 'bytes'
});
fs.createReadStream(full).pipe(res);
});
return;
}
// --- QR code for the bulletin / the screens -------------------------
if (p === '/qr.svg') {
const target = url.searchParams.get('url') || publicBase(req) + '/';
try {
const svg = await QRCode.toString(target, {
type: 'svg', errorCorrectionLevel: 'M', margin: 1, width: 512
});
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=300' });
res.end(svg);
} catch (e) {
res.writeHead(500).end('QR failed');
}
return;
}
if (p === '/operator' || p === '/operator/') {
/* Not even the HTML. There is nothing useful on it from out there, and a
login box that exists is a login box someone will hammer. */
if (!operatorReachable(req)) { res.writeHead(404).end('Not found'); return; }
serveStatic(req, res, '/operator.html', url);
return;
}
if (p === '/poster' || p === '/poster/') { serveStatic(req, res, '/poster.html', url); return; }
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405).end('Method not allowed'); return; }
serveStatic(req, res, p, url);
});
// ---------------------------------------------------------------- websocket
const wss = new WebSocketServer({ server, path: '/ws' });
function send(ws, obj) {
if (ws.readyState === 1) { try { ws.send(JSON.stringify(obj)); } catch (_) {} }
}
/* Scrollback is stored with every translation we had at the time. A listener
who picks a language mid-service gets the source text for lines that were
spoken before their language was active — better than a blank screen. */
/*
* Late joiner's backlog. The delay cutoff belongs only to a listener who IS
* delayed — handing the room a backlog that stops twenty seconds ago would
* make someone who just opened the page appear to be permanently behind, in
* the one place where they can hear the real thing.
*/
function scrollbackFor(lang, proxied) {
const holdMs = proxied ? streamDelayMs() : 0;
const cutoff = Date.now() - holdMs;
return state.scrollback.filter((row) => row.ts <= cutoff && !row.held).map((row) => ({
id: row.id,
ts: row.ts,
text: (row.tr && row.tr[lang]) || row.src,
translated: lang === config.sourceLanguage || !!(row.tr && row.tr[lang]),
original: row.src
}));
}
/*
* Translations for a row now trickle in per language, so writing the archive at
* final time would freeze it half-empty. Each row is written once, ~20s after
* its last change — long past any translation's arrival, far before anyone
* reads the file.
*/
const transcriptTimers = new Map();
function scheduleTranscript(row) {
const old = transcriptTimers.get(row.cid);
if (old) clearTimeout(old);
const t = setTimeout(() => {
transcriptTimers.delete(row.cid);
appendTranscript(row);
}, 20000);
if (t.unref) t.unref();
transcriptTimers.set(row.cid, t);
}
function appendTranscript(row) {
try {
const dir = path.join(DATA_DIR, 'transcripts');
fs.mkdirSync(dir, { recursive: true });
const day = new Date(row.ts).toISOString().slice(0, 10);
fs.appendFile(path.join(dir, day + '.jsonl'), JSON.stringify(row) + '\n', () => {});
} catch (_) { /* transcript is a convenience; never let it break the service */ }
}
function broadcastFinal(row) {
delayed((who) => broadcastFinalNow(row, who));
}
function broadcastFinalNow(row, who) {
/* Serialize once per language rather than once per listener — a full room on
four languages is four JSON.stringify calls, not four hundred. */
const cache = new Map();
for (const [ws, meta] of state.listeners) {
if (ws.readyState !== 1) continue;
if (!who(meta)) continue;
const lang = meta.lang || config.sourceLanguage;
let frame = cache.get(lang);
if (!frame) {
const text = (row.tr && row.tr[lang]) || row.src;
frame = JSON.stringify({
t: 'caption',
id: row.id,
ts: row.ts,
text,
/* A source-language reader is reading the words as spoken — that is
not "untranslated", and must not be styled as a failure. */
translated: lang === config.sourceLanguage || !!(row.tr && row.tr[lang]),
original: row.src,
/* The clip this listener's language should play, if the Mac could speak it. */
audio: (row.audio && row.audio[lang]) || null
});
cache.set(lang, frame);
}
try { ws.send(frame); } catch (_) {}
}
}
function broadcastPartial(text, tr) {
delayed((who) => broadcastPartialNow(text, tr, who));
}
function broadcastPartialNow(text, tr, who) {
const cache = new Map();
for (const [ws, meta] of state.listeners) {
if (ws.readyState !== 1) continue;
if (!who(meta)) continue;
const lang = meta.lang || config.sourceLanguage;
let frame = cache.get(lang);
if (!frame) {
const isSource = lang === config.sourceLanguage;
const shown = isSource ? text : (tr && tr[lang]) || '';
/* No half-translated text: if this language has no partial yet, the page
shows a typing indicator instead of stale or wrong-language words. */
frame = JSON.stringify({ t: 'partial', text: shown, pending: !shown, original: text });
cache.set(lang, frame);
}
try { ws.send(frame); } catch (_) {}
}
}
wss.on('connection', (ws, req) => {
let url;
try { url = new URL(req.url, 'http://localhost'); }
catch (_) { ws.close(1008, 'bad url'); return; }
const role = url.searchParams.get('role') || 'listener';
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
// ---- the Mac engine ------------------------------------------------
if (role === 'engine') {
if (!secretEquals(url.searchParams.get('key') || '', config.engineKey)) {
log('engine rejected: bad key from ' + (req.socket.remoteAddress || '?'));
ws.close(1008, 'bad key');
return;
}
if (state.engine && state.engine !== ws && state.engine.readyState === 1) {
/* Second engine wins; the stale one is usually a laptop someone forgot
to quit, and two engines would double every caption. */
log('engine replaced (a previous engine was still connected)');
try { state.engine.close(1000, 'replaced'); } catch (_) {}
}
state.engine = ws;
/* Back inside the grace window — whatever closed the last socket, the
service never actually lost its engine. */
if (enginePause) { clearTimeout(enginePause); enginePause = null; log('engine back before the grace window closed'); }
/*
* The engine window IS an operator surface — its Operator pane shows the
* same chips and health as the browser page. Without this it never
* received config/status broadcasts, so its cached copy went stale and a
* save from it could clobber an edit made from the browser page.
*/
state.operators.add(ws);
log('engine connected from ' + (req.socket.remoteAddress || '?'));
notifyCompanion();
send(ws, {
t: 'welcome',
live: state.live,
langs: activeLanguages(),
source: config.sourceLanguage,
glossary: config.glossary
});
broadcastOperators();
ws.on('message', (raw) => {
let m;
try { m = JSON.parse(raw); } catch (_) { return; }
if (m.t === 'partial') {
if (!state.live) return;
state.partial = String(m.text || '');
if (m.clear) {
/* The engine dropped what the preview was showing (a filler that
became an invented line). An empty partial normally means "still
typing"; this one means "nothing was said" and hides the line. */
delayed((who) => {
const frame = JSON.stringify({ t: 'partial', text: '', pending: false, original: '' });
for (const [ws, meta] of state.listeners) {
if (ws.readyState === 1 && who(meta)) { try { ws.send(frame); } catch (_) {} }
}
});
return;
}
broadcastPartial(state.partial, m.tr || null);
} else if (m.t === 'final') {
if (!state.live) {
/*
* The engine believes it is translating and we do not. Dropping the
* sentence is right — we are stopped — but doing it silently once hid
* a total outage: the engine transcribed a whole service into a
* server that discarded every word, while both screens looked fine.
* Say so, and tell the engine to stand down so it stops burning GPU.
*/
state.droppedWhileStopped = (state.droppedWhileStopped || 0) + 1;
if (state.droppedWhileStopped === 1 || state.droppedWhileStopped % 20 === 0) {
log('engine sent a sentence while translation is STOPPED (' +
state.droppedWhileStopped + ' so far) — re-sending stop');
}
sendToEngine({ t: 'stop' });
return;
}
state.droppedWhileStopped = 0;
const text = String(m.text || '').trim();
if (!text) return;
const row = { id: ++state.seq, ts: Date.now(), src: text, tr: m.tr || {},
audio: m.audio || {}, cid: m.cid,
/* Said while the cough button was down. delayed() already
keeps it off every live socket, but scrollback is
replayed to anyone who opens the page LATER — and to
anyone who just switches language — so without this the
held sentence goes out anyway, a minute late, to the
next person to arrive. */
held: state.hold };
state.scrollback.push(row);
if (state.scrollback.length > SCROLLBACK) state.scrollback.shift();
state.partial = '';
broadcastFinal(row);
scheduleTranscript(row);
} else if (m.t === 'tr') {
/*
* One language's translation of one sentence, arriving on its own
* schedule. The caption for THIS language's listeners goes out now —
* they no longer wait for six other languages to be produced first.
* (Their initial 'caption' frame carried the source text as a
* placeholder; this replaces it by row id on the page.)
*/
if (!state.live) return;
const row = state.scrollback.find((r) => r.cid === m.cid);
if (!row) return;
row.tr[m.lang] = String(m.text || '');
delayed((who) => {
const frame = JSON.stringify({
t: 'caption', id: row.id, ts: row.ts,
text: row.tr[m.lang], translated: true, original: row.src,
audio: (row.audio && row.audio[m.lang]) || null
});
for (const [ws, meta] of state.listeners) {
if (ws.readyState !== 1) continue;
if (!who(meta)) continue;
if ((meta.lang || config.sourceLanguage) !== m.lang) continue;
try { ws.send(frame); } catch (_) {}
}
});
scheduleTranscript(row);
} else if (m.t === 'audio') {
/*
* A spoken clip for a sentence already on screen.
*
* It arrives after its caption on purpose — text should never wait for
* speech. The engine tags each sentence with its own id so the clip can
* find the line it belongs to once it is ready.
*/
if (!state.live) return;
const row = state.scrollback.find((r) => r.cid === m.cid);
/*
* Into the continuous stream IMMEDIATELY — see the delay note below.
*
* This used to sit on the delay line, which delayed the room's audio
* along with the internet's. It cannot be split per listener the way
* captions can: HLS is one sliding window of segments that every
* listener of a language pulls over HTTP, so there is nothing to send
* "late" to one of them. Instead the segments always enter the window
* now, and a delayed listener is handed a playlist that starts further
* BACK in that window — same bytes, later position. The window is kept
* long enough to cover the delay (see liveAudio.reserve).
*
* The cough button still has to be honoured here, and delayed() is no
* longer doing it for us.
*/
/*
* DEFAULT VOICE ONLY, and this guard is load-bearing.
*
* The continuous stream is ONE window per language — that is the whole
* reason it can be shared — so it can carry exactly one voice. Pushing
* an alternate into it as well would splice two readings of every
* sentence into the same window, and every listener on the stream
* (including the ones who never touched the chooser) would hear the
* sermon spoken twice, overlapping.
*
* So a listener on an alternate voice is served by CLIPS, not the
* stream — see wantsClips() on the page.
*/
if (!state.hold && !m.voice) {
try {
const clipBytes = fs.readFileSync(path.join(CLIP_DIR, m.name));
/* The row's ts is when the sentence was SAID — the dropper's age
gate is measured from it, not from when the audio arrived. */
liveAudio.push(m.lang, clipBytes, m.sampleRate, m.terminal, row ? row.id : null, row ? row.ts : undefined);
} catch (e) {
log('could not stream clip ' + m.name + ': ' + e.message);
}
}
if (!row) return; // its caption has already scrolled away
/* Scrollback keeps only the DEFAULT voice's clip. A late joiner is
handed one clip per sentence, and an alternate that existed only
because someone was listening to it a minute ago is not the clip to
hand them — they may not even be on that voice. */
if (!m.voice) row.audio[m.lang] = m.name;
delayed((who) => {
const frame = JSON.stringify({
t: 'audio', id: row.id, name: m.name, seconds: m.seconds || 0
});
/* Which voice this clip IS. '' is the operator's default, which is
what a listener who never opened the chooser is on. */
const clipVoice = String(m.voice || '');
for (const [ws, meta] of state.listeners) {
if (ws.readyState !== 1) continue;
if (!who(meta)) continue;
if ((meta.lang || config.sourceLanguage) !== m.lang) continue;
/*
* Send each listener only their OWN voice's clip. Without this
* every listener would play every voice of their language on top
* of each other — the alternates do not replace the default clip,
* they are additional.
*/
if ((meta.voice || '') !== clipVoice) continue;
try { ws.send(frame); } catch (_) {}
}
});
} else if (m.t === 'voiceOptions') {
/*
* The operator changed which alternates listeners may pick.
*
* Anyone already listening on a voice that just stopped being offered
* is moved back to the default rather than left on a voice that will
* no longer be synthesised — otherwise their audio simply stops, with
* nothing on the page to explain why.
*/
state.voiceOptions = {};
const incoming = (m.map && typeof m.map === 'object') ? m.map : {};
for (const lang of Object.keys(incoming)) {
const list = Array.isArray(incoming[lang]) ? incoming[lang] : [];
const rows = [];
const seen = {};
for (const row of list.slice(0, 12)) {
const key = String((row && row.key) || '');
/*
* The EMPTY key is legal and load-bearing: it is the operator's own
* voice, which leads every list so the chooser reads "Alex · male /
* Santa · male" rather than "Default / Santa · male". Rejecting it
* as falsy dropped that entry and left the listener choosing
* between one voice and nothing. Allowed once, like any other.
*/
if (Object.prototype.hasOwnProperty.call(seen, key)) continue;
seen[key] = 1;
rows.push({ key: key, label: String((row && row.label) || key || 'Default').slice(0, 60) });
}
/* A list that is ONLY the default is not a choice — drop it so the
page shows no chooser at all rather than a one-entry menu. */
if (rows.filter((r) => r.key).length) state.voiceOptions[lang] = rows;
}
for (const [ws, meta] of state.listeners) {
const allowed = state.voiceOptions[meta.lang || config.sourceLanguage] || [];
const stillOffered = meta.voice && allowed.some((r) => r.key === meta.voice);
if (meta.voice && !stillOffered) meta.voice = '';
if (ws.readyState !== 1) continue;
try {
ws.send(JSON.stringify({ t: 'voices', options: allowed, voice: meta.voice || '' }));
} catch (_) {}
}
pushLanguagesToEngine();
} else if (m.t === 'mic') {
/*
* 0.7s of the room microphone. Straight into English's live stream —
* and deliberately NOT through the stream-delay line: this feed's
* audience is hearing-aid users IN THE ROOM, for whom a livestream
* delay would be minutes of echo. (A remote English listener gets
* room-live audio beside delayed captions; accepted, documented.)
*/
if (!state.live || !micWanted()) return;
try {
const pcm = Buffer.from(String(m.pcm || ''), 'base64');
if (!pcm.length || pcm.length > 64000) return;
const wav = Buffer.alloc(44 + pcm.length);
wav.write('RIFF', 0); wav.writeUInt32LE(36 + pcm.length, 4);
wav.write('WAVE', 8); wav.write('fmt ', 12);
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(16000, 24); wav.writeUInt32LE(32000, 28);
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
wav.write('data', 36); wav.writeUInt32LE(pcm.length, 40);
pcm.copy(wav, 44);
const base = path.join(require('os').tmpdir(), 'ct-mic-' + process.pid);
fs.writeFileSync(base + '.wav', wav);
require('./aac-encode.js').encodeAdts(base + '.wav', base + '.aac', 16000);
const st = liveAudio.ensure(config.sourceLanguage, 16000);
st.pushContinuous(fs.readFileSync(base + '.aac'));
} catch (err) {
log('mic feed chunk failed: ' + err.message);
}
} else if (m.t === 'status') {
state.engineStatus = m.status || {};
state.engineStatusAt = Date.now();
broadcastOperators();
}
});
ws.on('close', () => {
if (state.engine === ws) {
state.engine = null;
state.operators.delete(ws);
state.engineStatus = {};
state.engineStatusAt = 0;
log('engine disconnected');
notifyCompanion();
/*
* A BRIEF DISCONNECT IS NOT THE END OF THE SERVICE.
*
* Without an engine there is genuinely nothing to translate, and a
* frozen last line on every listener's screen is worse than saying so.
* But stopping the INSTANT the socket closes made any momentary
* reconnect end the service: measured, switching Wi-Fi off mid-service
* stopped translation outright, because the engine re-opened its
* loopback socket on an address-list change and this fired in the
* gap — a few milliseconds wide.
*
* So wait. If an engine is back within the grace window nothing
* happened as far as the room is concerned; if it is not, the service
* stops exactly as before. The captions stay on screen meanwhile,
* which for a gap this short is the truthful picture.
*/
if (state.live) {
clearTimeout(enginePause);
enginePause = setTimeout(() => {
enginePause = null;
if (!state.engine && state.live) setLive(false, 'engine gone');
}, ENGINE_GRACE_MS);
broadcastOperators();
} else broadcastOperators();
}
});
return;
}
// ---- operator page ---------------------------------------------------
if (role === 'operator') {
/* Same rule as the HTTP side. This socket checked the PIN itself, so
gating only /api/config would have left the whole desk reachable. */
if (!operatorReachable(req)) {
log('operator socket refused: the desk is local-only');
ws.close(1008, 'operator page is local-only');
return;
}
if (authLocked(req) ||
!secretEquals(url.searchParams.get('pin') || '', config.operatorPin)) {
const given = url.searchParams.get('pin') || '';
if (given && !authLocked(req)) authFailed(req, 'operator-PIN');
ws.close(1008, 'bad pin');
return;
}
authPassed(req);
state.operators.add(ws);
send(ws, { t: 'status', status: statusSnapshot() });
ws.on('message', (raw) => {
let m;
try { m = JSON.parse(raw); } catch (_) { return; }
if (m.t === 'start') setLive(true, 'operator');
else if (m.t === 'stop') setLive(false, 'operator');
else if (m.t === 'toggle') setLive(!state.live, 'operator');
});
ws.on('close', () => state.operators.delete(ws));
return;
}
// ---- a congregant --------------------------------------------------
const lang = url.searchParams.get('lang') || config.sourceLanguage;
/*
* Record who this is, not just that they exist.
*
* "2 people listening" is useless when one of them is a tab somebody forgot
* about: there is no way to tell which device to go and close. The address
* and the device name make a phantom identifiable.
*/
/* True when the request came through the tunnel. It decides whether this
listener is on the stream delay, so it is read once and reused. */
const proxied = !!req.headers['x-forwarded-for'];
state.listeners.set(ws, {
lang,
/* '' is the operator's default voice — where everyone starts, and where
anyone whose pick stops being offered is put back. */
voice: '',
joinedAt: Date.now(),
ip: listenerIp(req),
/* Behind a proxy every listener shares one address, so the address stops
meaning "device". Remembered per connection rather than assumed globally:
a service can be reached on the LAN and through a tunnel at the same time. */
proxied,
device: describeDevice(req.headers['user-agent'] || ''),
speaking: false
});
pushLanguagesToEngine();
send(ws, {
t: 'hello',
build: BUILD,
live: state.live,
lang,
/*
* How far behind the live edge THIS listener should sit. Zero in the room.
* The page uses it as its drift target, so a delayed listener's audio lines
* up with their delayed captions instead of running at the live edge.
*/
delaySec: proxied ? Math.round(streamDelayMs() / 1000) : 0,
serviceName: config.serviceName,
/* Whether this Mac can speak their language. If not the page falls back to
the device's own voice and says so, rather than sitting silent. */
builtInVoice: Voices.hasVoice(lang),
/* The alternates this listener may pick. Empty means the page shows no
chooser at all, which is the default everywhere until an operator
deliberately opens one up. */
voiceOptions: voiceOptionsFor(lang),
voice: '',
scrollback: scrollbackFor(lang, proxied)
});
broadcastOperators();
ws.on('message', (raw) => {
let m;
try { m = JSON.parse(raw); } catch (_) { return; }
if (m.t === 'speaking') {
/*
* One speaking page per DEVICE, enforced here rather than in the browser.
*
* The page already coordinates between its own tabs, but that only works
* inside one browser — Safari and Chrome on the same iPad cannot see each
* other, and the result is two voices reading different languages over
* each other with no way to tell which page to close. The server sees both
* connections and their shared address, so it can settle it.
*/
const meta = state.listeners.get(ws);
if (!meta) return;
meta.speaking = !!m.on;
/* The live-microphone feed follows this flag — the engine must hear
about it now, not on the next unrelated language change. */
pushLanguagesToEngine();
/*
* Only on a direct connection.
*
* On the church LAN each phone has its own DHCP address, so "same address"
* really does mean "same device" — which is the only way to catch Safari
* and Chrome on one iPad, since a page in one browser cannot see a page in
* the other by any means the web allows.
*
* Through a tunnel or a reverse proxy that inverts: every listener arrives
* from one address, and this rule would silence the whole congregation
* except whoever pressed play last. Browsers coordinate their own tabs via
* BroadcastChannel regardless, so stepping back here loses only the
* cross-browser case, and only for remote listeners.
*/
if (meta.speaking && !meta.proxied) {
const frame = JSON.stringify({ t: 'standDown' });
for (const [other, om] of state.listeners) {
if (other === ws || other.readyState !== 1) continue;
if (om.proxied || om.ip !== meta.ip || !om.speaking) continue;
om.speaking = false;
try { other.send(frame); } catch (_) {}
log('two pages speaking on ' + meta.ip + ' — stood one down');
}
}
broadcastOperators();
return;
}
if (m.t === 'voice') {
/*
* A listener picked a voice for themselves.
*
* Only a key the operator actually offered for THIS listener's language
* is accepted; anything else falls back to the default. A phone can send
* whatever it likes, and this is the gate — the engine will not
* synthesise a voice that never reaches voiceDemand().
*/
const meta = state.listeners.get(ws);
if (!meta) return;
const want = String(m.voice || '');
const allowed = voiceOptionsFor(meta.lang || config.sourceLanguage);
meta.voice = (want && allowed.some((r) => r.key === want)) ? want : '';
/* Immediate, not debounced: the listener is waiting to hear the change,
and pushLanguagesToEngine's 250 ms is for crowds, not for this. */
pushLanguagesToEngine();
send(ws, { t: 'voices', options: allowed, voice: meta.voice });
broadcastOperators();
return;
}
if (m.t === 'lang') {
const meta = state.listeners.get(ws);
if (!meta) return;
meta.lang = String(m.lang || config.sourceLanguage);
/*
* A voice belongs to a language. Carrying a Spanish speaker across to
* Portuguese would ask the engine for a voice that language does not
* have, so switching language always returns the listener to the
* default and hands them the new language's options.
*/
meta.voice = '';
pushLanguagesToEngine();
/*
* builtInVoice MUST be here, not just on the first hello. The page does
* srv.available = !!m.builtInVoice on every hello, so omitting it told a
* listener who merely switched language that this Mac cannot speak it —
* and the page then started the device's own voice on top of the clip we
* were already sending. Two voices, one of them reading the translation
* with whatever voice the phone happened to pick.
*/
send(ws, { t: 'hello', live: state.live, lang: meta.lang, serviceName: config.serviceName, build: BUILD,
builtInVoice: Voices.hasVoice(meta.lang),
voiceOptions: voiceOptionsFor(meta.lang), voice: '',
scrollback: scrollbackFor(meta.lang, meta.proxied) });
broadcastOperators();
}
});
ws.on('close', () => {
state.listeners.delete(ws);
pushLanguagesToEngine();
broadcastOperators();
});
});
/* Push the console a fresh snapshot on a timer as well as on events. Elapsed
time needs to tick, staleness needs to be noticed, and a page that only
updates when something happens will happily display a comforting lie for the
whole service. Operators are a handful of sockets; this is free. */
const consoleTick = setInterval(() => {
if (state.operators.size) broadcastOperators();
/* Companion needs the listener count whether or not anyone has an operator
page open — a Stream Deck showing "12 listening" is often the only place
anyone is looking. The signature guard means this costs one comparison
when nothing has moved. */
notifyCompanion();
}, 3000);
consoleTick.unref?.();
/* Cloudflare closes idle tunnels, and phones sleep. A 25s ping keeps the
socket honest and reaps the ones that silently died. */
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) { try { ws.terminate(); } catch (_) {} return; }
ws.isAlive = false;
try { ws.ping(); } catch (_) {}
});
}, 25000);
wss.on('close', () => clearInterval(heartbeat));
/* A delay loaded from config on boot needs the window sized for it before the
first listener arrives, not only when someone changes the number. */
liveAudio.reserve((Number(config.streamDelaySec) || 0) + 5);
server.listen(PORT, () => {
log('Church Translate listening on :' + PORT + ' (build ' + BUILD + ')');
log(' operator page /operator PIN in ' + path.join(DATA_DIR, 'OPERATOR-PIN.txt'));
log(' companion API /api/control/{start,stop,toggle}?key=…');
});
function shutdown(sig) {
log('shutting down (' + sig + ')');
clearInterval(heartbeat);
clearInterval(consoleTick);
try { wss.close(); } catch (_) {}
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 3000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));