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:
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user