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:
+269
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* mt-guard.js — catch what NLLB invents, before a congregation hears it.
|
||||
*
|
||||
* WHY THIS IS NOT A GENERATION-PARAMETER FIX. Measured against the shipped
|
||||
* model (nllb-200-distilled-600M fp32), "Donald Trump." produces
|
||||
* "Donald Trump. ¿Qué es eso?" under ALL of: default params, an input-derived
|
||||
* max_new_tokens cap, no_repeat_ngram_size:3, and num_beams:2. The model
|
||||
* genuinely believes that is the translation. Decoding cannot be tuned out of
|
||||
* it; the output has to be checked.
|
||||
*
|
||||
* WHY THE OBVIOUS RULE IS WRONG. "Source has no '?', target has one, so strip
|
||||
* the question" corrupts real output: Whisper routinely drops the question mark
|
||||
* on a rhetorical aside, so "Are you with me" arrives unpunctuated and a
|
||||
* correct Spanish rendering is "¿Estás conmigo?" — which that rule would
|
||||
* delete. The reliable signal is not the question mark, it is the SENTENCE
|
||||
* COUNT: one sentence in, two out, and the second one invented.
|
||||
*
|
||||
* WHY ONLY SHORT INPUTS. Hallucination is a short-input failure — the model has
|
||||
* no context and fills the gap. Long sentences are also the ones that
|
||||
* legitimately split in the target (many languages prefer shorter sentences
|
||||
* than English), so clamping them would delete real scripture. The length gate
|
||||
* keeps the clamp where the evidence is.
|
||||
*/
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.MtGuard = factory();
|
||||
}(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
/* Sentence-final punctuation across the scripts this app serves: Latin,
|
||||
CJK ideographic stop, fullwidth marks, Arabic question mark, Devanagari
|
||||
danda, Armenian, Greek, Thai has none. */
|
||||
const TERMINATORS = '.!?。!?؟۔।॥…՜՞;';
|
||||
const TERM_RE = new RegExp('[' + TERMINATORS.replace(/[.\-\]\\]/g, '\\$&') + ']');
|
||||
const QUESTION_RE = /[??؟՞]/;
|
||||
|
||||
/* Beyond this the input carries enough context that the model stops
|
||||
inventing, and legitimate sentence-splitting starts. Measured failures all
|
||||
sit far below it. */
|
||||
/* Narrow deliberately. Every hallucination measured against this model sat
|
||||
at one or two words; a wider gate starts reaching utterances that split
|
||||
into two target sentences legitimately, and deleting real scripture is a
|
||||
far worse failure than leaving one invented clause in. */
|
||||
const SHORT_WORDS = 6;
|
||||
|
||||
function words(s) {
|
||||
return String(s || '').trim().split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
/* Count sentences by terminator runs. "Mr. Smith went." must not read as two,
|
||||
so a terminator followed by a lowercase letter does not end a sentence. */
|
||||
function sentenceCount(s) {
|
||||
const parts = splitSentences(s);
|
||||
return parts.length || 1;
|
||||
}
|
||||
|
||||
function splitSentences(s) {
|
||||
const text = String(s || '').trim();
|
||||
if (!text) return [];
|
||||
const out = [];
|
||||
let cur = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
cur += ch;
|
||||
if (!TERM_RE.test(ch)) continue;
|
||||
/* Absorb a run of terminators and any closing quote/bracket. */
|
||||
while (i + 1 < text.length && (TERM_RE.test(text[i + 1]) || /["'\)\]”’」』]/.test(text[i + 1]))) {
|
||||
cur += text[++i];
|
||||
}
|
||||
const rest = text.slice(i + 1);
|
||||
/* An abbreviation or a decimal: "Dr. Smith", "3.5" — not a boundary. */
|
||||
if (/^\s*[a-zà-ÿ0-9]/.test(rest)) continue;
|
||||
out.push(cur.trim());
|
||||
cur = '';
|
||||
}
|
||||
if (cur.trim()) out.push(cur.trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
/*
|
||||
* NLLB was trained on a lot of subtitles and emits their dialogue dash. It is
|
||||
* never correct here — nothing in this app is a screenplay.
|
||||
*/
|
||||
function stripDash(s) {
|
||||
return String(s || '').replace(/^\s*[-‐-―−]\s+/, '').trim();
|
||||
}
|
||||
|
||||
/*
|
||||
* The main entry. Returns { text, trimmed, reason } — `trimmed` is true when
|
||||
* something was removed, so callers can log it and the operator can see how
|
||||
* often the model is inventing.
|
||||
*/
|
||||
/*
|
||||
* A ONE-WORD source that comes back with a comma-clause. Measured across the
|
||||
* archive, 13.4% of everything the preacher says is one or two words, and the
|
||||
* model answers the short ones conversationally:
|
||||
*
|
||||
* "Hello." -> "Hola, ¿qué pasa?" "Yeah." -> "Sí, es cierto."
|
||||
* "No." -> "No, no." "Good." -> "Bien, muy bien."
|
||||
* "Come." -> "Ven, ven." "Look." -> "Mira, ¿qué?"
|
||||
*
|
||||
* One English word cannot mean two clauses, so the tail is invented. Keeping
|
||||
* only the head fixes all of these and touches nothing that was already
|
||||
* right ("La fe.", "Sin embargo.", "Por lo tanto." have no comma).
|
||||
*
|
||||
* Restricted to EXACTLY one word deliberately — at two words a comma can be
|
||||
* legitimate. Handles the CJK and ideographic commas too, since this is the
|
||||
* one output rule that needs no per-language knowledge.
|
||||
*/
|
||||
function trimInventedClause(src, out) {
|
||||
const words = String(src == null ? '' : src).replace(/[.!?,;:]+$/, '').split(/\s+/).filter(Boolean);
|
||||
if (words.length !== 1) return out;
|
||||
const m = String(out).match(/^([^,\uff0c\u3001]+)[,\uff0c\u3001]/u);
|
||||
if (!m) return out;
|
||||
const head = m[1].trim();
|
||||
if (!head) return out;
|
||||
const end = (String(src).match(/[.!?]$/) || [''])[0];
|
||||
return /[.!?\u3002\uff01\uff1f]$/.test(head) ? head : head + end;
|
||||
}
|
||||
|
||||
/*
|
||||
* THE WHOLE OUTPUT IS AN INVENTED QUESTION.
|
||||
*
|
||||
* The sentence-count rule above catches an invented question APPENDED to a
|
||||
* real translation ("Donald Trump. ¿Qué es eso?"). It cannot catch the case
|
||||
* where the invention IS the output: one sentence in, one sentence out, and
|
||||
* the one out is "¿Qué es eso?" for a source of "Mmm" or "them" — MEASURED
|
||||
* in a live service, spoken aloud to the Spanish congregation three times in
|
||||
* one morning, and produced by 34 distinct short sources in the archive.
|
||||
*
|
||||
* Narrow on purpose. Source: at most two words and not itself a question.
|
||||
* Output: every sentence is a question. And the source must not be an
|
||||
* English interrogative or a tag — "Why.", "Who.", "Right?", "You know." —
|
||||
* because for those a Spanish question IS the correct rendering (measured:
|
||||
* 'why.' -> '¿Por qué?', 'Isn't it.' -> '¿No es cierto?'). Whisper drops
|
||||
* question marks routinely, so the word list, not the punctuation, is the
|
||||
* exemption.
|
||||
*/
|
||||
/*
|
||||
* Widened with the auxiliaries and contractions that FRONT a real English
|
||||
* question, because the rule below now reads every word of a short source
|
||||
* rather than the single word it used to be limited to.
|
||||
*
|
||||
* MEASURED: without `could` and `you're`, widening the rule deleted
|
||||
* 'Could it be' -> '¿Podría ser' and "You're interested in" -> '¿Te interesa?'
|
||||
* — both correct renderings of questions Whisper handed over unpunctuated.
|
||||
* With them the widened rule breaks NOTHING in 22,526 real short lines.
|
||||
*/
|
||||
const INTERROGATIVE_SRC = /^(?:why|who|whom|whose|what|where|when|when'?s|how|which|isn'?t|right|okay|ok|see|really|huh|anybody|anyone|somebody|someone|guess|any|says|you|you'?re|we'?re|they'?re|there'?s|it'?s|are|am|is|was|were|do|does|did|can|could|will|would|shall|should|may|might|must|have|has|had|ready|let'?s)$/i;
|
||||
|
||||
function isQuestionSentence(p) {
|
||||
const t = String(p || '').trim();
|
||||
return /^[¿]/.test(t) || /[??؟՞]["'\)\]”’」』]*$/.test(t);
|
||||
}
|
||||
|
||||
function bareSource(src) {
|
||||
return String(src || '').replace(/^[\s"'“‘(\[]+/u, '').replace(/[.!,;:…\-\s"'”’)\]]+$/u, '').trim();
|
||||
}
|
||||
|
||||
/* Short enough that the model had no context to work from. Four, measured:
|
||||
see the numbers below. */
|
||||
const INVENTED_MAX_WORDS = 4;
|
||||
|
||||
/*
|
||||
* UP TO FOUR words, not one.
|
||||
*
|
||||
* This was restricted to a single word because a two-word rule was measured
|
||||
* blanking 'Why not.' -> '¿Por qué no?', 'Guess what.' -> '¿Adivina qué?',
|
||||
* 'You ready.' -> '¿Están listos?'. That finding was right; the conclusion
|
||||
* drawn from it was too narrow. What those cases have in common is not their
|
||||
* LENGTH, it is that they contain an interrogative word — so the exemption
|
||||
* below, not the word count, is what protects them. Testing every word of
|
||||
* the source instead of only a lone one keeps all of them and frees the
|
||||
* count to rise.
|
||||
*
|
||||
* MEASURED over this church's own archive — 349 confirmed inventions and
|
||||
* 22,526 legitimate short translations from ten real services:
|
||||
*
|
||||
* one word (before) 198/349 caught 57% 0 legitimate lines broken
|
||||
* <= 4 words 254/349 caught 73% 0 legitimate lines broken
|
||||
*
|
||||
* The congregation was hearing 'Check, check.' as '¿Qué quieres?', 'Holy
|
||||
* Spirit.' as '我們必須要做什麼?' and 'Hello.' as '¿Qué pasa?' — all of them
|
||||
* two words, all of them one word past where this rule used to stop looking.
|
||||
*
|
||||
* A source of NO words reaches here too ('-', '...', a stray dash Whisper
|
||||
* emits between speakers). Nothing cannot mean a question, so it is not
|
||||
* exempted: an empty source with an all-question output is an invention by
|
||||
* definition.
|
||||
*/
|
||||
function inventedQuestion(src, out) {
|
||||
if (QUESTION_RE.test(src)) return false;
|
||||
const words = bareSource(src).split(/\s+/).filter(Boolean);
|
||||
if (words.length > INVENTED_MAX_WORDS) return false;
|
||||
/* ANY interrogative word, anywhere in the source — 'Guess what.' and
|
||||
'Why not.' put it first, 'How about now' and 'And why' do not. */
|
||||
if (words.some(function (w) { return INTERROGATIVE_SRC.test(w); })) return false;
|
||||
const parts = splitSentences(out);
|
||||
return parts.length > 0 && parts.every(isQuestionSentence);
|
||||
}
|
||||
|
||||
/*
|
||||
* THE PORTUGUESE TWIN. NLLB's stock invention for a fragment in Portuguese is
|
||||
* not a question but the statement "Não." ('See.' -> 'Não.', 'Wow.' ->
|
||||
* 'Não.', 'way.' -> 'Não.', 'You understand?' -> '- Não.' — measured in
|
||||
* the archive), and the Spanish "No, no lo sé." family. A short source with
|
||||
* no negation in it cannot mean "No".
|
||||
*/
|
||||
const NEGATION_SRC = /\b(?:no|not|never|nope|nah|nay|nix|negative|nothing|none|nobody|neither|nor|don'?t|can'?t|won'?t|isn'?t|ain'?t|didn'?t|doesn'?t|wasn'?t|aren'?t|couldn'?t|wouldn'?t|shouldn'?t)\b|\b(?:uh|nuh)-?uh\b/i;
|
||||
const NEGATION_OUT = /^[\s¿¡\-‐-―−"'“”]*(?:não|nao|no|non|nein|nee|niet|nej|nie|нет|いいえ|不|아니요|لا)[\s.!?…"'”]*$/iu;
|
||||
|
||||
function inventedNegation(src, out) {
|
||||
const bare = bareSource(src);
|
||||
const words = bare.split(/\s+/).filter(Boolean);
|
||||
if (!words.length || words.length > 2) return false;
|
||||
if (NEGATION_SRC.test(bare)) return false;
|
||||
return NEGATION_OUT.test(String(out || ''));
|
||||
}
|
||||
|
||||
function clean(source, target) {
|
||||
const src = String(source == null ? '' : source).trim();
|
||||
let out = stripDash(target);
|
||||
if (!out) return { text: '', trimmed: false, reason: '' };
|
||||
|
||||
const dashed = out !== String(target || '').trim();
|
||||
|
||||
const oneWord = trimInventedClause(src, out);
|
||||
if (oneWord !== out) {
|
||||
return { text: oneWord, trimmed: true,
|
||||
reason: 'dropped invented clause after a one-word line \u2014 "' + out + '"' };
|
||||
}
|
||||
|
||||
if (inventedQuestion(src, out)) {
|
||||
return { text: '', trimmed: true,
|
||||
reason: 'dropped an invented question for a short line \u2014 "' + out + '"' };
|
||||
}
|
||||
if (inventedNegation(src, out)) {
|
||||
return { text: '', trimmed: true,
|
||||
reason: 'dropped an invented negation for a short line \u2014 "' + out + '"' };
|
||||
}
|
||||
|
||||
if (words(src) > SHORT_WORDS) {
|
||||
return { text: out, trimmed: dashed, reason: dashed ? 'subtitle dash' : '' };
|
||||
}
|
||||
|
||||
const nSrc = sentenceCount(src);
|
||||
const parts = splitSentences(out);
|
||||
if (parts.length <= nSrc) {
|
||||
return { text: out, trimmed: dashed, reason: dashed ? 'subtitle dash' : '' };
|
||||
}
|
||||
|
||||
/*
|
||||
* More sentences came out than went in, on an input too short to justify
|
||||
* it. Keep the first nSrc — NLLB translates in order, so the real content
|
||||
* is at the front and the invention is appended.
|
||||
*/
|
||||
const kept = parts.slice(0, nSrc).join(' ').trim();
|
||||
const dropped = parts.slice(nSrc).join(' ').trim();
|
||||
if (!kept) return { text: out, trimmed: dashed, reason: '' };
|
||||
return {
|
||||
text: kept,
|
||||
trimmed: true,
|
||||
reason: 'dropped invented ' + (QUESTION_RE.test(dropped) ? 'question' : 'text') +
|
||||
' "' + dropped + '"'
|
||||
};
|
||||
}
|
||||
|
||||
return { clean, splitSentences, sentenceCount, stripDash, trimInventedClause, inventedQuestion, inventedNegation, SHORT_WORDS };
|
||||
}));
|
||||
Reference in New Issue
Block a user