/* * 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'); /* Loaded engines, one per language. Kept small: each costs ~330-400 MB of ONNX arena regardless of the 63 MB model, so we do not hold voices nobody is listening to. */ /* 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.' }; const loaded = new Map(); const MAX_LOADED = 6; /* * 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) { return new Promise((resolve, reject) => { fs.mkdirSync(VOICE_DIR, { recursive: true }); const url = voices.bundleUrl(v.bundle); reply({ type: 'voiceProgress', bundle: v.bundle, status: 'downloading' }); (async () => { const res = await fetch(url, { redirect: 'follow' }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = Buffer.from(await res.arrayBuffer()); /* A proxy or a captive portal returns an HTML error page with HTTP 200; the bzip2 magic is the cheapest way to notice before unpacking it. */ if (body.length < 1024 || body.toString('latin1', 0, 3) !== 'BZh') { throw new Error('that download was not a voice bundle'); } reply({ type: 'voiceProgress', bundle: v.bundle, status: 'installing' }); const bunzip = require('unbzip2-stream'); const tar = require('tar-stream'); const { Readable } = require('stream'); await new Promise((res2, rej2) => { const extract = tar.extract(); extract.on('entry', (header, stream, next) => { /* Never write outside VOICE_DIR, whatever the archive claims. */ const dest = path.resolve(VOICE_DIR, header.name); if (dest !== VOICE_DIR && !dest.startsWith(VOICE_DIR + path.sep)) { stream.resume(); return next(); } if (header.type === 'directory') { fs.mkdirSync(dest, { recursive: true }); stream.resume(); return next(); } fs.mkdirSync(path.dirname(dest), { recursive: true }); stream.pipe(fs.createWriteStream(dest)).on('finish', next).on('error', rej2); }); extract.on('finish', res2); extract.on('error', rej2); Readable.from(body).pipe(bunzip()).on('error', rej2).pipe(extract); }); reply({ type: 'voiceProgress', bundle: v.bundle, status: 'ready' }); })().then(resolve, (err) => reject(new Error('could not install ' + v.bundle + ': ' + err.message))); }); } async function engineFor(lang, explicit) { /* Auditions key the cache by bundle so trying five voices for one language does not evict the voice that language is actually using. */ const v = explicit || builtinVoiceFor(lang); const key = explicit ? "audition:" + v.bundle : lang; if (loaded.has(key)) { const e = loaded.get(key); e.usedAt = Date.now(); return e.tts; } if (!v) throw new Error('no built-in voice for ' + lang); if (!isInstalled(v)) await installVoice(v); if (!sherpa) sherpa = require('sherpa-onnx-node'); const dir = voicePath(v); const tts = new sherpa.OfflineTts(voices.ttsConfig(dir, v)); /* Evict the least recently used rather than growing without bound. */ if (loaded.size >= MAX_LOADED) { let oldest = null; for (const [k, e] of loaded) if (!oldest || e.usedAt < loaded.get(oldest).usedAt) oldest = k; if (oldest) { loaded.delete(oldest); log('evicted voice ' + oldest); } } loaded.set(key, { tts, usedAt: Date.now() }); log('voice ready: ' + key + ' (' + v.bundle + ')'); return tts; } // ------------------------------------------------------------- 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/ 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'); 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(); } return cloneClip(done, job.lang, started); } } 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 = (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 }; } if (!(alt && alt.engine === 'builtin') && cloudVoiceFor(job.lang)) { cloudStats.fallback++; fallbackAt = Date.now(); if (cloudStats.fallback === 1 || cloudStats.fallback % 10 === 0) pushStats('fallback'); } const tts = await engineFor(job.lang, voice.bundle ? voice : null); 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', /* 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 }; }; const promise = produce(); if (key) synthCache.set(key, { 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 (key && premiumPick && out && out.source !== premiumPick.provider) synthCache.delete(key); return out; } catch (e) { if (key) synthCache.delete(key); 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 || {}; /* Drop cached engines for languages whose choice changed, so the next sentence uses the new voice rather than the one already resident. */ for (const k of Array.from(loaded.keys())) { if (!String(k).startsWith("audition:")) loaded.delete(k); } 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'); const tts = await engineFor(msg.lang, spec.bundle ? spec : null); 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 || []) { try { await engineFor(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); });