Files
church-translate/app/server/public/operator.js
T
christianandClaude Opus 5 c9eef0672c 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>
2026-09-23 15:41:21 -04:00

334 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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();
}
})();