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>
144 lines
6.5 KiB
JavaScript
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 };
|