Files
church-translate/app/server/server.js
T

2097 lines
87 KiB
JavaScript
Raw Permalink Normal View History

/*
* 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; }
if (p === '/healthz') { sendJSON(res, 200, { ok: true, live: state.live, build: 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'));