Files
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

144 lines
6.5 KiB
JavaScript

/*
* speech-text.js — what the voice is given, as opposed to what the screen shows.
*
* Everything here exists because espeak-ng speaks characters it cannot
* pronounce rather than ignoring them. A single pair of markdown asterisks does
* not render as emphasis; it renders as the word "asterisco", twice, in the
* middle of a sentence. Measured: an asterisk pair nearly doubles clip length.
*
* This runs in the TTS child, deliberately, so the caption on the listener's
* phone keeps the original text. Someone READING "1 Corintios 13:4-7" wants
* exactly those characters; someone HEARING it wants "Primera de Corintios
* trece, del cuatro al siete". Normalising in the renderer would corrupt both.
*
* Two layers, and the split matters:
* - UNIVERSAL: symbols and URLs, which are noise in every language.
* - PER-LANGUAGE: scripture, ordinals and currency, which are grammar. A rule
* that is right in Spanish is wrong in German and meaningless in Korean, so
* these hang off the FLORES code and a language with no table gets only the
* universal layer. Adding a language means adding a table, never widening a
* regex.
*/
'use strict';
/* Characters espeak names aloud that carry no meaning in speech. Kept as a
character class rather than a word list because the failure is per-glyph.
DELETED, not replaced with a space: Chinese and Korean are written without
spaces, so spacing "**神**是爱" would split 神 off 是爱, and would tear a Korean
particle off its noun. Deleting is correct in both script families —
"**Dios**" and "**神**" both come back whole. */
const NOISE = /[*_#^~|\\`{}=+©®™]/g;
/* Angle-bracket tags go before the noise class: stripping < and > alone would
leave the tag name behind, so "<br>" would be spoken as the word "br". */
const TAGS = /<[^>]{1,80}>/g;
const ANGLES = /[<>]/g;
/* Spoken as "barra" / "arroba" and never meaningful mid-sentence. */
const SLASHES = /[/]+/g;
/* A URL read aloud is many seconds of spelled-out punctuation. The caption
still shows it, which is the only place a URL is usable anyway. */
const URLS = /\b(?:https?:\/\/|www\.)\S+/gi;
const EMAILS = /\b[^\s@]+@[^\s@]+\.[a-z]{2,}\b/gi;
/* Any letter or digit in any script. If nothing survives normalisation there is
nothing to say, and asking the engine to say nothing produces an audible
artifact rather than silence. */
const HAS_CONTENT = /[\p{L}\p{N}]/u;
/* Scripts without case, where the all-caps rule below is meaningless. */
const UNCASED = /[\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Thai}\p{Script=Devanagari}\p{Script=Georgian}\p{Script=Greek}]/u;
// ---------------------------------------------------------------- per-language
/*
* Spanish first because it is this church's second language and the one the
* congregation reported on. Each table is written against what a Spanish
* speaker expects to hear read from a pulpit, not against a dictionary.
*/
const ES_BOOKS = 'Corintios|Timoteo|Pedro|Juan|Reyes|Samuel|Crónicas|Tesalonicenses|Macabeos';
const ES_ORDINAL = { 1: 'Primera', 2: 'Segunda', 3: 'Tercera' };
const LANGUAGES = {
spa_Latn: {
rules: [
/* "1 Corintios" is written as a numeral and spoken as an ordinal. Read as
a cardinal it becomes "uno Corintios", which is not Spanish. */
[new RegExp('\\b([123])\\s+(' + ES_BOOKS + ')\\b', 'g'),
(_m, n, book) => ES_ORDINAL[n] + ' de ' + book],
/* A verse range: the hyphen is otherwise deleted outright, leaving two
loose numbers with nothing to say they were a range. */
[/(\d+)\s*[-–—]\s*(\d+)/g, '$1 al $2'],
[/\b(\d+)\s*(?:er|ro)\b/gi, (_m, n) => n === '1' ? 'primer' : n + 'º'],
[/\b1\s*[ªº]/gi, 'primera'], [/\b2\s*[ºo]\b/gi, 'segundo'], [/\b2\s*[ªa]\b/gi, 'segunda'],
[/\b3\s*[ºo]\b/gi, 'tercero'], [/\b3\s*[ªa]\b/gi, 'tercera'],
[/\bvv\./gi, 'versículos'], [/\bv\./gi, 'versículo'],
[/\bcaps\./gi, 'capítulos'], [/\bcap\./gi, 'capítulo'],
/* Spanish puts the unit after the amount; the symbol is written before it. */
[/\$\s*(\d[\d.,]*)/g, '$1 dólares'],
[/€\s*(\d[\d.,]*)/g, '$1 euros'],
[/&/g, ' y ']
]
},
eng_Latn: {
rules: [
[/\b([123])\s+(Corinthians|Timothy|Peter|John|Kings|Samuel|Chronicles|Thessalonians|Maccabees)\b/g,
(_m, n, book) => ({ 1: 'First', 2: 'Second', 3: 'Third' })[n] + ' ' + book],
[/(\d+)\s*[-–—]\s*(\d+)/g, '$1 through $2'],
[/\bvv\./gi, 'verses'], [/\bv\./gi, 'verse'],
[/\bch\./gi, 'chapter'], [/\bchap\./gi, 'chapter'],
[/\$\s*(\d[\d.,]*)/g, '$1 dollars'],
[/&/g, ' and ']
]
}
};
// ---------------------------------------------------------------- the pass
/*
* Returns the string to synthesise, or '' when there is nothing worth saying.
* '' is a real answer and the caller must treat it as "produce no clip" — an
* empty string handed to the engine emits a short burst of noise, not silence.
*/
function normalizeForSpeech(text, lang) {
let s = String(text == null ? '' : text);
s = s.replace(URLS, ' ').replace(EMAILS, ' ');
/* A regional variant reads with its base language's rules. Without this a
new code silently gets NO table and the numbered-book rule stops firing,
so "1 Corintios" is spoken as "uno Corintios" — the exact failure this
file exists to prevent. Canonical list: NLLB_ALIAS in engine.js. */
const VARIANT_OF = { por_PT: 'por_Latn', spa_ES: 'spa_Latn', spa_MX: 'spa_Latn' };
const table = LANGUAGES[lang] || LANGUAGES[VARIANT_OF[lang]];
if (table) for (const [re, to] of table.rules) s = s.replace(re, to);
s = s.replace(TAGS, ' ').replace(NOISE, '').replace(ANGLES, '').replace(SLASHES, ' ');
/* Shouting is phonemised differently from the same words in lower case, and
short all-caps words get spelled out letter by letter. Only for scripts
that have case at all. */
if (!UNCASED.test(s)) {
s = s.replace(/(?:\b[\p{Lu}\p{N}]{2,}\b[\s,.;:!?]*){2,}/gu, (run) => run.toLowerCase());
}
s = s.replace(/\s+/g, ' ').trim();
/* espeak takes final lengthening and sentence-final intonation from the
terminal punctuation, and ASR chunks cut by the VAD often arrive without
any — measured cost: 142 ms of final lengthening, gone, in 10/10 trials.
A period is the one addition that is always safe. */
if (/[\p{L}\p{N}]$/u.test(s)) s += '.';
return HAS_CONTENT.test(s) ? s : '';
}
module.exports = { normalizeForSpeech, LANGUAGES };