/* * 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 "
" 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 };