Files
church-translate/app/preach.js
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

115 lines
5.5 KiB
JavaScript

/*
* preach.js — plain English for the model, the preacher's words for the room.
*
* NLLB was trained on documents, not sermons. Handed a preaching idiom it
* translates the words and loses the instruction, and it does it fluently, so
* nobody watching a caption in a language they cannot read has any way to know.
* Measured against the shipped model, into Spanish, Portuguese and French:
*
* "Circle that in your Bible." -> spa "En su Biblia." VERB GONE
* fra "Envoyez-le dans..." (send it)
* por "Envolva isso..." (wrap it)
* "Walk it out." -> fra "Je vais sortir." (I'm leaving)
* "Let that sink in." -> spa "Deja que se hunde." (drowns)
* "Open up to the book of John."-> spa "...la obra de Juan." (the WORKS of John)
*
* Every entry below was measured before and after in at least three languages
* and kept only where the rewrite was better in more than one. "God showed up."
* was tested and REMOVED: "Dios apareció" was already right, so the rewrite was
* just a different guess. Do not add entries by intuition — measure them.
*
* CRITICAL: this rewrites ONLY what is sent to the translator. The English
* caption, the transcript archive and the operator's monitor all keep what was
* actually said. Normalising the source itself would put words in the
* preacher's mouth, and one source-side edit propagates to every language and
* the permanent record at once.
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.Preach = factory();
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
/* Longest and most specific first — "open up to the book of" has to win over
the general "open up to". Applied in order, each at most once per sentence. */
const RULES = [
/* Scripture navigation. "open up to" is the one that produced "obra". */
[/\bopen up to the book of\b/gi, 'turn to the book of'],
[/\bopen up to\b/gi, 'turn to'],
[/\bdrop down to\b/gi, 'go to'],
[/\bback up to\b/gi, 'go back to'],
[/\bcircle (that|this|it)\b/gi, 'mark $1'],
/* Instructions that became statements, or vanished. */
[/\blet (that|this|it) sink in\b/gi, 'think carefully about $1'],
[/\bwalk (it|that) out\b/gi, 'put $1 into practice'],
[/\bunpack (that|this|it)\b/gi, 'explain $1'],
[/\bcamp out here\b/gi, 'stay here'],
[/\bgive God a hand of praise\b/gi, 'applaud to praise God'],
[/\bput a hand on your neighbou?r\b/gi, 'put a hand on the person next to you'],
[/\bturn to your neighbou?r\b/gi, 'turn to the person next to you'],
/* Rhetorical checks. Wrong sense, not wrong words. */
[/\bare you tracking with me\b/gi, 'are you following me'],
[/\b(catch|get) (this|that)\b/gi, 'understand $2'],
[/\bthat will preach\b/gi, 'that is worth preaching']
];
/*
* A rewrite must not change how the sentence is capitalised — the target
* inherits sentence case from the source, and lowercasing the first word
* produced mid-sentence-looking captions.
*/
function forTranslation(text) {
let out = String(text == null ? '' : text);
if (!out) return out;
const had = out;
for (const [re, to] of RULES) out = out.replace(re, to);
if (out === had) return had;
/* Restore the opening capital if a rule replaced the first word. */
if (/^[a-z]/.test(out) && /^[A-Z]/.test(had)) out = out.charAt(0).toUpperCase() + out.slice(1);
return out;
}
/*
* THE TERMINAL PERIOD ON A SHORT UTTERANCE. One character, and it is the
* difference between a name and invented dialogue.
*
* Measured against the shipped model:
* "Peter." -> spa "¿Qué es eso?" | "Peter" -> "Pedro"
* "Jesus." -> spa "¿Qué quieres?" | "Jesus" -> "Jesús"
* "Matthew." -> spa "¿Qué es eso?" | "Matthew" -> "Mateo"
* "Habakkuk." -> fra "Habakuk est mort" | "Habakkuk" -> "Habakuk"
* "Simon Peter." -> spa "Sí, es el Simon Peter." | -> "Simón Pedro"
*
* With the period the model reads a complete conversational turn and answers
* it; without, it translates the term. A preacher naming people one at a time
* — "Peter. James. John." — got back a run of questions containing no names.
*
* Only the PERIOD is removed, and only from utterances of three words or
* fewer. "?" and "!" carry real meaning and are left alone, and a full
* sentence needs its punctuation for the model to parse it at all.
*/
const SHORT_WORDS_FOR_DOT = 3;
function stripShortPeriod(text) {
const s = String(text == null ? '' : text);
const t = s.trim();
if (!/[^.!?]\.$/.test(t)) return { text: s, hadPeriod: false };
if (t.split(/\s+/).filter(Boolean).length > SHORT_WORDS_FOR_DOT) return { text: s, hadPeriod: false };
return { text: t.slice(0, -1), hadPeriod: true };
}
/* Put the period back, so the caption and the voice both get a finished line
(espeak takes its final intonation from terminal punctuation). */
function restorePeriod(out, hadPeriod) {
const s = String(out == null ? '' : out).trim();
if (!hadPeriod || !s) return s;
return /[.!?\u3002\uff01\uff1f\u061f]$/.test(s) ? s : s + '.';
}
function rules() { return RULES.length; }
return { forTranslation, stripShortPeriod, restorePeriod, rules, RULES };
}));