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:
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user