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
+75
View File
@@ -0,0 +1,75 @@
/*
* app-log.js — the persistent log this app never had.
*
* Every child (voice process, congregation server, translation model) writes
* to stdout, and main.js forwards that to ITS stdout — which, for an app
* launched from the Finder, is nowhere. So the morning a service went wrong
* there was a caption transcript and not one line saying what the voice
* process had decided to do. This module tees everything to
* <dataDir>/logs/app.log, rotated so it can never fill a disk.
*
* Deliberately boring: synchronous appends (a crash must not lose the line
* that explains it), a size cap, three generations, and every failure
* swallowed — logging must never be the thing that takes the service down.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const MAX_BYTES = 2 * 1024 * 1024; // rotate past 2 MB
const KEEP = 3; // app.log, app.log.1, app.log.2
function createAppLog(dir, name) {
name = name || 'app.log';
const file = path.join(dir, name);
let size = 0;
try { fs.mkdirSync(dir, { recursive: true }); } catch (_) {}
try { size = fs.statSync(file).size; } catch (_) { size = 0; }
function rotate() {
try {
for (let i = KEEP - 1; i >= 1; i--) {
const from = file + (i === 1 ? '' : '.' + (i - 1));
const to = file + '.' + i;
try { fs.renameSync(from, to); } catch (_) {}
}
} catch (_) {}
size = 0;
}
function stamp() {
const d = new Date();
const p = (n, w) => String(n).padStart(w || 2, '0');
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' +
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()) + '.' + p(d.getMilliseconds(), 3);
}
/* Write one or more lines under a source tag. Multi-line chunks from a
child's stdout are split so every line carries its own stamp and tag. */
function write(tag, chunk) {
try {
const text = String(chunk == null ? '' : chunk);
if (!text.trim()) return;
const lines = text.replace(/\r/g, '').split('\n').filter((l) => l.length);
const out = lines.map((l) => stamp() + ' [' + tag + '] ' + l).join('\n') + '\n';
/* Bytes, not characters: accented Spanish and CJK log lines are 2-3
bytes a character, and a character count let a generation reach 6 MB. */
const bytes = Buffer.byteLength(out, 'utf8');
if (size + bytes > MAX_BYTES) rotate();
fs.appendFileSync(file, out);
size += bytes;
} catch (_) { /* never let logging take anything down */ }
}
/* Attach to a child process: tee stdout/stderr to the file. Returns the
child for chaining. The caller may still forward to its own stdout. */
function tee(child, tag) {
if (child && child.stdout) child.stdout.on('data', (d) => write(tag, d));
if (child && child.stderr) child.stderr.on('data', (d) => write(tag + ':err', d));
return child;
}
return { write, tee, file, rotate };
}
module.exports = { createAppLog, MAX_BYTES, KEEP };