2026-09-23 15:41:21 -04:00
/*
* 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.
*/
2026-09-24 12:36:30 -04:00
/*
* 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.
*/
2026-09-24 14:32:25 -04:00
/* 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 ;
2026-09-24 12:36:30 -04:00
function loadNote () {
const n = activeLanguages (). length ;
if ( n < 2 ) return '' ;
return ' Carrying ' + n + ' languages' +
( config . allowAllLanguages ? ', and listeners may add more' : '' ) + '.' ;
}
2026-09-23 15:41:21 -04:00
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.' };
2026-09-24 14:32:25 -04:00
/*
* 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 )
2026-09-24 12:36:30 -04:00
return { level : 'red' ,
detail : 'Falling badly behind — some sentences are captions only.' + loadNote () };
2026-09-24 14:32:25 -04:00
const recentDegraded = es . degradedAt && ( Date . now () - es . degradedAt ) < TROUBLE_WINDOW_MS ;
if (( es . lagMs || 0 ) > 6000 || ( es . queue || 0 ) >= 2 || recentDegraded )
2026-09-24 12:36:30 -04:00
return { level : 'amber' ,
detail : 'Running behind — idle languages may be trimmed to catch up.' + loadNote () };
2026-09-23 15:41:21 -04:00
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 ; }
2026-09-24 13:04:46 -04:00
/*
* 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 ;
}
2026-09-23 15:41:21 -04:00
// --- 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 ) => {
/* 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 || '' );
2026-10-15 16:05:15 -04:00
/*
* 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
});
2026-09-23 15:41:21 -04:00
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 ;
2026-10-15 16:05:15 -04:00
const next = String ( m . lang || config . sourceLanguage );
2026-09-23 15:41:21 -04:00
/*
* 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.
2026-10-15 16:05:15 -04:00
*
* 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.
2026-09-23 15:41:21 -04:00
*/
2026-10-15 16:05:15 -04:00
if ( next !== meta . lang ) meta . voice = '' ;
meta . lang = next ;
2026-09-23 15:41:21 -04:00
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 ),
2026-10-15 16:05:15 -04:00
voiceOptions : voiceOptionsFor ( meta . lang ), voice : meta . voice || '' ,
2026-09-23 15:41:21 -04:00
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' ));