/* * 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. */ /* How long a stumble keeps colouring the verdict. Long enough that a real problem stays visible while someone walks to the desk, short enough that a one-off does not outlive the sentence after it. */ const TROUBLE_WINDOW_MS = 120000; function loadNote() { const n = activeLanguages().length; if (n < 2) return ''; 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.' }; /* * RECENTLY, not ever. * * captionOnlyFinals is a running total for the whole service, and testing it * for "more than zero" meant a single stale sentence pinned the desk's light * red until the next restart. MEASURED: a campus Mac took one 16 s stall at * 14:25 — caused by two Screen Sharing sessions competing for the machine — * recovered immediately to a 421 ms lag with an empty queue, and still showed * red half an hour later. An operator who looks up and sees red wants to know * what is wrong NOW; a light that cannot go back to green teaches them to * ignore it. * * So the totals still travel, for the record and for the operator page, but * the verdict asks when it last happened. */ const recentCaptionOnly = es.captionOnlyAt && (Date.now() - es.captionOnlyAt) < TROUBLE_WINDOW_MS; if ((es.lagMs || 0) > 12000 || recentCaptionOnly) return { level: 'red', detail: 'Falling badly behind — some sentences are captions only.' + loadNote() }; const recentDegraded = es.degradedAt && (Date.now() - es.degradedAt) < TROUBLE_WINDOW_MS; if ((es.lagMs || 0) > 6000 || (es.queue || 0) >= 2 || recentDegraded) return { level: 'amber', detail: 'Running behind — idle languages may be trimmed to catch up.' + loadNote() }; if (es.cloud && es.cloud.parked) 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=' }); 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) => { /* 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 || ''); /* * The frame says which language and voice it carries, and the page * checks both again when the clip's bytes land (clipIsOurs() in * listen.js). The routing below is decided at SEND time; a listener * who switches language or voice while a clip is still downloading * would otherwise hear it after the switch, in the voice they left. */ const frame = JSON.stringify({ t: 'audio', id: row.id, name: m.name, seconds: m.seconds || 0, lang: m.lang, voice: clipVoice }); 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; const next = 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. * * Only a CHANGE of language, though. The page re-sends the language it * is already on to have its captions re-rendered (the original-text * toggle does exactly that), and resetting the voice there put a * listener on an alternate back on the default for a round trip — * until the page re-asserted its pick, they were sent the default * voice's clips: one voice, then the other, then theirs again. */ if (next !== meta.lang) meta.voice = ''; meta.lang = next; 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: meta.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'));