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