Church Translate, as it runs at the campuses

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>
This commit is contained in:
2026-09-23 15:41:21 -04:00
co-authored by Claude Opus 5
commit c9eef0672c
118 changed files with 36031 additions and 0 deletions
+333
View File
@@ -0,0 +1,333 @@
/*
* operator.js — the sound desk console.
*
* Everything here is secondary to one thing: the big button has to be right,
* and it has to tell the truth about whether the system is actually working.
* A green "START" on a Sunday when the Mac engine is closed would be worse
* than no button at all, so the button disables itself when there is no engine.
*/
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var PIN_KEY = 'ct.pin';
var app = { pin: null, cfg: null, status: null, ws: null, base: location.origin, elapsedTimer: null };
var toastTimer = null;
function toast(msg) {
var t = $('toast');
t.textContent = msg;
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { t.hidden = true; }, 2600);
}
function api(path, opts) {
opts = opts || {};
opts.headers = Object.assign({ 'X-Operator-Pin': app.pin }, opts.headers || {});
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts).then(function (r) {
return r.json().then(function (j) {
if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
return j;
});
});
}
function save(patch, okMsg) {
return api('/api/config', { method: 'POST', body: JSON.stringify(patch) })
.then(function (j) { app.cfg = j.config; toast(okMsg || 'Saved'); return j; })
.catch(function (e) { toast('Could not save: ' + e.message); throw e; });
}
// ───────────────────────────────────────────── gate
$('gateForm').addEventListener('submit', function (e) {
e.preventDefault();
var pin = $('pin').value.trim();
if (!pin) return;
app.pin = pin;
api('/api/config')
.then(function (j) {
try { sessionStorage.setItem(PIN_KEY, pin); } catch (_) {}
$('gateErr').hidden = true;
enterConsole(j);
})
.catch(function () {
app.pin = null;
$('gateErr').textContent = 'That PIN was not accepted.';
$('gateErr').hidden = false;
$('pin').value = '';
$('pin').focus();
});
});
function enterConsole(j) {
app.cfg = j.config;
app.status = j.status;
app.base = (j.config.publicUrl || j.publicBase || location.origin).replace(/\/+$/, '');
$('gate').classList.remove('is-active');
$('console').classList.add('is-active');
$('totalLangs').textContent = Languages.LANGS.length;
renderConfig();
renderStatus(j.status);
connect();
}
// ───────────────────────────────────────────── status + big button
function fmtElapsed(since) {
if (!since) return '—';
var s = Math.max(0, Math.floor((Date.now() - since) / 1000));
var m = Math.floor(s / 60);
return m + ':' + String(s % 60).padStart(2, '0');
}
/* One chip per language actually being read, busiest first. */
function renderWho(rows) {
var box = $('whoList');
box.textContent = '';
if (!rows.length) { box.hidden = true; return; }
box.hidden = false;
rows.forEach(function (row) {
var l = Languages.get(row.code);
var el = document.createElement('span');
el.className = 'who';
var n = document.createElement('b');
n.textContent = row.count;
var name = document.createElement('span');
name.textContent = l ? l.native : row.code;
el.appendChild(n);
el.appendChild(name);
box.appendChild(el);
});
}
function renderStatus(st) {
if (!st) return;
app.status = st;
$('svcName').textContent = st.serviceName || 'Live Translation';
/* The traffic light — computed on the server so every surface agrees. */
var hl = $('opHealth');
if (hl) {
hl.hidden = !st.health;
if (st.health) {
hl.setAttribute('data-level', st.health.level);
hl.textContent = st.health.detail;
}
}
var live = !!st.live;
var ready = !!st.engineConnected;
var btn = $('bigBtn');
btn.dataset.live = live ? 'true' : 'false';
btn.disabled = !ready && !live;
btn.querySelector('.big-label').textContent = live ? 'STOP TRANSLATING' : 'START TRANSLATING';
var stale = !!st.engineStale;
$('bigSub').textContent = stale
? 'The Mac engine has stopped responding'
: live ? 'On air — the congregation is reading'
: (ready ? 'Engine ready' : 'Waiting for the Mac engine');
/* Connected-but-silent is the dangerous state: the socket is up, so
everything looks healthy, but nothing is being transcribed. */
$('engineWarn').hidden = ready && !stale;
if (stale) {
$('engineWarn').querySelector('strong').textContent = 'The Mac engine is connected but silent.';
$('engineWarn').querySelector('span').textContent =
'It has not reported for over 10 seconds. Quit and reopen Church Translate Engine on the Mac Studio.';
} else if (!ready) {
$('engineWarn').querySelector('strong').textContent = 'The Mac engine is not connected.';
$('engineWarn').querySelector('span').textContent =
'Open Church Translate Engine on the backstage Mac Studio. Nothing will translate until it is running.';
}
$('stListeners').textContent = st.listeners || 0;
$('stLangs').textContent = (st.activeLanguages || []).length;
$('stElapsed').textContent = fmtElapsed(st.startedAt);
renderWho(st.listenersByLanguage || []);
clearInterval(app.elapsedTimer);
if (live && st.startedAt) {
app.elapsedTimer = setInterval(function () {
$('stElapsed').textContent = fmtElapsed(st.startedAt);
}, 1000);
}
var es = st.engineStatus || {};
var bits = [];
if (es.device) bits.push('🎙 ' + es.device);
if (es.model) bits.push('🧠 ' + es.model);
if (typeof es.peakDb === 'number' && es.peakDb > -99) {
/* Peak over the last few seconds — an instantaneous sample lands between
syllables and reads as silence on a working feed. */
bits.push('📶 ' + es.peakDb + ' dB peak' + (es.peakDb < -40 ? ' (low)' : ''));
} else if (typeof es.rms === 'number') {
bits.push('📶 ' + (es.rms > 0.002 ? 'audio present' : 'silent'));
}
if (typeof es.lagMs === 'number') bits.push('⏱ ' + Math.round(es.lagMs) + ' ms behind');
$('engineInfo').textContent = bits.join(' ');
$('engineInfo').hidden = bits.length === 0;
}
$('bigBtn').addEventListener('click', function () {
if (!app.ws || app.ws.readyState !== 1) { toast('Not connected to the server.'); return; }
app.ws.send(JSON.stringify({ t: 'toggle' }));
});
function connect() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var ws = new WebSocket(proto + '//' + location.host + '/ws?role=operator&pin=' + encodeURIComponent(app.pin));
app.ws = ws;
ws.onmessage = function (ev) {
var m;
try { m = JSON.parse(ev.data); } catch (_) { return; }
if (m.t === 'status') renderStatus(m.status);
};
ws.onclose = function () {
if (app.ws !== ws) return;
app.ws = null;
setTimeout(connect, 2000);
};
}
// ───────────────────────────────────────────── languages
function chip(code, onRemove) {
var l = Languages.get(code) || { native: code, name: code };
var el = document.createElement('span');
el.className = 'chip';
el.textContent = l.native === l.name ? l.native : l.native + ' · ' + l.name;
var x = document.createElement('button');
x.type = 'button';
x.setAttribute('aria-label', 'Remove ' + l.name);
x.textContent = '×';
x.addEventListener('click', function () { onRemove(code); });
el.appendChild(x);
return el;
}
function renderConfig() {
var cfg = app.cfg;
var f = $('featuredList');
f.textContent = '';
(cfg.featured || []).forEach(function (code) {
f.appendChild(chip(code, function (c) {
save({ featured: cfg.featured.filter(function (x) { return x !== c; }) })
.then(renderConfig);
}));
});
var p = $('pinnedList');
p.textContent = '';
(cfg.pinned || []).forEach(function (code) {
p.appendChild(chip(code, function (c) {
save({ pinned: cfg.pinned.filter(function (x) { return x !== c; }) })
.then(renderConfig);
}));
});
$('bias').value = ((cfg.glossary && cfg.glossary.fixes) || []).join('\n');
$('publicUrl').value = cfg.publicUrl || '';
var base = (cfg.publicUrl || app.base).replace(/\/+$/, '');
$('qr').src = '/qr.svg?url=' + encodeURIComponent(base + '/');
$('posterLink').href = '/poster?pin=' + encodeURIComponent(app.pin);
var key = encodeURIComponent(cfg.controlToken);
$('urlToggle').textContent = base + '/api/control/toggle?key=' + key;
$('urlStart').textContent = base + '/api/control/start?key=' + key;
$('urlStop').textContent = base + '/api/control/stop?key=' + key;
}
var searchTimer = null;
$('langSearch').addEventListener('input', function (e) {
var q = e.target.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(function () { renderLangResults(q); }, 90);
});
function renderLangResults(q) {
var box = $('langResults');
box.textContent = '';
if (!q.trim()) return;
Languages.search(q).slice(0, 25).forEach(function (l) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = l.native;
var s = document.createElement('span');
s.textContent = l.name;
b.appendChild(s);
b.addEventListener('click', function () {
var featured = (app.cfg.featured || []).slice();
if (featured.indexOf(l.code) === -1) featured.push(l.code);
save({ featured: featured }, l.name + ' added').then(function () {
$('langSearch').value = '';
box.textContent = '';
renderConfig();
});
});
box.appendChild(b);
});
}
// ───────────────────────────────────────────── the rest
$('saveGlossary').addEventListener('click', function () {
var lines = $('bias').value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
/* Warn rather than silently drop: a line without "=>" is not a correction
and would otherwise look saved while doing nothing. */
var bad = lines.filter(function (l) { return l.indexOf('=>') === -1; });
var good = lines.filter(function (l) { return l.indexOf('=>') !== -1; });
var glossary = Object.assign({}, app.cfg.glossary, { fixes: good });
save({ glossary: glossary }, bad.length
? (good.length + ' saved · ' + bad.length + ' line(s) ignored, they need "=>"')
: (good.length + ' corrections saved'));
});
$('savePublic').addEventListener('click', function () {
save({ publicUrl: $('publicUrl').value.trim() }, 'Address saved').then(renderConfig);
});
$('savePin').addEventListener('click', function () {
var pin = $('newPin').value.trim();
if (pin.length < 4) { toast('Use at least 4 characters.'); return; }
save({ operatorPin: pin }, 'PIN updated').then(function () {
app.pin = pin;
try { sessionStorage.setItem(PIN_KEY, pin); } catch (_) {}
$('newPin').value = '';
renderConfig();
});
});
document.addEventListener('click', function (e) {
var btn = e.target.closest('.copy');
if (!btn) return;
var text = $(btn.dataset.copy).textContent;
navigator.clipboard.writeText(text)
.then(function () { toast('Copied'); })
.catch(function () { toast('Copy failed — select it by hand'); });
});
// Resume a session on reload without retyping the PIN.
var saved = null;
try { saved = sessionStorage.getItem(PIN_KEY); } catch (_) {}
if (saved) {
app.pin = saved;
api('/api/config').then(enterConsole).catch(function () {
app.pin = null;
try { sessionStorage.removeItem(PIN_KEY); } catch (_) {}
$('pin').focus();
});
} else {
$('pin').focus();
}
})();