Files

1362 lines
64 KiB
JavaScript
Raw Permalink Normal View History

/*
* tts-service.js — the built-in voices, in a process of its own.
*
* Listeners used to hear their own device's voice, which is a lottery: a Mac has
* ~180 and Chrome adds Google's, an iPad ships roughly one per language until
* someone digs through Settings, and Apple's novelty voices sort first and sound
* like cartoons. The church's requirement was voices that are built in and ready
* for anyone, so the Mac speaks and the phones just play what it sends.
*
* Runs as a forked PLAIN NODE child for the same reason the translator does:
* Chromium's PartitionAlloc traps on the allocations a model load makes, killing
* any process inside Electron. See ensureTranslator() in main.js.
*
* Measured on this M2 Max, already under load:
* synthesis ~394 ms for 7.8 s of speech (20x real time)
* encoding ~67 ms — afconvert on macOS, bundled ffmpeg on Windows
* size 51 KB of AAC for 7.8 s, 6.6x smaller than the WAV
*
* Threads are capped on purpose. Unbounded, each session grabs all 12 cores and
* extra workers buy nothing — the same flat curve NLLB showed. Capped at two,
* synthesis scales properly across languages, because Piper is compute-bound on
* a 63 MB model that stays in cache rather than DRAM-bandwidth-bound like NLLB.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFile, execFileSync } = require('child_process');
const voices = require('./voices.js');
const catalog = require('./voice-catalog.js');
const { normalizeForSpeech } = require('./speech-text.js');
let sherpa = null;
/* Overridden by main.js's {type:'config'} message in the packaged app; this is
only the default for running this process standalone. Windows keeps the big
directories in LOCALAPPDATA — %APPDATA% is the ROAMING profile and a domain
PC would try to sync tens of gigabytes of voice bundles at every logon. */
let VOICE_DIR = process.platform === 'win32'
? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Church Translate Voices')
: path.join(os.homedir(), 'Library/Application Support/Church Translate Voices');
let CLIP_DIR = path.join(os.tmpdir(), 'church-translate-audio');
/* What each voice says when auditioned — a real line of scripture, so the
operator hears the voice on the material it will actually read. */
const SAMPLE_TEXT = {
eng_Latn: 'The Lord is my shepherd; I shall not want.',
spa_Latn: 'El Señor es mi pastor, nada me faltará.',
por_Latn: 'O Senhor é o meu pastor, nada me faltará.',
por_PT: 'O Senhor é o meu pastor, nada me faltará.',
spa_ES: 'El Señor es mi pastor; nada me faltará.',
spa_MX: 'El Señor es mi pastor; nada me faltará.',
fra_Latn: 'Le Seigneur est mon berger, je ne manquerai de rien.',
deu_Latn: 'Der Herr ist mein Hirte, mir wird nichts mangeln.',
ita_Latn: 'Il Signore è il mio pastore, non manco di nulla.',
zho_Hans: '耶和华是我的牧者,我必不致缺乏。',
kor_Hang: '여호와는 나의 목자시니 내게 부족함이 없으리로다.',
vie_Latn: 'Chúa là Đấng chăn giữ tôi, tôi sẽ chẳng thiếu thốn gì.',
arb_Arab: 'الرب راعيّ فلا يعوزني شيء.',
rus_Cyrl: 'Господь — Пастырь мой; я ни в чём не буду нуждаться.',
hat_Latn: 'Senyè a se gadò mwen, mwen p ap manke anyen.'
};
/*
* Loaded engines, ONE PER BUNDLE, shared by every language, variant and
* audition that speaks with it. Kept bounded, not small: MEASURED on an M2
* Max, an engine costs ~75 MB at construction and ~165 MB once it has spoken
* (8 engines, 1.3 GB after one sentence each), not the 330-400 MB this used
* to assume. The cap is sized to the largest campus — eight languages — so a
* live engine is never evicted to make room for another live one in normal
* service; below the cap an eviction means a reload of ~0.3-0.5 s on this
* process's only thread, in front of the next sentence, every time.
*
* `live` marks an engine a sentence or the preload asked for. Auditions keep
* to MAX_AUDITION slots of their own, always go first, and can never push a
* live engine out: with every slot speaking, an auditioned voice is built,
* played once and dropped. See engineFor() for why this is keyed by bundle.
*/
const loaded = new Map(); // 'bundle/model' -> { tts, usedAt, live }
const installing = new Map(); // bundle -> Promise: one download, however many ask
const MAX_LOADED = 8;
const MAX_AUDITION = 2;
/*
* The operator's per-language voice choices, made by ear in the Voices
* card: { spa_Latn: { engine: "local", speaker: "ryan" },
* kor_Hang: { engine: "builtin", bundle: "...", model: "...onnx" } }.
* A language with no choice uses the shipped default in voices.js.
*/
let voiceChoice = {};
/* The effective built-in voice for a language: the operator's pick if they
made one, otherwise the default. Cloud/local picks are handled earlier. */
function builtinVoiceFor(lang) {
const c = voiceChoice[lang];
if (c && c.engine === "builtin" && c.bundle) {
const base = voices.voiceFor(lang) || {};
/* Keep the default's tuning (speed/gain/sid) only when the operator
stayed on the same bundle; another bundle has its own character. */
return c.bundle === base.bundle ? base
: { bundle: c.bundle, model: c.model, speed: c.speed, gain: c.gain, sid: c.sid };
}
return voices.voiceFor(lang);
}
/*
* Resolve a listener's chosen voice KEY into a voice the synthesiser can use.
*
* The key is whatever identifies a voice uniquely for its engine: a `speaker`
* for Kokoro and the local sidecar, a `bundle` for a built-in.
*
* Looked up in the CATALOG rather than trusted. The key arrives from a phone
* on the other side of a WebSocket, and `forLanguage()` is where the no-female
* rule lives — resolving through it means a listener cannot name a voice the
* catalog filters out, cannot reach another language's voice, and cannot
* inject a path. An unknown key resolves to null and the caller falls back to
* the operator's own choice, which is the right failure.
*/
function voiceByKey(lang, key) {
if (!key) return null;
for (const v of catalog.forLanguage(lang) || []) {
const k = (v.engine === 'local' || v.engine === 'kokoro') ? v.speaker : v.bundle;
if (k && k === String(key)) return v;
}
return null;
}
function reply(msg) { try { if (process.send) process.send(msg); } catch (_) {} }
function log(msg) { process.stdout.write(msg + '\n'); }
// ---------------------------------------------------------------- voices
function voicePath(v) { return path.join(VOICE_DIR, v.bundle); }
function isInstalled(v) {
try { return fs.existsSync(path.join(voicePath(v), v.model)); } catch (_) { return false; }
}
/* Fetch and unpack one voice bundle. ~60-80 MB each, so this is deliberately on
demand rather than shipped in the app: a campus that serves Spanish and
Korean should not download thirty languages it will never speak. */
/*
* Fetch and unpack one voice bundle, WITHOUT shelling out.
*
* This used to be `curl` piped into `tar xjf`, which is fine on macOS and
* fatal on Windows: System32's tar.exe is a libarchive build linked against
* zlib ONLY — it cannot read bzip2 — and every one of the 100+ bundles is
* .tar.bz2 with no .tar.gz published. On Windows that meant no voice could
* ever install, and with the natural-voice sidecar being Apple-Silicon-only
* too, a Windows build would have had no voices at all.
*
* So: global fetch (follows GitHub's redirect) into unbzip2-stream into
* tar-stream. Verified byte-identical to `tar xjf` on a real 64 MB bundle —
* 359 files, same total bytes — and it is now the same code on both platforms.
*/
function installVoice(v) {
/*
* OFF THIS THREAD. The fetch, the bzip2 and the unpack all ran here, on the
* voice process's only thread, from inside engineFor() — so the first
* sentence of a language whose voice was not installed yet stalled every
* OTHER language's sentences for as long as the install took. MEASURED on a
* campus Mac, 2026-10-15 15:11: a listener tapped Chinese, the 121 MB zh
* model was fetched and unpacked three times at once on this thread, every
* stream stalled, 26 sentences were dropped and the catch-up rushed the
* Spanish voices to their +16% cap. The work now happens in a worker thread
* (voice-install.js); this thread only relays its progress and keeps
* speaking. The worker unpacks into a staging folder and renames it into
* place whole, so isInstalled() never sees a half-written bundle.
*/
return new Promise((resolve, reject) => {
const fail = (why) => reject(new Error('could not install ' + v.bundle + ': ' + why));
reply({ type: 'voiceProgress', bundle: v.bundle, status: 'downloading' });
let w;
try {
const { Worker } = require('worker_threads');
w = new Worker(path.join(__dirname, 'voice-install.js'), {
workerData: { url: voices.bundleUrl(v.bundle), voiceDir: VOICE_DIR, bundle: v.bundle }
});
} catch (e) { return fail(e.message); }
let settled = false;
const done = (err) => { if (settled) return; settled = true; err ? fail(err) : resolve(); };
w.on('message', (m) => {
if (!m || !m.status) return;
if (m.status === 'error') { done(m.message || 'the installer failed'); return; }
reply({ type: 'voiceProgress', bundle: v.bundle, status: m.status });
if (m.status === 'ready') done();
});
w.on('error', (e) => done((e && e.message) || String(e)));
w.on('exit', (code) => { if (!settled) done('the installer exited (' + code + ')'); });
});
}
/*
* The engine for one voice, built at most once however many ask for it.
*
* KEYED BY BUNDLE — and the old keying is what put batches of four
* "audition:" loads in the production log with nobody at the Voices card.
* Engines were keyed two ways: by language ('spa_Latn') for the preload, and
* 'audition:<bundle>' whenever a voice was passed in, which speak() did for
* EVERY ordinary sentence because the resolved default always has a bundle.
* So the live path was filed as an audition, every bundle was built at least
* twice (five languages asked for nine engines against a cap of six), the
* preload warmed copies no sentence ever read, and the first sentence of
* every service built the real ones on the spot. MEASURED on the Mini,
* 2026-10-15: the 14:26 service's first sentence was said at 14:54:26.291
* and its four engines were ready at 26.995, 27.309, 27.833 and 28.344 —
* ~0.3-0.5 s each on this process's only thread — while what got evicted
* was the startup preload's unused copies.
*
* The engine's config is a function of the bundle alone (voices.ttsConfig;
* speed, gain and sid are applied per sentence), so sharing is exact:
* spa_Latn and spa_MX read one es_MX-ald engine, and auditioning a voice that
* is already speaking reads the live engine instead of building a second.
*/
async function engineFor(v, live, who) {
if (!v || !v.bundle) throw new Error('no built-in voice' + (who ? ' for ' + who : ''));
const key = v.bundle + '/' + (v.model || '');
/*
* One download per bundle. A language joining mid-service is asked for by
* the preload AND by its first sentences at once, and each used to fetch
* and unpack the whole bundle itself — MEASURED on the Mini: pt_BR-jeff
* three times between 11:39:05 and 11:39:50, zh-ll three times between
* 15:11:17 and 15:12:16, two 'voice timed out' each time. The unpack is staged and
* renamed into place whole (voice-install.js), so isInstalled() stays false
* until the bundle is complete — but a second asker must still wait on the
* first install, not start its own.
*/
if (!loaded.has(key) && (installing.has(v.bundle) || !isInstalled(v))) {
if (!installing.has(v.bundle)) {
installing.set(v.bundle, installVoice(v).finally(() => installing.delete(v.bundle)));
}
await installing.get(v.bundle);
}
/* Looked up again: whoever finished waiting first has built it. Nothing can
interleave from here to the set — construction is synchronous. */
let e = loaded.get(key);
if (!e) {
if (!sherpa) sherpa = require('sherpa-onnx-node');
e = { tts: new sherpa.OfflineTts(voices.ttsConfig(voicePath(v), v)), usedAt: 0, live: !!live };
if (makeRoom(!!live)) loaded.set(key, e);
log('voice ready: ' + v.bundle + (who ? ' for ' + who : '') +
(live ? '' : loaded.get(key) === e ? ' (audition)' : ' (audition, not kept: every slot is speaking)'));
}
e.usedAt = Date.now();
/* An audition never demotes a live engine; a sentence promotes an auditioned one. */
if (live) e.live = true;
return e.tts;
}
/*
* Room for one more engine, or false when an audition should not take one.
* Least recently used goes first, as before — auditioned engines before live
* ones, and a live engine only ever gives way to another live one.
*/
function makeRoom(live) {
const stalest = (wantLive) => {
let k = null;
for (const [key, x] of loaded) if (x.live === wantLive && (!k || x.usedAt < loaded.get(k).usedAt)) k = key;
return k;
};
const evict = (k) => { log('evicted voice ' + k + (loaded.get(k).live ? '' : ' (audition)')); loaded.delete(k); };
const auditions = Array.from(loaded.values()).filter((x) => !x.live).length;
if (!live && auditions >= MAX_AUDITION) evict(stalest(false));
if (loaded.size < MAX_LOADED) return true;
const k = stalest(false) || (live ? stalest(true) : null);
if (!k) return false;
evict(k);
return true;
}
// ------------------------------------------------------------- cloud voices
/*
* Premium cloud voices, strictly optional and strictly guarded.
*
* The contract: when a provider is configured and healthy, a sentence is
* synthesized in the cloud and sounds like a person; when ANYTHING goes wrong
* — no key, network down, wallet drained, one slow request — the same sentence
* comes out of the local Piper voice instead, a beat later, and the service
* never goes silent. A circuit breaker stops the flip-flop failure mode:
* three consecutive failures park that provider for five minutes rather than
* letting every sentence race a 2.5s timeout and alternate voices.
*
* Keys live in the APP's config (never the congregation server's config,
* which is readable over the operator API and the internet tunnel).
*/
let cloud = { provider: 'off' };
let cloudFailStreak = 0; // = strikes.length, kept by name for the stats
let cloudParkedUntil = 0;
let parkedAt = 0; // when the park was set — a success from a request in flight then unparks
/* Each strike remembers when its request was SENT, so a later success can
prove the sidecar was alive while it waited (contention, not death). */
let strikes = [];
let cloudStats = { ok: 0, failed: 0, fallback: 0 };
let cloudInFlight = 0;
let lastCloudError = '';
/*
* Languages the local engine has already spoken once. Measured: the first
* sentence in a language costs ~6s (the sidecar loads that language lazily)
* while every later one lands under 1s. A single timeout for both cases
* either fell back on every first sentence or waited absurdly long on
* every later one, so the first call in a language gets a generous budget
* and the rest get a strict one.
*/
const localWarm = new Set();
const CLOUD_MAX_INFLIGHT = 4; // fish's starter tier allows 5 concurrent
const CLOUD_TIMEOUT_MS = 2500; // beyond this, Piper is the better answer
/*
* THE SIDECAR'S OWN CLOCK, and the morning it cost a service its voice.
*
* MEASURED on the shipped build: the very first Kokoro answer after the sidecar
* starts takes ~4.3 s (load_model 0.2 s + the first generate for a language
* 4.1 s). Its budget was the fixed 2.5 s above. That one sentence is spoken in
* three byte-identical Spanish variants, so ONE cold sentence produced three
* aborts, three aborts tripped the breaker, and every premium voice was parked
* for five minutes — the congregation heard the Piper default, then Alex came
* back "on its own" when the clock ran out. The app had been up 92 minutes; only
* the Qwen3 model had ever been warmed.
*
* So budgets are now per (provider, language) PAIR and the first attempt on a
* cold pair is generous, once. Everything stays under main.js's 15 s tts:speak
* wall INCLUDING a Piper fallback (~1-2.5 s under load): past that wall a
* sentence is not slow, it is SILENT, which is worse than the wrong voice.
*/
const COLD_FIRST_MS = { kokoro: 8000, local: 10000 };
const WARM_BASE_MS = { kokoro: 4000, local: 6000 };
const WARM_MAX_MS = { kokoro: 7000, local: 9000 };
const warmPairs = new Set(); // 'kokoro:e', 'local:spanish' — answered at least once
const coldTried = new Set(); // pairs whose one generous attempt is spent
let sidecarReady = false; // main.js tells us; nothing counts against the breaker before this
let lastSidecarOkAt = 0; // liveness: a later request succeeding proves an abort was contention
let localInFlight = 0; // Qwen3 requests only — Kokoro must not gate German onto Piper
let fallbackAt = 0; // when a premium sentence last fell back to Piper (for the operator)
let parkedReason = '';
function pairKey(pick, lang) {
return pick.provider === 'kokoro' ? 'kokoro:' + (KOKORO_LANG[lang] || lang)
: pick.provider === 'local' ? 'local:' + (LOCAL_LANG[lang] || lang)
: pick.provider;
}
/* How long this request may take before Piper is the better answer. */
function premiumBudget(pick, lang, explicit) {
if (explicit) return 90000; // an audition is the operator waiting on purpose
const p = pick.provider;
if (p !== 'kokoro' && p !== 'local') return CLOUD_TIMEOUT_MS;
/*
* While main.js has not declared the sidecar ready, FAIL FAST to Piper.
* Nothing counts as a strike in that window anyway, and the alternative —
* the generous cold budget on every sentence — made each one wait 8 s and
* left the whole language chain 8-9 s per sentence behind while the
* sidecar was still loading (reviewer's repro: 8547 / 8123 / 8115 ms).
*/
if (!sidecarReady) return CLOUD_TIMEOUT_MS;
const pair = pairKey(pick, lang);
if (!warmPairs.has(pair) && !coldTried.has(pair)) return COLD_FIRST_MS[p];
/* Warm, but the sidecar serialises: cover the other requests already in it
(~175 ms each for Kokoro) and a Qwen3 job (2-8 s at RTF 0.5-1.5) if one
is running. The per-job deadline in speak() is what keeps the sum under
main.js's 15 s wall. */
const others = Math.max(0, cloudInFlight - 1);
const qwen = (p === 'kokoro' && localInFlight > 0) ? 4000 : 0;
const cap = (p === 'kokoro' && localInFlight > 0) ? 9000 : WARM_MAX_MS[p];
return Math.min(cap, WARM_BASE_MS[p] + others * 500 + qwen);
}
/* Does any configured voice actually use the sidecar? A cloud-only (fish /
google) setup must not be told its "voices are starting". */
function needsSidecar() {
if (cloud && (cloud.provider === 'kokoro' || cloud.provider === 'local')) return true;
return Object.keys(voiceChoice).some((l) => {
const e = (voiceChoice[l] || {}).engine;
return e === 'kokoro' || e === 'local';
});
}
function isConnRefused(err) {
const c = err && err.cause && err.cause.code;
return c === 'ECONNREFUSED' || c === 'ECONNRESET' || /ECONNREFUSED/.test(String(err && err.message || ''));
}
/*
* The park's clock ran out. Without this the streak stayed at 3 after the
* five minutes, so the very next miss re-parked everything on ONE strike —
* the same five-minute wrong voice, through a different door.
*/
function expireParkIfDue() {
if (cloudParkedUntil && Date.now() >= cloudParkedUntil) {
cloudParkedUntil = 0; parkedAt = 0; parkedReason = '';
strikes = []; cloudFailStreak = 0;
log('premium voices unparked — park expired');
pushStats('unparked');
}
}
/* Degradation pushed to the app the moment it happens, not on the next 20 s tick. */
function pushStats(why) {
reply({ type: 'cloudStats', stats: Object.assign({}, cloudStats, {
parked: Date.now() < cloudParkedUntil, parkedUntil: cloudParkedUntil, parkedReason,
strikes: cloudFailStreak, fallbackAt, sidecarReady: needsSidecar() ? sidecarReady : null,
lastError: lastCloudError, provider: (cloud && cloud.provider) || 'off', why: why || ''
}) });
}
/*
* The local provider speaks Qwen3-TTS through an mlx-audio sidecar on this
* Mac — bought once for $0 (Apache-2.0 weights), no network, no metering.
* Its language names must be FULL WORDS: short codes silently fall through
* to auto-detect (measured — the difference between clean Spanish and a
* guess). Languages outside this map stay on Piper.
*/
/*
* Kokoro speaks in SINGLE-LETTER language codes, not words — a request with
* lang_code 'spanish' is rejected outright with
* AssertionError: ('spanish', {'a': 'American English', ... 'e': 'es', ...}).
* Its own table, because it shares nothing with the one below.
*
* MEASURED on this M2 Max: 3.50s of Spanish in 0.16s — RTF 0.05, and 0.12 with
* three streams at once. Roughly 3x faster than Piper and 60x faster than
* Qwen3, which is why it is the only realistic voice here that can cover every
* language at once.
*/
/* One table for both processes — see sidecar-langs.js for why. */
const { KOKORO_MODEL, KOKORO_LANG, LOCAL_LANG } = require('./sidecar-langs.js');
function cloudVoiceFor(lang) {
/*
* A voice the operator chose BY EAR for this language wins over the global
* provider setting — that is the whole contract of the Voices card. A
* built-in pick means "speak this language locally, whatever the premium
* provider is doing"; a premium pick names the exact speaker to use.
*/
expireParkIfDue();
const choice = voiceChoice[lang];
if (choice && choice.engine === 'builtin') return null;
if (choice && choice.engine === 'kokoro' && choice.speaker) {
return (Date.now() < cloudParkedUntil || !KOKORO_LANG[lang]) ? null
: { provider: 'kokoro', voice: choice.speaker };
}
if (choice && choice.engine === 'local' && choice.speaker) {
/* Honoured even when the global provider is 'off': choosing a premium
voice for ONE language is a legitimate setup (Spanish premium, the rest
built-in), and the sidecar is running either way. */
return Date.now() < cloudParkedUntil ? null
: { provider: 'local', voice: choice.speaker };
}
if (choice && (choice.engine === 'fish' || choice.engine === 'google') && choice.voice) {
return Date.now() < cloudParkedUntil ? null
: { provider: choice.engine, voice: choice.voice };
}
if (!cloud || cloud.provider === 'off' || !cloud.provider) return null;
if (Date.now() < cloudParkedUntil) return null;
const per = (cloud.perLang || {})[lang];
if (cloud.provider === 'fish') return (per || cloud.fishVoice) ? { provider: 'fish', voice: per || cloud.fishVoice } : null;
if (cloud.provider === 'google') return per ? { provider: 'google', voice: per } : null;
if (cloud.provider === 'local') return LOCAL_LANG[lang] ? { provider: 'local', voice: per || cloud.localVoice || 'aiden' } : null;
if (cloud.provider === 'kokoro') return KOKORO_LANG[lang] ? { provider: 'kokoro', voice: per || cloud.kokoroVoice || 'em_alex' } : null;
return null;
}
/* Concatenate WAVs that share a format (they come from one engine). */
function joinWavs(parts) {
if (parts.length === 1) return parts[0];
const rate = parts[0].readUInt32LE(24) || 24000;
const bodies = parts.map((b) => b.subarray(44));
/* A breath between sentences, same as the built-in path. */
const gap = Buffer.alloc(Math.round(rate * 0.22) * 2);
const pieces = [];
bodies.forEach((b, i) => { if (i) pieces.push(gap); pieces.push(b); });
const data = Buffer.concat(pieces);
const out = Buffer.alloc(44 + data.length);
parts[0].copy(out, 0, 0, 44);
out.writeUInt32LE(36 + data.length, 4);
out.writeUInt32LE(data.length, 40);
data.copy(out, 44);
return out;
}
async function cloudSynth(lang, text, explicit, opts) {
opts = opts || {};
/*
* Multi-sentence text goes one sentence at a time. A premium speaker that
* stops cleanly on a single sentence can run away on a passage — measured:
* one sentence spoken, then 92 seconds of silence with no end token. This
* also keeps every runaway contained to the sentence that caused it.
*/
const sentences = String(text).split(/(?<=[.!?\u3002\uff01\uff1f])\s+/u).filter((s) => s.trim());
if (sentences.length > 1) {
const parts = [];
for (const s of sentences) {
const one = await cloudSynth(lang, s, explicit, opts);
if (!one) return null; // any failure falls back whole
parts.push(one.wav);
}
const who = explicit ? (explicit.engine || 'local') : ((cloudVoiceFor(lang) || {}).provider || 'local');
return { wav: joinWavs(parts), provider: who };
}
const pick = explicit && explicit.engine
? { provider: explicit.engine, voice: explicit.speaker || explicit.voice }
: cloudVoiceFor(lang);
const instruct = (explicit && explicit.instruct) ? String(explicit.instruct).slice(0, 300) : '';
/*
* Capacity is never a reason to change VOICE. The inflight cap exists for
* metered cloud tiers; with five pinned languages it sent the fifth one to
* Piper on every single sentence — a different voice for one language, all
* service long. Kokoro is exempt: the sidecar serialises at ~175 ms a
* request, so queueing costs a beat and keeps the voice.
*/
if (!pick) return null;
if (!explicit && pick.provider !== 'kokoro' && cloudInFlight >= CLOUD_MAX_INFLIGHT) return null;
/*
* Qwen3 only. MEASURED: RTF 0.48 alone but 1.5 at three concurrent, so
* parallel requests make every one slower than real time.
*
* Kokoro is explicitly NOT held to this — measured at RTF 0.05 alone and
* 0.12 with three at once, it has headroom the bigger model never had, and
* serialising it would throw away the entire reason to use it.
*/
/* Counted against OTHER Qwen3 jobs only. Counting Kokoro too meant German
went to Piper whenever any Spanish sentence was in flight — nearly always. */
if (pick.provider === 'local' && !explicit && localInFlight >= 1) return null;
cloudInFlight++;
if (pick.provider === 'local') localInFlight++;
const sentAt = Date.now();
const ctl = new AbortController();
/*
* Live speech is impatient (a late sentence is worse than a plain one),
* but an AUDITION is the operator deliberately waiting to hear one voice
* — cutting that off at six seconds just showed them "aborted" instead of
* the voice they asked for. Cold languages also need room to load.
*/
const fullBudget = premiumBudget(pick, lang, explicit);
let budget = fullBudget;
/*
* ONE deadline per speak() job, not one budget per sentence. A caption that
* splits into two sentences used to get two full budgets (cold 8 s, then
* warm 7 s) and THEN the Piper fallback — 15.4 s measured — past main.js's
* 15 s wall, where the sentence is not late but silent. Every sentence's
* budget is clamped to what is left, and with under 800 ms left the premium
* attempt is skipped so Piper still has room.
*
* Applied whenever a deadline is present — including a LISTENER'S ALTERNATE
* voice, which travels as `explicit` and used to keep the audition's 90 s
* (review measured a 16 s alternate sentence: silent on every phone on that
* voice). Auditions never carry a deadline, so they keep their 90 s.
*/
let clamped = false;
if (opts.deadline) {
const left = opts.deadline - Date.now();
if (left < 800) { cloudInFlight--; if (pick.provider === 'local') localInFlight--; return null; }
if (left < budget) { budget = left; clamped = true; }
}
const timer = setTimeout(() => ctl.abort(), budget);
try {
let body;
if (pick.provider === 'local' || pick.provider === 'kokoro') {
/* One sidecar, two models. Kokoro is small and fast enough to serve
every language; Qwen3 is the expressive one and serves about one. */
const isKokoro = pick.provider === 'kokoro';
const res = await fetch('http://127.0.0.1:' + (cloud.localPort || 43111) + '/v1/audio/speech', {
method: 'POST',
signal: ctl.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: isKokoro ? (cloud.kokoroModel || KOKORO_MODEL)
: (cloud.localModel || 'mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-8bit'),
voice: pick.voice,
input: text,
lang_code: isKokoro ? KOKORO_LANG[lang] : LOCAL_LANG[lang],
response_format: 'wav',
/*
* The interpreter's reflex, for Kokoro too. Until now the lag-driven
* hurry only ever reached the Piper path; a Kokoro clip that was
* late could only be DROPPED by the stream, never quickened, so the
* dropper was the sole catch-up mechanism. Live speech only — an
* audition or a listener's chosen alternate must never speed up
* because the room's stream happens to be behind.
*/
...((isKokoro && !explicit && opts.speed && opts.speed !== 1) ? { speed: opts.speed } : {}),
/*
* Plain-English delivery direction, when one was given. This model
* takes it, and it changes the DELIVERY rather than the volume —
* MEASURED: "whisper this softly and intimately" made the same
* Spanish sentence 6.2s where the plain reading was 4.0s. Wording
* matters and is not predictable ("speak loudly and forcefully"
* came out QUIETER than plain), which is exactly why the operator
* needs to try wordings by ear rather than be handed one.
*/
...(instruct ? { instruct } : {}),
/* Both guards are measured, not theoretical: an uncapped generation
once ran to 96 SECONDS of audio for a one-line sentence, and the
default temperature made the same sentence vary 5-12s across
runs. */
max_tokens: Math.min(900, 80 + text.length * 4),
/*
* temperature 0, and it is not a style preference — it is the fix
* for "five voices randomly switching". This model samples its
* SPEAKER along with its audio, so any temperature above zero
* re-rolls who is talking on every sentence. Measured: at 0.2 the
* same sentence drifted 122-145 Hz (3 semitones) across takes; at
* 0 it is bit-identical every time. One language, one voice, all
* service long.
*/
temperature: 0
})
});
if (!res.ok) throw new Error('local ' + res.status);
body = Buffer.from(await res.arrayBuffer());
/* The sidecar returns 200 + audio even for garbage input — validate. */
if (body.length < 512 || body.toString('latin1', 0, 4) !== 'RIFF') throw new Error('local: not a wav');
const rate = body.readUInt32LE(24) || 24000;
const secs = (body.length - 44) / 2 / rate;
const maxSecs = 2 + text.length * 0.22;
if (secs > maxSecs) {
/*
* Some speakers never emit a stop token for some languages — they
* generate until whatever cap you give them. Unusable live, but the
* operator auditioning voices needs to HEAR that, not read a refusal:
* so an audition returns the audio with a warning attached, while
* live speech still refuses and falls back to the built-in voice.
*/
const why = 'never stops in ' + (LOCAL_LANG[lang] || lang) + ' — ' + secs.toFixed(0) +
's of audio for a ' + text.length + '-character line';
if (!explicit) throw new Error(why);
/* Trim the preview to something listenable and hand back the warning. */
const keep = Math.min(body.length, 44 + Math.round(rate * 6) * 2);
const head = body.subarray(0, keep);
head.writeUInt32LE(keep - 8, 4);
head.writeUInt32LE(keep - 44, 40);
cloudFailStreak = 0;
if (pick.provider === 'local') localWarm.add(lang);
warmPairs.add(pairKey(pick, lang));
lastSidecarOkAt = Date.now();
return { wav: head, provider: pick.provider, warning: why };
}
} else if (pick.provider === 'fish') {
const res = await fetch('https://api.fish.audio/v1/tts', {
method: 'POST',
signal: ctl.signal,
headers: {
'Authorization': 'Bearer ' + (cloud.fishKey || ''),
'Content-Type': 'application/json',
'model': cloud.fishModel || 's2.1-pro'
},
body: JSON.stringify({
text,
reference_id: pick.voice,
format: 'wav',
sample_rate: 24000,
latency: 'balanced'
})
});
if (!res.ok) throw new Error('fish ' + res.status);
body = Buffer.from(await res.arrayBuffer());
} else {
const res = await fetch('https://texttospeech.googleapis.com/v1/text:synthesize?key=' +
encodeURIComponent(cloud.googleKey || ''), {
method: 'POST',
signal: ctl.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input: { text },
voice: { languageCode: pick.voice.split('-').slice(0, 2).join('-'), name: pick.voice },
audioConfig: { audioEncoding: 'LINEAR16', sampleRateHertz: 24000 }
})
});
if (!res.ok) throw new Error('google ' + res.status);
const j = await res.json();
body = Buffer.from(j.audioContent || '', 'base64');
/* LINEAR16 from Google is raw PCM without a header — wrap it. */
const wav = Buffer.alloc(44 + body.length);
wav.write('RIFF', 0); wav.writeUInt32LE(36 + body.length, 4);
wav.write('WAVE', 8); wav.write('fmt ', 12);
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(24000, 24); wav.writeUInt32LE(48000, 28);
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
wav.write('data', 36); wav.writeUInt32LE(body.length, 40);
body.copy(wav, 44);
body = wav;
}
if (!body || body.length < 512) throw new Error('empty audio');
cloudFailStreak = 0;
cloudStats.ok++;
if (pick.provider === 'local') localWarm.add(lang);
strikes = [];
if (pick.provider === 'local' || pick.provider === 'kokoro') {
warmPairs.add(pairKey(pick, lang));
lastSidecarOkAt = Date.now();
/* This request was already in the sidecar when the park was set, and it
came back fine: the sidecar was alive, the strikes were contention. */
if (cloudParkedUntil && parkedAt >= sentAt) {
cloudParkedUntil = 0; parkedAt = 0; parkedReason = '';
log('premium voices unparked — a request sent before the park succeeded');
pushStats('unparked');
}
}
/* The caller tags the clip with the engine that produced it (the
operator Test button reports it) and `pick` is local to this
function, so it travels with the audio. */
return { wav: body, provider: pick.provider };
} catch (err) {
lastCloudError = (err && err.message) || String(err);
cloudStats.failed++;
const isSidecar = pick.provider === 'local' || pick.provider === 'kokoro';
const pair = pairKey(pick, lang);
const wasCold = isSidecar && !warmPairs.has(pair) && !coldTried.has(pair);
/* The generous attempt is spent by an attempt that REACHED the sidecar
(an answer, an HTTP error, or an abort while it was working) — not by a
connection refused, which says nothing about this pair. */
if (isSidecar && !isConnRefused(err)) coldTried.add(pair);
/*
* What counts as a STRIKE. The breaker exists for a provider that is
* genuinely down, and four kinds of failure say nothing about that:
* - an AUDITION (explicit): the operator trying voices during a blip
* used to park the live service for five minutes;
* - the one cold-load attempt on a pair the sidecar has never spoken;
* - anything while main.js says the sidecar is not ready yet;
* - an abort while a request sent AFTER this one has since succeeded —
* the sidecar is demonstrably alive, this was contention.
*/
const provedAlive = isSidecar && lastSidecarOkAt > sentAt;
/* An abort because the JOB's deadline shortened this sentence's budget says
nothing about the provider — it answered the earlier sentences fine. */
const countable = !explicit && !wasCold && !(isSidecar && !sidecarReady) && !provedAlive && !clamped;
if (countable) { strikes.push(sentAt); cloudFailStreak = strikes.length; }
if (cloudFailStreak >= 3 && Date.now() >= cloudParkedUntil) {
cloudParkedUntil = Date.now() + 5 * 60 * 1000;
parkedAt = Date.now();
parkedReason = String(err && err.message || err).slice(0, 120);
log('premium voices parked for 5 min after 3 straight failures (' + parkedReason + ')');
pushStats('parked');
} else if (!countable) {
log('premium miss not counted (' + (explicit ? 'audition/alternate' : wasCold ? 'cold load' :
!sidecarReady ? 'sidecar starting' : clamped ? 'job deadline' : 'contention') + '): ' + lastCloudError);
}
return null;
} finally {
clearTimeout(timer);
cloudInFlight--;
if (pick.provider === 'local') localInFlight--;
}
}
// ---------------------------------------------------------------- synthesis
/*
* SEEDED FROM THE CLOCK, NOT FROM ZERO.
*
* A clip's name IS its URL: /audio/p3-spa_Latn.aac. Starting this counter at 0
* meant every restart minted the SAME names again — and the server serves that
* URL `public, max-age=3600, immutable` while the hardware output fetches it
* `force-cache`. Identical URL, different audio, declared permanent: the
* renderer and every listener's phone kept serving the PREVIOUS session's clip
* for an hour.
*
* MEASURED, and it is what "it said 'right now' when I said 'it is not
* translating'" was: the disk held the correct 'No está traduciendo.' as
* p2-spa_Latn.aac; what played was the p2 from the run before. Restarts are
* what surface it, which is why it looked intermittent and unrelated to
* anything the operator changed.
*
* MILLISECONDS times 1000, so each run owns a block of a thousand names per
* millisecond of its start time. A second-resolution seed was not enough: this
* app's own supervisor restarts the voice process on failure, and two starts
* inside one second would have collided again — the same bug, rarer, which is
* the worst kind. To collide now a run would have to emit more than a thousand
* clips within one millisecond of another run starting.
*
* Still plain digits, so the server's \d+ check is unchanged; nothing
* downstream parses the number, it only has to be unique. Stays inside
* Number.MAX_SAFE_INTEGER until the year 2255.
*/
let seq = Date.now() * 1000;
function encodeAac(wavPath, outPath, rate) {
/* ADTS, not MP4. Each ADTS frame carries its own header and length, so a
stream is just frames concatenated — which is what lets the live-audio
pump splice sentences into one continuous stream that survives a locked
screen. An .m4a cannot be spliced that way; its moov box describes the
whole file up front. The file is still a perfectly ordinary AAC that any
browser plays directly, so the per-clip path still works as a fallback.
One encoder module for both platforms — see server/aac-encode.js. */
/*
* The output rate is PINNED to the language's declared stream rate. Without
* this, a premium cloud clip (24k/44.1k WAV) landing next to a Piper clip
* (22.05k) makes the live stream detect a rate change and REBUILD — dumping
* every queued sentence. One language, one rate, forever, whatever produced
* the audio.
*/
require('./server/aac-encode.js').encodeAdts(wavPath, outPath, rate || 22050);
}
/*
* Below this, a clip is not speech. Ukrainian shipped broken for months at
* 0.006-0.019 s/char — a fraction of a second of noise per sentence, encoded
* and delivered to listeners with no error anywhere. Real speech in every
* language measured sits far above this; 33 characters per second is not a
* rate a human voice reaches.
*/
const MIN_SEC_PER_CHAR = 0.030;
/*
* Scale a finished 16-bit WAV in place.
*
* The premium path returns rendered WAV rather than samples, so it never went
* through the Piper gain block. Left alone, dynamics would apply to built-in
* voices and silently not to premium ones — the kind of split that costs
* somebody an afternoon a year from now.
*/
function applyWavGainDb(wav, gainDb) {
const db = Number(gainDb) || 0;
if (!db || !Buffer.isBuffer(wav) || wav.length < 44) return wav;
const g = Math.pow(10, Math.max(-12, Math.min(12, db)) / 20);
/* Walk the chunks rather than assuming a 44-byte header — plenty of encoders
emit LIST/fact chunks before the data. */
let off = 12;
while (off + 8 <= wav.length) {
const id = wav.toString('ascii', off, off + 4);
const size = wav.readUInt32LE(off + 4);
const body = off + 8;
if (id === 'data') {
const end = Math.min(wav.length, body + size);
const out = Buffer.from(wav);
for (let i = body; i + 1 < end; i += 2) {
let v = Math.round(out.readInt16LE(i) * g);
if (v > 32000) v = 32000; else if (v < -32000) v = -32000;
out.writeInt16LE(v, i);
}
return out;
}
off = body + size + (size % 2);
}
return wav;
}
/*
* Kokoro pads every clip with silence — MEASURED at -40 dBFS on the live
* sidecar: 0.18-0.21 s before the first word and 0.13-0.25 s after the last,
* so a 1.45 s "Te necesitamos." is 26% silence. Multiplied by every sentence
* of a sermon that is the difference between keeping up and dropping. The
* stream adds its own breath between sentences (SENTENCE_GAP_MS), so nothing
* natural is lost by trimming here; a little lead is kept so the first
* consonant is never clipped.
*/
function trimWavSilence(wav, keepLeadMs, keepTrailMs) {
try {
if (!Buffer.isBuffer(wav) || wav.length < 44 + 4800) return wav;
if (wav.toString('latin1', 0, 4) !== 'RIFF' || wav.toString('latin1', 36, 40) !== 'data') return wav;
if (wav.readUInt16LE(20) !== 1 || wav.readUInt16LE(22) !== 1 || wav.readUInt16LE(34) !== 16) return wav;
const rate = wav.readUInt32LE(24) || 24000;
const n = (wav.length - 44) >> 1;
const win = Math.max(1, Math.round(rate * 0.01));
const thr = 32768 * 0.01; // -40 dBFS
const peak = (from, to) => {
let m = 0;
for (let i = from; i < to; i++) { const v = wav.readInt16LE(44 + i * 2); const a = v < 0 ? -v : v; if (a > m) m = a; }
return m;
};
let lead = 0;
for (let i = 0; i < n; i += win) { if (peak(i, Math.min(n, i + win)) > thr) { lead = i; break; } if (i + win >= n) return wav; }
let trail = n;
for (let i = n - win; i >= 0; i -= win) { if (peak(i, Math.min(n, i + win)) > thr) { trail = Math.min(n, i + win); break; } }
const start = Math.max(0, lead - Math.round(rate * keepLeadMs / 1000));
const end = Math.min(n, trail + Math.round(rate * keepTrailMs / 1000));
if (end - start < win * 4 || (start === 0 && end === n)) return wav;
const body = wav.subarray(44 + start * 2, 44 + end * 2);
const out = Buffer.alloc(44 + body.length);
wav.copy(out, 0, 0, 44);
out.writeUInt32LE(36 + body.length, 4);
out.writeUInt32LE(body.length, 40);
body.copy(out, 44);
return out;
} catch (_) { return wav; }
}
/*
* Clip names now say WHICH ENGINE spoke: k = Kokoro, q = Qwen3 (local),
* c = a cloud provider, p = Piper. The transcript records these names, and the
* morning a voice switched mid-service the transcript could not tell us
* whether a single sentence had been Piper or Kokoro. Nothing parses the
* prefix (verified: the server serves /audio/<name> and the stream is keyed by
* language), so this is free.
*/
function clipPrefix(provider) {
return provider === 'kokoro' ? 'k' : provider === 'local' ? 'q' : provider === 'piper' ? 'p' : 'c';
}
/*
* SYNTHESISE ONCE, SPEAK IN EVERY VARIANT.
*
* spa_Latn, spa_ES and spa_MX carry byte-identical text and the same Kokoro
* voice, yet were three separate synth requests per sentence (the model side
* already dedupes; the voice side did not) — 5 requests where 2 would do,
* which is also what pushed the 5th language over the inflight cap onto
* Piper. The key is everything that changes the AUDIO. Never shared for a
* listener's chosen alternate (`alt`), and a failure is not cached: the
* follower re-synthesises itself.
*/
const synthCache = new Map(); // key -> { promise, at }
const SYNTH_CACHE_MS = 3 * 60 * 1000; // under sweepClips' 5 min, so the file is still there
setInterval(() => {
const cut = Date.now() - SYNTH_CACHE_MS;
for (const [k, e] of synthCache) if (e.at < cut) synthCache.delete(k);
}, 60 * 1000).unref();
function cloneClip(done, lang, started) {
const pfx = (done.name || 'c').charAt(0);
const name = pfx + (++seq) + '-' + lang + '.aac';
const file = path.join(CLIP_DIR, name);
fs.copyFileSync(done.file, file);
return Object.assign({}, done, { file, name, totalMs: Date.now() - started, shared: true });
}
async function speak(job) {
const started = Date.now();
/* The caption keeps the original text; only the spoken form is normalised. */
const text = normalizeForSpeech(job.text, job.lang);
if (!text) {
/* Nothing sayable. Asking the engine to say nothing emits an audible burst,
not silence, so do not ask it. */
return { empty: true };
}
/*
* A listener may be listening on an alternate voice the operator opened up.
* `alt` is null for everyone else, which is the whole room by default, so
* this path costs nothing until someone actually picks something.
*/
const alt = voiceByKey(job.lang, job.voice);
const voice = (alt && alt.engine === 'builtin')
? { bundle: alt.bundle, model: alt.model, speed: alt.speed, gain: alt.gain, sid: alt.sid }
: (builtinVoiceFor(job.lang) || {});
const pinRate = voices.sampleRateFor(job.lang);
/*
* PACE FIRST, for every engine. The interpreter's reflex: when this
* language's spoken audio has fallen behind its captions, deliver a little
* faster until it catches up. It used to be computed AFTER the premium
* branch had already returned, so a Kokoro clip that was late could only
* be dropped by the stream, never quickened.
*
* Piper: starts at 1 s behind, +5%/s, capped +16% — measured, a full hurry
* shortens a clip ~14%, and past that a voice stops sounding like someone
* talking. Kokoro's speed control is weaker — MEASURED on the live sidecar:
* 1.16 is only 4.7% shorter, 1.25 is 9.3%, and 1.35 falls off a cliff to
* 30% (rushed) — so it gets a longer lever, capped at 1.25, quantised to
* 0.05 so variants whose backlogs differ by a hair still share one
* synthesis (see the cache key below).
*/
const lag = Number(job.lagSec) || 0;
const hurry = Math.min(0.16, Math.max(0, (lag - 1) * 0.05));
const speed = (voice.speed || 1.0) * (1 + hurry);
/* Badly behind (past the stream's TARGET backlog) the 1.35 "cliff" — 30%
shorter, audibly rushed — is exactly the deficit, and a rushed sentence
beats a missing one. Otherwise the gentle curve. */
const kokoroSpeed = lag >= 6 ? 1.35
: Math.round((1 + Math.min(0.25, Math.max(0, (lag - 1) * 0.08))) * 20) / 20;
/* Leaves ~4 s for the Piper fallback (5 languages measured at 2.3 s) before
main.js's 15 s tts:speak wall. */
const deadline = started + 11000;
const premiumPick = (alt && alt.engine === 'builtin') ? null
: (alt && alt.engine !== 'builtin') ? { provider: alt.engine, voice: alt.speaker || alt.voice }
: cloudVoiceFor(job.lang);
/* Everything that changes the audio, and nothing that does not. */
const key = alt ? null : [
premiumPick ? (premiumPick.provider + '|' + premiumPick.voice + '|' +
(premiumPick.provider === 'kokoro' ? KOKORO_LANG[job.lang] : (LOCAL_LANG[job.lang] || job.lang)) +
'|' + kokoroSpeed)
: ('piper|' + (voice.bundle || '') + '|' + (voice.sid || 0) + '|' + speed.toFixed(2)),
text, Math.round((Number(job.gainDb) || 0) * 2) / 2, pinRate
].join('\u0001');
/* Set when the leader's result was a Piper fallback in ANOTHER variant's
voice — this job then speaks its own fallback, straight to Piper. */
let piperOnly = false;
if (key) {
const hit = synthCache.get(key);
if (hit) {
try {
const done = await hit.promise;
if (done && !done.empty && done.file && fs.existsSync(done.file)) {
/* A follower that was already waiting shares the leader's result
even when that result is the Piper fallback — the variants of one
utterance switch together rather than each re-hitting a sidecar
that just missed. It is a fallback for THIS stream too, so it is
counted as one; the key is deleted below so the NEXT utterance
tries the natural voice again. */
if (premiumPick && done.source !== premiumPick.provider) { cloudStats.fallback++; fallbackAt = Date.now(); }
/*
* ...but only a fallback in THIS language's own Piper voice. The key
* names the PREMIUM voice, and spa_Latn, spa_ES and spa_MX share it
* (KOKORO_LANG maps all three to 'e', both Portugueses to 'p'). Their
* Piper fallbacks they do not share: spa_ES falls back to davefx,
* spa_Latn to ald, por_PT to miro, por_Latn to jeff. Cloning the
* leader's fallback put the Mexican voice in the Castilian stream for
* that sentence — measured in a harness against this file: spa_ES's
* clip was the byte-identical ald file and davefx never even loaded.
* So the follower still skips the sidecar that just missed (the
* variants still switch together), but in its own voice.
*/
if (done.source !== 'piper' || done.bundle === (voice.bundle || '')) {
return cloneClip(done, job.lang, started);
}
piperOnly = true;
}
} catch (_) { /* the first caller failed — make our own */ }
}
}
const produce = async () => {
/*
* Premium first, when configured: the sidecar or cloud returns finished
* WAV speech, so the whole Piper path is skipped — the clip goes straight
* to the encoder, pinned to this language's stream rate. Any failure
* falls through to Piper below. A listener who picked a BUILT-IN
* alternate skips the premium path entirely — otherwise cloudSynth would
* fall back to the operator's own pick and the page would lie.
*/
const cloudOut = (piperOnly || (alt && alt.engine === 'builtin'))
? null
: await cloudSynth(job.lang, text, (alt && alt.engine !== 'builtin') ? alt : null, { speed: kokoroSpeed, deadline });
if (cloudOut) {
let cloudWav = cloudOut.wav;
if (cloudOut.provider === 'kokoro' || cloudOut.provider === 'local') {
cloudWav = trimWavSilence(cloudWav, 40, 120);
}
fs.mkdirSync(CLIP_DIR, { recursive: true });
const cbase = clipPrefix(cloudOut.provider) + (++seq) + '-' + job.lang;
const cwav = path.join(CLIP_DIR, cbase + '.wavtmp');
const caac = path.join(CLIP_DIR, cbase + '.aac');
fs.writeFileSync(cwav, applyWavGainDb(cloudWav, job.gainDb));
encodeAac(cwav, caac, pinRate);
try { fs.unlinkSync(cwav); } catch (_) {}
const st = fs.statSync(caac);
/* Duration from the WAV header's data size (16-bit mono). */
const dataLen = cloudWav.length - 44;
const cloudRate = cloudWav.readUInt32LE(24) || 24000;
return {
file: caac, name: cbase + '.aac',
seconds: dataLen / 2 / cloudRate,
terminal: text.slice(-1),
sampleRate: pinRate,
synthMs: 0, totalMs: Date.now() - started, bytes: st.size,
source: cloudOut.provider
};
}
/* A piperOnly job's fallback was already counted at the cache hit. */
if (!piperOnly && !(alt && alt.engine === 'builtin') && cloudVoiceFor(job.lang)) {
cloudStats.fallback++;
fallbackAt = Date.now();
if (cloudStats.fallback === 1 || cloudStats.fallback % 10 === 0) pushStats('fallback');
}
/* Live: a sentence is speaking with this voice, so no audition may take
its engine. A listener's built-in alternate counts — someone is on it. */
const tts = await engineFor(voice.bundle ? voice : null, true, job.lang);
if (!sherpa) sherpa = require('sherpa-onnx-node');
/*
* One caption can carry several sentences (the engine translates per source
* sentence and rejoins). sherpa splits them internally but butts the audio
* together with almost nothing between — measured 90-190 ms where real
* speech leaves 300+. The stream's inter-clip gap cannot reach inside a
* clip, so the pause is put in here, at the PCM level.
*/
const parts = text.split(/(?<=[.!?\u3002\uff01\uff1f])\s+/u).filter((p) => p.trim());
let audio;
if (parts.length <= 1) {
audio = tts.generate({ text, sid: voice.sid || 0, speed });
} else {
const chunks = [];
let sampleRate = 0;
for (const part of parts) {
const a = tts.generate({ text: part, sid: voice.sid || 0, speed });
sampleRate = a.sampleRate;
if (chunks.length) chunks.push(new Float32Array(Math.round(sampleRate * 0.28)));
chunks.push(a.samples);
}
const total = chunks.reduce((n, c) => n + c.length, 0);
const joined = new Float32Array(total);
let off = 0;
for (const c of chunks) { joined.set(c, off); off += c.length; }
audio = { samples: joined, sampleRate };
}
const synthMs = Date.now() - started;
const secs = audio.samples.length / audio.sampleRate;
if (text.length >= 8 && secs / text.length < MIN_SEC_PER_CHAR) {
/* A silent failure becomes a caption. speakAll() already treats a
per-language error as "no voice for this one" and the listener falls back
to their own device, which is worth far more than a burst of noise. */
log('voice output too short for ' + job.lang + ' (' + secs.toFixed(2) + 's for ' +
text.length + ' chars) — refusing the clip');
throw new Error('voice produced unintelligible audio for ' + job.lang);
}
/*
* Two gains, multiplied into one pass.
*
* voice.gain — per-voice correction, because models render at wildly
* different levels (the Italian miro peaks at ~0.2 where
* everyone else sits above 0.5) and a stream that changes
* loudness per language reads as broken.
*
* job.gainDb — how much louder or quieter the PREACHER was for this
* sentence than the room's normal, already compressed to
* about +/-7 dB by the engine. Off (0) unless the operator
* turned dynamics on.
*
* Hard-limited either way, so no combination can clip.
*/
const dynG = job.gainDb ? Math.pow(10, Math.max(-12, Math.min(12, job.gainDb)) / 20) : 1;
const totalG = (voice.gain || 1) * dynG;
if (totalG !== 1) {
for (let i = 0; i < audio.samples.length; i++) {
const x = audio.samples[i] * totalG;
audio.samples[i] = x > 0.98 ? 0.98 : (x < -0.98 ? -0.98 : x);
}
}
fs.mkdirSync(CLIP_DIR, { recursive: true });
const base = clipPrefix('piper') + (++seq) + '-' + job.lang;
const wav = path.join(CLIP_DIR, base + '.wav');
const aac = path.join(CLIP_DIR, base + '.aac');
sherpa.writeWave(wav, { samples: audio.samples, sampleRate: audio.sampleRate });
encodeAac(wav, aac, pinRate);
try { fs.unlinkSync(wav); } catch (_) {}
const seconds = secs;
return {
source: 'piper',
/* WHICH Piper voice spoke it. A follower may share a fallback only when
it is its own voice — see the cache hit above. */
bundle: voice.bundle || '',
/* What the sentence ended on — the stream gives a full stop a full breath
and a mid-thought fragment only a beat. */
terminal: text.slice(-1),
file: aac,
name: base + '.aac',
sampleRate: pinRate,
seconds,
synthMs,
totalMs: Date.now() - started,
bytes: fs.statSync(aac).size
};
};
/* A piperOnly job is a fallback by construction. It must not stand in the
cache as a leader, or the next identical line would inherit the miss
without trying the natural voice — the chorus case below. */
const cacheKey = piperOnly ? null : key;
const promise = produce();
if (cacheKey) synthCache.set(cacheKey, { promise, at: Date.now() });
try {
const out = await promise;
/*
* NEVER cache a fallback. If the premium voice missed and Piper spoke this
* sentence, the next identical sentence must try the premium voice again —
* a worship chorus repeats the same line eleven times, and caching the
* miss would have re-created the very voice switch this file exists to
* prevent (measured in the harness before shipping, not after).
*/
if (cacheKey && premiumPick && out && out.source !== premiumPick.provider) synthCache.delete(cacheKey);
return out;
} catch (e) {
if (cacheKey) synthCache.delete(cacheKey);
throw e;
}
}
/* Clips are worth nothing once played. Sweep anything older than five minutes so
a two-hour service does not fill the disk with audio nobody will hear again. */
function sweepClips() {
try {
const cutoff = Date.now() - 5 * 60 * 1000;
for (const f of fs.readdirSync(CLIP_DIR)) {
const p = path.join(CLIP_DIR, f);
try { if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p); } catch (_) {}
}
} catch (_) {}
}
setInterval(sweepClips, 60 * 1000).unref();
/* Degradation must be visible: while a premium provider is configured, its
health rides up to the app every 20s and lands on the operator's status. */
setInterval(() => {
/* Per-language Kokoro picks work with the global provider 'off', so this
cannot be gated on cloud.provider — it used to be, and a Kokoro-only
setup never reported anything. */
const anyPremium = (cloud && cloud.provider && cloud.provider !== 'off') ||
Object.keys(voiceChoice).some((l) => /^(kokoro|local|fish|google)$/.test((voiceChoice[l] || {}).engine));
if (anyPremium) pushStats();
}, 20000).unref();
// ---------------------------------------------------------------- protocol
process.on('message', async (msg) => {
msg = msg || {};
try {
if (msg.type === 'config') {
if (msg.voiceDir) VOICE_DIR = msg.voiceDir;
if (msg.clipDir) CLIP_DIR = msg.clipDir;
reply({
type: 'ready',
clipDir: CLIP_DIR,
spoken: voices.spokenLanguages(),
/* The shipped default bundle per language, so the Voices card can mark
which row is the default when the operator has not chosen one. */
defaults: Object.fromEntries(voices.spokenLanguages().map(
(l) => [l, (voices.voiceFor(l) || {}).bundle])),
installed: voices.spokenLanguages().filter((l) => isInstalled(voices.voiceFor(l)))
});
return;
}
if (msg.type === 'sidecar') {
/*
* main.js owns the sidecar process and tells us when it is genuinely
* ready (every configured voice has answered once). Coming up resets
* the breaker — the thing it was tripping on has been fixed — and going
* down forgets what was warm, because a restarted sidecar is cold again.
*/
const was = sidecarReady;
sidecarReady = !!msg.ready;
if (!sidecarReady) { warmPairs.clear(); coldTried.clear(); }
if (!was && sidecarReady) {
coldTried.clear(); // a fresh sidecar earns fresh first attempts
strikes = []; cloudFailStreak = 0; parkedAt = 0;
if (cloudParkedUntil) log('premium voices unparked — sidecar is ready');
cloudParkedUntil = 0; parkedReason = '';
pushStats('sidecar ready');
}
return;
}
if (msg.type === 'cloud') {
/* Premium-voice settings, pushed by the app whenever they change. A
distinct message type on purpose: replying 'ready' here would re-fire
the app's voices-ready path and re-preload everything. */
cloud = msg.cloud || { provider: 'off' };
strikes = []; cloudFailStreak = 0;
cloudParkedUntil = 0; parkedAt = 0; parkedReason = '';
reply({ type: 'cloudOk', provider: cloud.provider || 'off' });
return;
}
if (msg.type === 'stats') {
reply({ type: 'stats', id: msg.id, cloud: Object.assign({}, cloudStats, {
parked: Date.now() < cloudParkedUntil, parkedUntil: cloudParkedUntil, parkedReason,
strikes: cloudFailStreak, fallbackAt, sidecarReady: needsSidecar() ? sidecarReady : null,
lastError: lastCloudError, provider: (cloud && cloud.provider) || 'off', inFlight: cloudInFlight
}) });
return;
}
if (msg.type === 'voices') {
/* The operator changed a per-language voice choice. */
voiceChoice = msg.choices || {};
/*
* Nothing to drop. Engines are keyed by bundle, so a changed choice
* resolves to a different engine on the next sentence by itself, and
* the one it replaced ages out like any other. The loop that was here
* only ever deleted the preload's per-language copies (the live path was
* filed under 'audition:'); under bundle keys it would delete the live
* engines too, and every "Use it" would make the next sentence rebuild
* every language's voice.
*/
reply({ type: 'voicesOk', count: Object.keys(voiceChoice).length });
return;
}
if (msg.type === 'audition') {
/*
* Speak one sample sentence with a specific voice and hand the audio
* back as bytes — the point of the whole Voices card is that the
* operator judges by ear, so this must be able to play ANY candidate,
* including one that is not installed yet (it downloads first).
*/
const spec = msg.voice || {};
const text = normalizeForSpeech(msg.text || SAMPLE_TEXT[msg.lang] || SAMPLE_TEXT.eng_Latn, msg.lang);
const started = Date.now();
let wav;
let auditionWarning = '';
if (spec.engine === 'local' || spec.engine === 'kokoro' ||
spec.engine === 'fish' || spec.engine === 'google') {
const saved = voiceChoice;
try {
lastCloudError = '';
const out = await cloudSynth(msg.lang, text,
msg.instruct ? Object.assign({}, spec, { instruct: msg.instruct }) : spec);
if (!out) throw new Error(lastCloudError || 'that voice did not answer');
wav = out.wav;
auditionWarning = out.warning || '';
} finally { voiceChoice = saved; }
} else {
if (!sherpa) sherpa = require('sherpa-onnx-node');
/* Not live: shares the engine when this voice is already speaking,
otherwise takes an audition slot — never a live one. */
const tts = await engineFor(spec.bundle ? spec : builtinVoiceFor(msg.lang), false, 'audition');
const audio = tts.generate({ text, sid: spec.sid || 0, speed: spec.speed || 1.0 });
if (spec.gain && spec.gain !== 1) {
for (let i = 0; i < audio.samples.length; i++) {
const x = audio.samples[i] * spec.gain;
audio.samples[i] = x > 0.98 ? 0.98 : (x < -0.98 ? -0.98 : x);
}
}
fs.mkdirSync(CLIP_DIR, { recursive: true });
const tmp = path.join(CLIP_DIR, 'audition.wav');
sherpa.writeWave(tmp, { samples: audio.samples, sampleRate: audio.sampleRate });
wav = fs.readFileSync(tmp);
try { fs.unlinkSync(tmp); } catch (_) {}
}
const rate = wav.readUInt32LE(24) || 24000;
reply({ type: 'audition', id: msg.id, wav: wav.toString('base64'),
seconds: (wav.length - 44) / 2 / rate,
warning: auditionWarning,
ms: Date.now() - started });
return;
}
if (msg.type === 'speak') {
const out = await speak(msg);
if (out.empty) { reply({ type: 'empty', id: msg.id, lang: msg.lang }); return; }
reply({ type: 'audio', id: msg.id, lang: msg.lang, ...out });
return;
}
if (msg.type === 'preload') {
/* Warm the languages people are actually listening to, so the first
sentence of a service is not the one that pays for the download. */
for (const lang of msg.langs || []) {
/* Keyed exactly as speak() keys it, so this warms the engine the first
sentence will read, not a copy beside it. */
try { await engineFor(builtinVoiceFor(lang), true, lang); } catch (e) { log('preload failed for ' + lang + ': ' + e.message); }
}
reply({ type: 'preloaded', langs: msg.langs || [] });
return;
}
} catch (err) {
reply({ type: 'error', id: msg.id, lang: msg.lang, message: (err && err.message) || String(err) });
}
});
process.on('uncaughtException', (e) => {
reply({ type: 'fatal', message: e.message });
process.exit(1);
});