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:
2026-09-23 15:41:21 -04:00
co-authored by Claude Opus 5
commit c9eef0672c
118 changed files with 36031 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
/*
* voices.js — which languages get a built-in voice, and where it comes from.
*
* Text and audio do NOT cover the same set of languages, and that asymmetry is
* a fact of the product rather than a gap to be papered over. NLLB translates
* ~200 languages; the best free offline TTS covers about 50. So some listeners
* read a language the Mac can speak, and some read one it cannot — the page has
* to say which, honestly, rather than staying silent and looking broken.
*
* Bundles are the pre-converted sherpa-onnx builds, which carry their own
* espeak-ng phoneme data so there is no native phonemizer to install:
* https://github.com/k2-fsa/sherpa-onnx/releases/tag/tts-models
*
* Licensing: Piper's code and the rhasspy/piper-voices weights are MIT — free to
* use and redistribute. TWO EXCEPTIONS, accepted knowingly for this church's
* non-commercial use: it_IT-miro-high and pt_PT-miro-high are CC BY-NC-SA
* (OpenVoiceOS synthetic voices). Still deliberately NOT included: Meta's MMS
* voices, which cover 1000+ languages (including Haitian Creole) but are CC-BY-NC. A church is
* a nonprofit and a reasonable person would call this non-commercial, but
* "reasonable person" is not a licence, so it stays out until someone decides
* to make that call on purpose.
*/
'use strict';
const fs = require('fs');
const path = require('path');
/* FLORES code -> sherpa bundle. Verified present in the tts-models release. */
const VOICES = {
/* lessac — Piper's shipped default — measures 211 Hz: a female register.
joe is 99 Hz, and audited a perfect 12/12 (char 1.000). */
eng_Latn: { bundle: 'vits-piper-en_US-joe-medium', model: 'en_US-joe-medium.onnx' },
/*
* speed 1.2: ald is measurably ~11% slower than pt_BR-jeff, the voice the
* congregation praised for its flow — and rate turned out to be the real
* difference between them, not pitch (they are register twins at ~149 Hz;
* every "monotone" metric failed to separate them under scrutiny). At 1.25
* ald matches jeff's chars/sec exactly; 1.2 sits just under that so a
* translated sermon does not feel rushed. Faster speech also directly
* reduces how often the live stream has to drop sentences to keep up.
*/
spa_Latn: { bundle: 'vits-piper-es_MX-ald-medium', model: 'es_MX-ald-medium.onnx', speed: 1.2 },
por_Latn: { bundle: 'vits-piper-pt_BR-jeff-medium', model: 'pt_BR-jeff-medium.onnx' },
/* European Portuguese — same translation as por_Latn (aliased in the
engine), read with a European voice: 97Hz male, 12/12 audit, espeak 'pt'
phonemization, quiet like its Italian sibling (hence the gain).
LICENSE NOTE: CC BY-NC-SA, same as it_IT-miro — accepted for this
church's non-commercial use; the licensing header above records it. */
por_PT: { bundle: 'vits-piper-pt_PT-miro-high', model: 'pt_PT-miro-high.onnx', gain: 3.9 },
/*
* Castilian Spanish. This is the ENTIRE difference between spa_ES and
* spa_Latn — the words are identical, the accent is not, and the accent is
* what a Spaniard notices first.
*
* davefx of the four Castilian options, chosen on measurement rather than on
* name or licence alone. Against ald (the Latin American voice) speaking the
* same line: ald rms 0.138 / 16.1 chars-per-sec.
* davefx rms 0.153, 16.8 c/s -> matches at gain 0.93. MIT. 122 Hz male.
* sharvard rms 0.062 -> tops out at 0.096 even at maximum safe
* gain, i.e. audibly weaker than the room
* is used to.
* carlfm rms 0.071, x_low tier.
* miro rms 0.015 (needs gain 6.9) AND is CC BY-NC-SA.
* No speed correction: davefx is already within 4% of ald's rate.
*/
spa_ES: { bundle: 'vits-piper-es_ES-davefx-medium', model: 'es_ES-davefx-medium.onnx', gain: 0.93 },
/* Mexico. Same words as spa_Latn — the grammar is identical — and by default
the same voice, because ald IS the Mexican voice. It is a separate entry so
a Mexican listener sees their own country in the picker, and so the operator
can give the two different voices from the Voices card. */
spa_MX: { bundle: 'vits-piper-es_MX-ald-medium', model: 'es_MX-ald-medium.onnx', speed: 1.2 },
fra_Latn: { bundle: 'vits-piper-fr_FR-tom-medium', model: 'fr_FR-tom-medium.onnx' },
/* Mandarin is not an espeak language in any real sense — espeak's 'cmn'
dictionary gets tones wrong on core church vocabulary (基督, 请翻), and a
wrong tone is a different word. MeloTTS looks words up in a lexicon
instead, and ships rule FSTs so numbers and dates are read properly. */
/* melo is single-speaker female (238 Hz). zh-ll carries five speakers; sid 4
measured 100 Hz male and ~18% smoother than sid 1 (the documented fallback
if 4 disappoints the ear). Same lexicon frontend, same rule FSTs. */
zho_Hans: { bundle: 'sherpa-onnx-vits-zh-ll', model: 'model.onnx', frontend: 'lexicon',
ruleFsts: ['date.fst', 'number.fst', 'phone.fst'], sampleRate: 16000, sid: 4 },
vie_Latn: { bundle: 'vits-piper-vi_VN-vais1000-medium', model: 'vi_VN-vais1000-medium.onnx' },
arb_Arab: { bundle: 'vits-piper-ar_JO-kareem-medium', model: 'ar_JO-kareem-medium.onnx' },
rus_Cyrl: { bundle: 'vits-piper-ru_RU-ruslan-medium', model: 'ru_RU-ruslan-medium.onnx' },
/* NOT uk_UA-ukrainian_tts-medium: its 49-token vocabulary is Cyrillic
LETTERS with no IPA, yet its ONNX metadata claims has_espeak=1, so sherpa
phonemises to IPA that maps to nothing and emits ~0.4s of noise per
sentence — silently, with no error. Audited 0/12; lada scores 12/12. */
ukr_Cyrl: { bundle: 'vits-piper-uk_UA-lada-x_low', model: 'uk_UA-lada-x_low.onnx', sampleRate: 16000 },
deu_Latn: { bundle: 'vits-piper-de_DE-thorsten-medium', model: 'de_DE-thorsten-medium.onnx' },
pol_Latn: { bundle: 'vits-piper-pl_PL-darkman-medium', model: 'pl_PL-darkman-medium.onnx' },
ron_Latn: { bundle: 'vits-piper-ro_RO-mihai-medium', model: 'ro_RO-mihai-medium.onnx' },
swh_Latn: { bundle: 'vits-piper-sw_CD-lanfrica-medium', model: 'sw_CD-lanfrica-medium.onnx' },
/* Both male; the old one audited SUSPECT (8/12), ganji_adabi passes clean. */
pes_Arab: { bundle: 'vits-piper-fa_IR-ganji_adabi-medium', model: 'fa_IR-ganji_adabi-medium.onnx' },
tur_Latn: { bundle: 'vits-piper-tr_TR-dfki-medium', model: 'tr_TR-dfki-medium.onnx' },
hin_Deva: { bundle: 'vits-piper-hi_IN-pratham-medium', model: 'hi_IN-pratham-medium.onnx' },
npi_Deva: { bundle: 'vits-piper-ne_NP-google-medium', model: 'ne_NP-google-medium.onnx' },
/* paola is 205 Hz female; miro is 115 Hz male, high tier. Two measured
quirks wired in here: he renders ~37% slower than paola (speed 1.3
restores parity) and much quieter (peaks 0.13-0.21 vs 0.55+ — gain 2.2
brings him level without clipping). */
ita_Latn: { bundle: 'vits-piper-it_IT-miro-high', model: 'it_IT-miro-high.onnx', speed: 1.3, gain: 2.2 },
nld_Latn: { bundle: 'vits-piper-nl_BE-rdh-medium', model: 'nl_BE-rdh-medium.onnx' },
ces_Latn: { bundle: 'vits-piper-cs_CZ-jirka-medium', model: 'cs_CZ-jirka-medium.onnx' },
hun_Latn: { bundle: 'vits-piper-hu_HU-imre-medium', model: 'hu_HU-imre-medium.onnx' },
ell_Grek: { bundle: 'vits-piper-el_GR-rapunzelina-low', model: 'el_GR-rapunzelina-low.onnx' },
srp_Cyrl: { bundle: 'vits-piper-sr_RS-serbski_institut-medium', model: 'sr_RS-serbski_institut-medium.onnx' },
kat_Geor: { bundle: 'vits-piper-ka_GE-natia-medium', model: 'ka_GE-natia-medium.onnx' },
/* iseke was male but x_low tier (the robotic class); issai-high sid 1 is the
same register at a proper quality tier. */
kaz_Cyrl: { bundle: 'vits-piper-kk_KZ-issai-high', model: 'kk_KZ-issai-high.onnx', sid: 1 },
swe_Latn: { bundle: 'vits-piper-sv_SE-nst-medium', model: 'sv_SE-nst-medium.onnx' },
dan_Latn: { bundle: 'vits-piper-da_DK-talesyntese-medium', model: 'da_DK-talesyntese-medium.onnx' },
fin_Latn: { bundle: 'vits-piper-fi_FI-harri-medium', model: 'fi_FI-harri-medium.onnx' },
/* pau is x_low tier but audited best-in-class (12/12, char 0.995) — the tier
label undersells this particular voice. 16 kHz bundle. */
cat_Latn: { bundle: 'vits-piper-ca_ES-upc_pau-x_low', model: 'ca_ES-upc_pau-x_low.onnx', sampleRate: 16000 },
/* ur_PK-uk-medium is a 404 in the tts-models release — this download failed
every time, and because hasVoice() answers from this table the listener
page still claimed a built-in voice and suppressed the device fallback, so
Urdu listeners got silence with no way out of it. */
urd_Arab: { bundle: 'vits-piper-ur_PK-fasih-medium', model: 'ur_PK-fasih-medium.onnx' },
/* Not a Piper voice — Piper has no Korean in the pre-converted set, and
Korean is a language this church actually serves. mimic3 is the ready
alternative in the same release. */
kor_Hang: { bundle: 'vits-mimic3-ko_KO-kss_low', model: 'ko_KO-kss_low.onnx' }
};
const BASE = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/';
function hasVoice(flores) { return Object.prototype.hasOwnProperty.call(VOICES, flores); }
function voiceFor(flores) { return VOICES[flores] || null; }
function bundleUrl(bundle) { return BASE + bundle + '.tar.bz2'; }
/*
* The rate a voice renders at, needed BEFORE it has ever spoken.
*
* The live stream has to be running and audible from the moment someone picks
* a language — a stream that only starts at the first sentence leaves the page
* silent, and iOS suspends a silent page after ten seconds, which kills the
* socket that would have delivered that first sentence. So the rate cannot be
* discovered from the audio; it has to be declared.
*
* 22050 is the Piper default and correct for almost all of them. The two that
* differ are declared above. A wrong guess is recoverable — the stream rebuilds
* on the first clip — but it costs a hiccup, so keep this honest.
*/
function sampleRateFor(lang) {
const v = VOICES[lang];
return (v && v.sampleRate) || 22050;
}
/*
* The sherpa config for one voice — the SINGLE definition of it.
*
* This lives here rather than in tts-service.js because tools/voice-audit.mjs
* has to build the identical config: an audit that constructs its own is not
* auditing the app, and a copied config drifts silently. It drifted once
* already, which is how a Chinese voice that needs a lexicon was handed
* espeak data.
*
* Two frontends. espeak models phonemise to IPA and need espeak-ng-data;
* lexicon models look words up and need lexicon.txt plus dict/. Giving a model
* the wrong one makes sherpa hard-EXIT the process rather than throw, so this
* must be a branch, never a fallback.
*/
/*
* The .onnx a bundle actually contains. The declared name is right for Piper
* and wrong for the fanchen Chinese bundles, which name the model after the
* bundle (vits-zh-hf-fanchen-unity.onnx) rather than model.onnx — so a voice
* picked from the catalog failed with sherpa's bare "Please check your
* config!" and no clue which file was missing.
*/
function findModel(dir, declared) {
const has = (f) => { try { return f && fs.existsSync(path.join(dir, f)); } catch (_) { return false; } };
if (has(declared)) return declared;
let names = [];
try { names = fs.readdirSync(dir); } catch (_) { return null; }
const onnx = names.filter((f) => f.endsWith('.onnx'));
if (!onnx.length) return null;
/* Prefer full precision, then the conventional name, then whatever is there. */
const full = onnx.filter((f) => !/\.int8\.onnx$/.test(f));
const pool = full.length ? full : onnx;
return pool.indexOf('model.onnx') !== -1 ? 'model.onnx' : pool[0];
}
function ttsConfig(dir, v) {
const has = (f) => { try { return fs.existsSync(path.join(dir, f)); } catch (_) { return false; } };
/*
* The bundle ON DISK decides the frontend — not the file name, and not a
* flag in the table above, because the voice picker offers bundles that
* table never described. Every Chinese bundle ships lexicon.txt and no
* espeak-ng-data; every Piper bundle is the reverse. And the naming is no
* guide either: vits-piper-zh_CN-chaowen-medium is a Piper-NAMED bundle
* that is nonetheless lexicon-based. Looking is the only reliable test.
*/
const model = findModel(dir, v.model);
if (!model) {
throw new Error(path.basename(dir) + ' has no .onnx file — that bundle did not ' +
'download completely. Delete its folder and pick the voice again to refetch it.');
}
const lexical = has('lexicon.txt');
if (!lexical && !has('espeak-ng-data')) {
throw new Error(path.basename(dir) + ' has neither lexicon.txt nor espeak-ng-data — ' +
'this bundle is not a Piper or lexicon VITS voice and cannot be used.');
}
const vits = lexical
? Object.assign(
{ model: path.join(dir, model),
tokens: path.join(dir, 'tokens.txt'),
lexicon: path.join(dir, 'lexicon.txt') },
/* dict/ is the jieba word-segmentation dictionary. The fanchen and
zh-ll bundles ship it; chaowen does not, and passing a path that
does not exist aborts the load. */
has('dict') ? { dictDir: path.join(dir, 'dict') } : {})
: { model: path.join(dir, model),
tokens: path.join(dir, 'tokens.txt'),
dataDir: path.join(dir, 'espeak-ng-data') };
/* sherpa's own in-language normalisation for numbers, dates and phone
numbers. Detected rather than declared, for the same reason as above:
the catalog does not carry them. Missing files would abort the load. */
const ruleFsts = (v.ruleFsts || ['date.fst', 'number.fst', 'phone.fst'])
.filter(has)
.map((f) => path.join(dir, f))
.join(',');
return {
model: { vits, numThreads: 2, debug: false }, // threads capped: see tts-service.js
maxNumSentences: 1,
ruleFsts
};
}
function spokenLanguages() { return Object.keys(VOICES); }
module.exports = { VOICES, hasVoice, voiceFor, bundleUrl, spokenLanguages, ttsConfig, sampleRateFor };