115 lines
5.5 KiB
JavaScript
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 };
|
||
|
|
}));
|