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>
270 lines
12 KiB
JavaScript
270 lines
12 KiB
JavaScript
/*
|
||
* 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 };
|
||
}));
|