Live sermon translation into 100+ languages, running entirely on one Mac per campus: audio in, voice detection, Whisper on the GPU, NLLB-200 in its own process, and a built-in web server the congregation reads from on their phones. No audio leaves the building. This is the source only. What used to sit beside it — 14 GB of speech and translation models, 8 GB of voice bundles, 6 GB of natural voices, the Electron caches, and the app data holding the operator PIN and every service transcript — is machine state and downloaded assets, and stays in the NAS backup where it belongs. The vendored node, cloudflared and Windows ffmpeg binaries are out too (533 MB, re-fetched for packaging); the browser-side runtime beside them is kept, so a checkout still serves the congregation page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2078 lines
86 KiB
JavaScript
2078 lines
86 KiB
JavaScript
/*
|
|
* Church Translate — server (runs in Docker on the Synology NAS).
|
|
*
|
|
* This box never touches audio and never runs a model. It is the venue:
|
|
* • serves the listener page that the QR code points at
|
|
* • serves the operator page
|
|
* • exposes a plain-HTTP control API for Bitfocus Companion / Stream Deck
|
|
* • relays text from the Mac engine out to every listener
|
|
*
|
|
* Because only text crosses the wire, a thousand listeners cost the NAS about
|
|
* as much as one.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { WebSocketServer } = require('ws');
|
|
const QRCode = require('qrcode');
|
|
const Languages = require('./public/languages.js');
|
|
/* Which languages this Mac can actually speak — text reaches ~200, speech 31. */
|
|
let Voices = { hasVoice: () => false, spokenLanguages: () => [], sampleRateFor: () => 22050 };
|
|
try { Voices = require('../voices.js'); } catch (_) { /* running standalone */ }
|
|
|
|
const PORT = Number(process.env.PORT || 3100);
|
|
/*
|
|
* How long the service survives with no engine attached. Long enough to cover
|
|
* a socket re-open (milliseconds) and a renderer reload (a second or two),
|
|
* short enough that a genuinely dead engine is reported while the room still
|
|
* connects the two events.
|
|
*/
|
|
const ENGINE_GRACE_MS = 8000;
|
|
let enginePause = null;
|
|
/*
|
|
* NEVER default inside the bundle. electron-builder replaces
|
|
* Contents/Resources/app wholesale on every install, so a server started
|
|
* without DATA_DIR would put the config, the transcripts and the PIN file
|
|
* somewhere an update deletes — producing exactly the "my settings reset after
|
|
* every update" report, deterministically. The app always sets this; the
|
|
* fallback is for standalone runs and points somewhere durable.
|
|
*/
|
|
const DATA_DIR = process.env.DATA_DIR || (process.platform === 'win32'
|
|
? path.join(process.env.LOCALAPPDATA ||
|
|
path.join(require('os').homedir(), 'AppData', 'Local'), 'Church Translate', 'data')
|
|
: path.join(require('os').homedir(), 'Library', 'Application Support', 'Church Translate', 'data'));
|
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
|
|
|
|
/* Where the voice process writes its clips. Set by the app when it forks us. */
|
|
const CLIP_DIR = process.env.CLIP_DIR || path.join(require('os').tmpdir(), 'church-translate-audio');
|
|
|
|
const { LiveAudio } = require('./live-audio.js');
|
|
const { execFileSync } = require('child_process');
|
|
/* One continuous stream per language. See live-audio.js for why per-sentence
|
|
clips cannot survive a locked phone. */
|
|
const liveAudio = new LiveAudio((m) => log(m));
|
|
|
|
/*
|
|
* Read-along: the moment a sentence's frames start leaving the queue, tell the
|
|
* listeners on that language, so their page can highlight the caption the
|
|
* voice is actually saying rather than the newest one. The phone compensates
|
|
* for its own player lag before lighting it up.
|
|
*/
|
|
/*
|
|
* A dropped sentence is recorded on its transcript row and counted — the one
|
|
* thing the morning of the failure did not have. Not a rewrite of what was
|
|
* said (the caption stands); a note that this language's voice skipped it.
|
|
*/
|
|
let state_dropTotal = 0;
|
|
liveAudio.onDropped = (lang, ids, secs) => {
|
|
state_dropTotal += ids.length;
|
|
for (const id of ids) {
|
|
const row = state.scrollback.find((r) => r.id === id);
|
|
if (row) { if (!row.audioDropped) row.audioDropped = []; if (row.audioDropped.indexOf(lang) === -1) row.audioDropped.push(lang); }
|
|
}
|
|
log('live audio ' + lang + ': dropped ' + ids.length + ' stale sentence(s), ' + secs.toFixed(1) + 's (' +
|
|
state_dropTotal + ' this service)');
|
|
broadcastOperators();
|
|
notifyCompanion();
|
|
};
|
|
|
|
liveAudio.onSentenceStart = (lang, id, secs) => {
|
|
const frame = JSON.stringify({ t: 'nowSpeaking', id, seconds: secs });
|
|
const to = (only) => {
|
|
for (const [ws, meta] of state.listeners) {
|
|
if (ws.readyState !== 1) continue;
|
|
if (!only(meta)) continue;
|
|
if ((meta.lang || config.sourceLanguage) !== lang) continue;
|
|
try { ws.send(frame); } catch (_) {}
|
|
}
|
|
};
|
|
const ms = streamDelayMs();
|
|
if (ms === 0) { to(ALL); return; }
|
|
/* This fires when frames leave the queue at the LIVE edge. A delayed
|
|
listener is that far back in the window, so the highlight has to wait the
|
|
same amount or it lights up a caption they have not reached yet. */
|
|
to(LOCAL);
|
|
const t = setTimeout(() => { delayTimers.delete(t); if (state.live) to(REMOTE); }, ms);
|
|
if (t.unref) t.unref();
|
|
delayTimers.add(t);
|
|
};
|
|
|
|
/*
|
|
* The interpreter's feedback loop: every couple of seconds, tell the engine how
|
|
* far each language's voice is behind its text. The voice process quickens its
|
|
* delivery when it is falling behind — which is what a human interpreter does —
|
|
* so the catch-up logic almost never has to drop a sentence.
|
|
*/
|
|
setInterval(() => {
|
|
if (!state.live || !state.engine) return;
|
|
const backlog = liveAudio.backlog();
|
|
if (Object.keys(backlog).length) sendToEngine({ t: 'lag', backlog });
|
|
}, 2000).unref();
|
|
|
|
/* How many finalized lines a late arrival sees when they join mid-sermon. */
|
|
const SCROLLBACK = 40;
|
|
|
|
// ---------------------------------------------------------------- config
|
|
|
|
const DEFAULT_CONFIG = {
|
|
serviceName: 'Waters Church',
|
|
welcome: 'Choose your language',
|
|
sourceLanguage: 'eng_Latn',
|
|
featured: Languages.DEFAULT_FEATURED.slice(),
|
|
/* Translated every service whether or not anyone is listening. Use this when
|
|
you know a group is coming and want zero warm-up on the first sentence. */
|
|
pinned: [],
|
|
/*
|
|
* Seconds to hold everything LISTENER-facing (captions, previews, spoken
|
|
* audio) so it lines up with a livestream's own latency — a YouTube viewer's
|
|
* video runs 10-40s behind the room, and captions arriving live would spoil
|
|
* the sermon ahead of the video. Operators in the room always see live.
|
|
*/
|
|
streamDelaySec: 0,
|
|
allowAllLanguages: true,
|
|
publicUrl: '',
|
|
/* Optional. If set, the server pushes live/ready state into a Companion
|
|
custom variable so the Stream Deck button can colour itself. Without it
|
|
the button still works, it just won't reflect state. */
|
|
companionUrl: '',
|
|
companionVariable: 'translate_state',
|
|
operatorPin: '',
|
|
controlToken: '',
|
|
engineKey: '',
|
|
/* `fixes` are corrections applied to the transcript — each line is
|
|
"what it heard => what it should say". `overrides` pin a term's
|
|
translation in a specific language. Both are plain find-and-replace,
|
|
which is exactly why they are dependable. */
|
|
glossary: {
|
|
fixes: [
|
|
'have a cook => Habakkuk',
|
|
'water church => Waters Church'
|
|
],
|
|
overrides: {}
|
|
}
|
|
};
|
|
|
|
function ensureDataDir() {
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
}
|
|
|
|
/*
|
|
* Settings are the operator's WORK — pinned languages, the corrections list
|
|
* built up over months of services. Losing them is not a glitch, it is asking
|
|
* someone to redo an afternoon.
|
|
*
|
|
* The failure this guards against is real and was reproduced: a config file
|
|
* that will not parse (a crash mid-write, a full disk, anything) used to fall
|
|
* through to {}, merge the defaults over it, and SAVE — permanently replacing
|
|
* every setting AND minting a new operator PIN, with nothing logged. One bad
|
|
* read destroyed everything.
|
|
*
|
|
* So: snapshot before every write, restore from the newest good snapshot when
|
|
* the live file is unreadable, and NEVER overwrite an unreadable file with
|
|
* defaults — keep it for forensics instead.
|
|
*/
|
|
const BACKUP_DIR = () => path.join(DATA_DIR, 'config-backups');
|
|
const KEEP_BACKUPS = 30;
|
|
|
|
/*
|
|
* The listener-facing delay line. Every broadcast to LISTENERS funnels through
|
|
* delayed(); operators bypass it entirely. Stopping the service cancels every
|
|
* pending timer — nothing may fire into a stopped room.
|
|
*/
|
|
/*
|
|
* WHO a broadcast is for.
|
|
*
|
|
* The stream delay used to be one global timer: turning it on to protect a
|
|
* livestream also delayed the people sitting in the room, which is the one
|
|
* group that must never be delayed — they can hear the preacher with their own
|
|
* ears, and captions arriving twenty seconds late are worse than none.
|
|
*
|
|
* `proxied` is recorded per listener at connection time (it is true when the
|
|
* request carried x-forwarded-for, i.e. it came through the tunnel), so the
|
|
* room and the internet can be served from the same broadcast at different
|
|
* times. A service reachable on the LAN and through a tunnel at once is the
|
|
* normal case, not an edge case.
|
|
*/
|
|
const ALL = () => true;
|
|
const LOCAL = (meta) => !meta.proxied;
|
|
const REMOTE = (meta) => !!meta.proxied;
|
|
|
|
function streamDelayMs() {
|
|
return Math.max(0, Number(config.streamDelaySec) || 0) * 1000;
|
|
}
|
|
|
|
const delayTimers = new Set();
|
|
function delayed(fn) {
|
|
/*
|
|
* Gated at ENQUEUE, not at fire time, and that is the whole design. With a
|
|
* stream delay the words a listener is hearing right now were spoken N
|
|
* seconds ago — so what must be suppressed is what is being said FROM THE
|
|
* MOMENT THE BUTTON IS PRESSED, which is exactly what is being enqueued now.
|
|
* Anything already on the delay line was said before the hold and is safe;
|
|
* cancelling it would chop the stream off mid-sentence for no reason.
|
|
*/
|
|
if (state.hold) return;
|
|
const ms = streamDelayMs();
|
|
/* No delay set: one pass, everybody, exactly as before. */
|
|
if (ms === 0) { fn(ALL); return; }
|
|
fn(LOCAL);
|
|
const t = setTimeout(() => { delayTimers.delete(t); if (state.live) fn(REMOTE); }, ms);
|
|
if (t.unref) t.unref();
|
|
delayTimers.add(t);
|
|
}
|
|
function cancelDelayed() {
|
|
for (const t of delayTimers) clearTimeout(t);
|
|
delayTimers.clear();
|
|
}
|
|
|
|
/*
|
|
* Pass the content to snapshot, or nothing to snapshot whatever is on disk.
|
|
*
|
|
* This is called AFTER a successful write, not before it. Snapshotting the old
|
|
* content first sounds safer but means the newest snapshot is always one edit
|
|
* behind — so a restore silently reverted the very change the operator made
|
|
* last. The newest snapshot should BE the last known-good config.
|
|
*/
|
|
function backupConfig(content) {
|
|
try {
|
|
let raw = content;
|
|
if (raw === undefined) {
|
|
if (!fs.existsSync(CONFIG_PATH)) return;
|
|
raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
}
|
|
JSON.parse(raw); // never snapshot a broken file
|
|
const dir = BACKUP_DIR();
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
|
/* Identical content is not worth a new file — it just pushes real history
|
|
out of the 30 we keep. */
|
|
const existing = fs.readdirSync(dir).filter((f) => /^config-.*\.json$/.test(f)).sort();
|
|
if (existing.length) {
|
|
try {
|
|
if (fs.readFileSync(path.join(dir, existing[existing.length - 1]), 'utf8') === raw) return;
|
|
} catch (_) {}
|
|
}
|
|
|
|
/* Second resolution, not minute. At minute resolution only the FIRST state
|
|
in any given minute was ever captured, so a restore quietly reverted
|
|
every edit made since — a backup that loses the work it exists to protect
|
|
is worse than none. 30 snapshots of a file this size costs nothing. */
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
const file = path.join(dir, 'config-' + stamp + '.json');
|
|
if (!fs.existsSync(file)) fs.writeFileSync(file, raw);
|
|
|
|
const all = fs.readdirSync(dir).filter((f) => /^config-.*\.json$/.test(f)).sort();
|
|
for (const old of all.slice(0, Math.max(0, all.length - KEEP_BACKUPS))) {
|
|
try { fs.unlinkSync(path.join(dir, old)); } catch (_) {}
|
|
}
|
|
} catch (_) { /* a backup must never be the reason a save fails */ }
|
|
}
|
|
|
|
function newestBackup() {
|
|
try {
|
|
const dir = BACKUP_DIR();
|
|
const all = fs.readdirSync(dir).filter((f) => /^config-.*\.json$/.test(f)).sort();
|
|
for (let i = all.length - 1; i >= 0; i--) {
|
|
try {
|
|
const raw = fs.readFileSync(path.join(dir, all[i]), 'utf8');
|
|
return { cfg: JSON.parse(raw), file: all[i] };
|
|
} catch (_) { /* try the one before it */ }
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
function loadConfig() {
|
|
ensureDataDir();
|
|
let cfg = {};
|
|
let recovered = false;
|
|
const existed = fs.existsSync(CONFIG_PATH);
|
|
|
|
try {
|
|
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
/* Snapshot every good config we manage to read. Backing up only on save
|
|
meant a server that booted fine and was never edited had NO history —
|
|
so the first corruption had nothing to fall back to. Boot is exactly
|
|
when we know the file is good and nothing is about to change it. */
|
|
backupConfig();
|
|
} catch (err) {
|
|
cfg = {};
|
|
if (existed) {
|
|
/* The file is there but unreadable. Do NOT let defaults win. */
|
|
const back = newestBackup();
|
|
if (back) {
|
|
cfg = back.cfg;
|
|
recovered = true;
|
|
log('config.json was unreadable (' + err.message + ') — RESTORED from ' + back.file);
|
|
} else {
|
|
log('config.json is unreadable (' + err.message + ') and there is no backup to restore');
|
|
}
|
|
/* Keep the damaged file rather than silently overwriting the evidence. */
|
|
try {
|
|
const kept = CONFIG_PATH + '.broken-' + Date.now();
|
|
fs.copyFileSync(CONFIG_PATH, kept);
|
|
log('kept the damaged file at ' + path.basename(kept));
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
const merged = Object.assign({}, DEFAULT_CONFIG, cfg);
|
|
merged.glossary = Object.assign({}, DEFAULT_CONFIG.glossary, cfg.glossary || {});
|
|
|
|
/* Secrets are generated once, on first boot, and then persist. The page is
|
|
reachable from the public internet through the tunnel, so "no password"
|
|
is not an option we offer. */
|
|
let dirty = false;
|
|
if (!merged.controlToken) { merged.controlToken = crypto.randomBytes(9).toString('base64url'); dirty = true; }
|
|
if (!merged.engineKey) { merged.engineKey = crypto.randomBytes(18).toString('base64url'); dirty = true; }
|
|
/* Four digits: short enough to type at a desk in the dark. What keeps it safe
|
|
is that the operator page is LAN-only (see operatorRemote), not its length. */
|
|
if (!merged.operatorPin) { merged.operatorPin = String(crypto.randomInt(1000, 9999)); dirty = true; }
|
|
if (dirty || recovered || !fs.existsSync(CONFIG_PATH)) {
|
|
saveConfig(merged);
|
|
/* Written in the clear next to the data so it can be read over SMB without
|
|
shelling into the NAS. */
|
|
try {
|
|
fs.writeFileSync(path.join(DATA_DIR, 'OPERATOR-PIN.txt'),
|
|
'Operator PIN: ' + merged.operatorPin + '\n' +
|
|
'Companion control token: ' + merged.controlToken + '\n' +
|
|
'Engine key: ' + merged.engineKey + '\n\n' +
|
|
'Change the PIN on the operator page. Keep this file off the projector.\n');
|
|
} catch (_) {}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/*
|
|
* Returns true on success. NEVER throws: this is called straight from the HTTP
|
|
* handler, and an exception there (full disk, read-only share, permissions)
|
|
* killed the whole server process — the operator saw a page that simply stopped
|
|
* responding, tried again, and killed it again.
|
|
*/
|
|
function saveConfig(cfg) {
|
|
try {
|
|
return writeConfig(cfg);
|
|
} catch (err) {
|
|
log('COULD NOT SAVE config: ' + err.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function writeConfig(cfg) {
|
|
ensureDataDir();
|
|
const tmp = CONFIG_PATH + '.tmp';
|
|
const body = JSON.stringify(cfg, null, 2);
|
|
|
|
/* Parse what we are about to write. A bug upstream that produced a circular
|
|
or half-built object should fail HERE, with the good file still in place,
|
|
rather than after the rename. */
|
|
JSON.parse(body);
|
|
|
|
const fd = fs.openSync(tmp, 'w');
|
|
try {
|
|
fs.writeSync(fd, body);
|
|
/* Force it to the platter before the rename. Without this a power cut
|
|
between write and rename can leave a zero-length config — the exact
|
|
state that used to wipe every setting on the next boot. */
|
|
fs.fsyncSync(fd);
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
fs.renameSync(tmp, CONFIG_PATH);
|
|
backupConfig(body); // newest snapshot == what we just saved
|
|
return true;
|
|
}
|
|
|
|
let config = loadConfig();
|
|
|
|
// ---------------------------------------------------------------- state
|
|
|
|
const state = {
|
|
live: false, // is the engine actively transcribing
|
|
/*
|
|
* The cough button. Translation keeps running and the operator keeps seeing
|
|
* every word; nothing reaches a LISTENER while this is on. For the moment a
|
|
* pastor names someone in a prayer request, or a conversation happens on a
|
|
* live mic that was never meant to leave the room. Stopping the service
|
|
* would work too, but it drops every listener, empties the delay line and
|
|
* makes everyone re-open the page — for fifteen seconds of privacy.
|
|
*/
|
|
hold: false,
|
|
engine: null, // the Mac engine's socket, if connected
|
|
engineStatus: {}, // last status frame from the engine
|
|
engineStatusAt: 0, // when that frame arrived
|
|
startedAt: null,
|
|
listeners: new Map(), // ws -> { lang, voice, joinedAt }
|
|
/*
|
|
* Which alternate voices listeners may pick, per language, as pushed by the
|
|
* renderer (which owns the setting). In memory only and deliberately so —
|
|
* the app config is the single owner, and a copy in the server's own
|
|
* config.json would be a second truth to drift out of sync.
|
|
*
|
|
* Empty until the engine connects, which is correct: with no engine there is
|
|
* nothing synthesising alternates anyway.
|
|
*/
|
|
voiceOptions: {}, // lang -> [voice key, ...]
|
|
operators: new Set(),
|
|
scrollback: [], // recent finals, for late joiners
|
|
partial: '', // in-flight sentence, source language
|
|
seq: 0
|
|
};
|
|
|
|
/* The set of languages the engine should actually spend GPU on: whatever a
|
|
listener currently has open, plus anything pinned. Nobody listening in
|
|
Icelandic means nobody pays for Icelandic. */
|
|
/*
|
|
* Does anyone want the live-microphone English feed? Source-language listeners
|
|
* who have speech ON. An idle English tab reading captions costs nothing.
|
|
*/
|
|
function micWanted() {
|
|
if (!state.live) return false;
|
|
for (const meta of state.listeners.values()) {
|
|
if ((meta.lang || config.sourceLanguage) === config.sourceLanguage && meta.speaking) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function activeLanguages() {
|
|
const set = new Set(config.pinned || []);
|
|
for (const meta of state.listeners.values()) {
|
|
if (meta.lang && meta.lang !== config.sourceLanguage) set.add(meta.lang);
|
|
}
|
|
const langs = Array.from(set);
|
|
/* A language nobody is listening to should not cost a timer or a byte. The
|
|
source language rides along while the microphone feed is wanted — its
|
|
stream carries the room audio at the mic's own 16k, not a voice's rate. */
|
|
const keep = micWanted() ? langs.concat([config.sourceLanguage]) : langs;
|
|
if (state.live) {
|
|
liveAudio.retain(keep, (l) => l === config.sourceLanguage ? 16000 : Voices.sampleRateFor(l));
|
|
} else liveAudio.stopAll();
|
|
return langs;
|
|
}
|
|
|
|
let langPushTimer = null;
|
|
function pushLanguagesToEngine() {
|
|
if (langPushTimer) return;
|
|
/* Debounced: a crowd arriving at once would otherwise retune the engine
|
|
dozens of times in a second. */
|
|
langPushTimer = setTimeout(() => {
|
|
langPushTimer = null;
|
|
/* The counts ride along with the language list — the app shows the operator
|
|
who is actually reading, without needing a second channel. */
|
|
sendToEngine({
|
|
t: 'langs',
|
|
list: activeLanguages(),
|
|
micWanted: micWanted(),
|
|
source: config.sourceLanguage,
|
|
/* The glossary rides along too. Without it, an operator who fixes a
|
|
misheard name mid-service gets a "saved" toast while the engine keeps
|
|
using the old table until the next stop/start — the UI lies. */
|
|
glossary: config.glossary,
|
|
listeners: state.listeners.size,
|
|
byLanguage: listenerCounts(),
|
|
/* Debounced on the same timer as the language list, so a crowd all
|
|
switching voice at once retunes the engine once, not per phone. */
|
|
voiceDemand: voiceDemand(),
|
|
detail: listenerDetail()
|
|
});
|
|
}, 250);
|
|
}
|
|
|
|
function sendToEngine(msg) {
|
|
if (state.engine && state.engine.readyState === 1) {
|
|
try { state.engine.send(JSON.stringify(msg)); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
/* The engine reports every 3 seconds. A socket that is still open but has gone
|
|
quiet means the Mac app is wedged — which looks exactly like "working fine"
|
|
unless we say otherwise. */
|
|
const ENGINE_STALE_MS = 12000;
|
|
|
|
/*
|
|
* One traffic light a volunteer can act on. "27153 ms behind" on a status line
|
|
* means nothing to someone running sound for the first time; red/amber/green
|
|
* with one plain sentence does. Computed here so every operator surface —
|
|
* in-app pane, browser page — shows the same verdict.
|
|
*/
|
|
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.' };
|
|
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.' };
|
|
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'));
|