Live sermon translation into 100+ languages, running entirely on one Mac per campus: audio in, voice detection, Whisper on the GPU, NLLB-200 in its own process, and a built-in web server the congregation reads from on their phones. No audio leaves the building. This is the source only. What used to sit beside it — 14 GB of speech and translation models, 8 GB of voice bundles, 6 GB of natural voices, the Electron caches, and the app data holding the operator PIN and every service transcript — is machine state and downloaded assets, and stays in the NAS backup where it belongs. The vendored node, cloudflared and Windows ffmpeg binaries are out too (533 MB, re-fetched for packaging); the browser-side runtime beside them is kept, so a checkout still serves the congregation page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2143 lines
91 KiB
JavaScript
2143 lines
91 KiB
JavaScript
/*
|
||
* Church Translate — Electron main process.
|
||
*
|
||
* This app is the ears. It runs on the backstage Mac, takes whatever CoreAudio
|
||
* input you point it at (Dante Virtual Soundcard, Waves SoundGrid, the console's
|
||
* USB out, BlackHole), transcribes and translates on the M-series GPU, and
|
||
* sends nothing but text to the church server.
|
||
*
|
||
* The heavy lifting is in the renderer, because that is where WebGPU lives.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { app, BrowserWindow, ipcMain, systemPreferences, shell, dialog, clipboard } = require('electron');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const http = require('http');
|
||
const { fork, spawn } = require('child_process');
|
||
|
||
/* WebGPU is what makes this fast enough to be worth doing. Ask for it
|
||
explicitly rather than hoping the default is on. */
|
||
app.commandLine.appendSwitch('enable-unsafe-webgpu');
|
||
app.commandLine.appendSwitch('enable-features', 'WebGPU,WebGPUDeveloperFeatures');
|
||
/* A long sermon is a long-running renderer; don't let it get throttled when
|
||
the operator switches to another app. */
|
||
app.commandLine.appendSwitch('disable-renderer-backgrounding');
|
||
app.commandLine.appendSwitch('disable-background-timer-throttling');
|
||
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
|
||
|
||
/*
|
||
* One copy, ever.
|
||
*
|
||
* A second instance cannot get the userData directory and comes up as a black
|
||
* window — which is what happens when somebody double-clicks the dock icon
|
||
* twice, or launches it while it is already minimised backstage. Focus the
|
||
* window that already exists instead.
|
||
*/
|
||
console.log('[main] module loaded, electron ' + process.versions.electron);
|
||
process.on('uncaughtException', (e) => { console.error('[main] UNCAUGHT: ' + e.stack); });
|
||
|
||
const gotLock = app.requestSingleInstanceLock();
|
||
console.log('[main] single-instance lock: ' + gotLock);
|
||
if (!gotLock) {
|
||
app.quit();
|
||
/* Nothing below should run in the losing instance. */
|
||
process.exit(0);
|
||
}
|
||
|
||
let win = null;
|
||
let engineIsLive = false;
|
||
let forceQuit = false;
|
||
let assetServer = null;
|
||
let assetOrigin = null;
|
||
|
||
/*
|
||
* The page is served from 127.0.0.1 rather than loaded as file://.
|
||
*
|
||
* This is not cosmetic. A file:// page is an opaque origin, where the Cache API
|
||
* is unavailable and WebGPU is restricted — which would mean re-downloading
|
||
* roughly 1.5 GB of model weights on every single launch. localhost is a
|
||
* secure context, so the models are fetched once and cached forever.
|
||
*/
|
||
const ASSET_MIME = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.mjs': 'text/javascript; charset=utf-8',
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.wasm': 'application/wasm',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.svg': 'image/svg+xml'
|
||
};
|
||
|
||
function startAssetServer() {
|
||
return new Promise((resolve, reject) => {
|
||
const root = __dirname;
|
||
assetServer = http.createServer((req, res) => {
|
||
const rel = decodeURIComponent((req.url || '/').split('?')[0]);
|
||
const full = path.resolve(root, '.' + (rel === '/' ? '/index.html' : rel));
|
||
if (full !== root && !full.startsWith(root + path.sep)) { res.writeHead(403).end(); return; }
|
||
fs.readFile(full, (err, buf) => {
|
||
if (err) { res.writeHead(404).end('Not found'); return; }
|
||
res.writeHead(200, {
|
||
'Content-Type': ASSET_MIME[path.extname(full).toLowerCase()] || 'application/octet-stream',
|
||
'Content-Length': buf.length,
|
||
'Cache-Control': 'no-store'
|
||
});
|
||
res.end(buf);
|
||
});
|
||
});
|
||
/*
|
||
* A FIXED port, deliberately — never port 0.
|
||
*
|
||
* The browser cache is keyed by origin, and the model weights live in it.
|
||
* With a random port, every launch was a new origin, so Whisper's 1.5 GB
|
||
* re-downloaded each time and the old copy was never evicted: the profile
|
||
* reached 22 GB of duplicate blobs, and startup slowed as Chromium walked
|
||
* it. A stable port means one copy, downloaded once.
|
||
*
|
||
* The port that worked is remembered, so it stays stable across launches
|
||
* even if we had to move off the default.
|
||
*/
|
||
const cfg = loadConfig();
|
||
const preferred = Number(cfg.assetPort) || 43110;
|
||
const candidates = [preferred, 43110, 43111, 43112, 43113, 43114];
|
||
|
||
let i = 0;
|
||
const tryNext = () => {
|
||
if (i >= candidates.length) { reject(new Error('no free port for the local page server')); return; }
|
||
const port = candidates[i++];
|
||
assetServer.listen(port, '127.0.0.1', () => {
|
||
assetOrigin = 'http://127.0.0.1:' + port;
|
||
if (port !== cfg.assetPort) saveConfig({ assetPort: port });
|
||
resolve(assetOrigin);
|
||
});
|
||
};
|
||
assetServer.on('error', (err) => {
|
||
if (err && err.code === 'EADDRINUSE') { tryNext(); return; }
|
||
reject(err);
|
||
});
|
||
tryNext();
|
||
});
|
||
}
|
||
|
||
const CONFIG_PATH = () => path.join(app.getPath('userData'), 'config.json');
|
||
|
||
/* Model weights live next to the profile, never inside it. See ensureTranslator(). */
|
||
/*
|
||
* WHERE THE BIG DIRECTORIES LIVE.
|
||
*
|
||
* On Windows app.getPath('appData') is %APPDATA% — the ROAMING profile. This
|
||
* app keeps 22 GB there (models, voice bundles, and a Chromium profile holding
|
||
* Whisper's weights), and on a domain-joined church PC Windows would try to
|
||
* SYNC all of it at every logon and logoff. Everything heavy goes to
|
||
* %LOCALAPPDATA% instead. macOS has no such split and is unchanged.
|
||
*/
|
||
/*
|
||
* Where the bundled ffmpeg is, for the forked children.
|
||
*
|
||
* server.js and tts-service.js are plain node processes, so the Electron-only
|
||
* process.resourcesPath is undefined in them — they would never find it. Put
|
||
* it in the environment they inherit instead. macOS uses afconvert and ignores
|
||
* this entirely.
|
||
*/
|
||
function ffmpegPathForChildren() {
|
||
if (process.platform !== 'win32') return '';
|
||
const candidates = [
|
||
path.join(process.resourcesPath || '', 'ffmpeg.exe'),
|
||
path.join(__dirname, 'vendor-win', 'ffmpeg.exe')
|
||
];
|
||
for (const c of candidates) { try { if (fs.existsSync(c)) return c; } catch (_) {} }
|
||
return '';
|
||
}
|
||
|
||
function appDataRoot() {
|
||
if (process.platform === 'win32' && process.env.LOCALAPPDATA) return process.env.LOCALAPPDATA;
|
||
return app.getPath('appData');
|
||
}
|
||
/* Before anything reads it: move the Chromium profile off the roaming path too. */
|
||
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||
try { app.setPath('userData', path.join(process.env.LOCALAPPDATA, 'Church Translate')); } catch (_) {}
|
||
}
|
||
const MODELS_DIR = path.join(appDataRoot(), 'Church Translate Models');
|
||
|
||
const DEFAULT_CONFIG = {
|
||
/* The app serves the congregation page itself, on this machine. There is no
|
||
server to deploy and nothing to point at — every campus runs its own copy. */
|
||
port: 3100,
|
||
/* The internal page server's port. Kept stable so the model cache survives —
|
||
see startAssetServer(). */
|
||
assetPort: 43110,
|
||
deviceId: '',
|
||
deviceLabel: '',
|
||
/*
|
||
* Which input channels carry the speaker.
|
||
*
|
||
* MEASURED: Chromium's macOS audio INPUT path downmixes every device to 2
|
||
* channels no matter what is asked for — a Dante Virtual Soundcard that the
|
||
* OS reports as 8-in arrives here as 2. So this can only ever choose within
|
||
* the pair the driver hands over, and picking "channel 16" has to be done in
|
||
* Dante Controller / SoundGrid Studio by subscribing it to 1 or 2.
|
||
*
|
||
* An empty list mixes both, which is right for a console feed.
|
||
*/
|
||
audioChannels: [],
|
||
/* The pre-multichannel setting. Still read as a fallback. */
|
||
audioChannel: 0,
|
||
/*
|
||
* Sending each language's voice to its own physical output channels.
|
||
*
|
||
* The OUTPUT side has no such cap — MEASURED, the same Dante device offers
|
||
* destination.maxChannelCount = 8 — which is why routing exists in this
|
||
* direction and not the other. Device-neutral: Dante, Waves SoundGrid, a
|
||
* console over USB, all the same to it.
|
||
*/
|
||
outputDeviceId: '',
|
||
/* The durable handle. Core Audio reissues ids across reboots and they differ
|
||
entirely on another Mac, so the NAME is what finds a device again — which
|
||
is what makes one settings file usable at more than one campus. */
|
||
outputDeviceLabel: '',
|
||
outputMap: {},
|
||
/* Per-language dB trim, for matching a hardware input — an FM assistive
|
||
listening transmitter's line in, a console channel, a recorder. */
|
||
outputGain: {},
|
||
/* q4f16 is the quality/speed balance that fits comfortably on an M-series
|
||
GPU. Raise to fp16 on a machine with headroom to spare. */
|
||
/* Two speech models on purpose.
|
||
The accurate one transcribes finished sentences; the fast one drives the
|
||
live preview, where being 300 ms old matters far more than being perfect —
|
||
the preview is provisional and gets replaced by the accurate line moments
|
||
later anyway. */
|
||
asrModel: 'onnx-community/whisper-large-v3-turbo',
|
||
asrDtype: 'q4f16',
|
||
/* whisper-small, measured on this M2 Max against a 4-second preview window:
|
||
tiny 340 ms but renders "Habakkuk" as "have a back-up"
|
||
base 1030 ms and this ONNX export is broken outright — emits ">> >> >>"
|
||
small 700 ms accurate
|
||
turbo 1245 ms accurate (this is what finished sentences use)
|
||
Small is 1.75x faster than the accurate model and still gets proper nouns
|
||
right, which tiny does not — and a preview that visibly mangles the
|
||
pastor's text costs more trust than it buys in speed. */
|
||
asrFastModel: 'onnx-community/whisper-small',
|
||
asrFastDtype: 'auto',
|
||
mtModel: 'Xenova/nllb-200-distilled-600M',
|
||
/* Translation processes. One, deliberately.
|
||
Measured on this M2 Max: a single process does five languages in 1.9 s.
|
||
Three processes did the same work in 3.4 s while holding 17 GB — NLLB
|
||
inference is memory-bandwidth-bound, so extra copies contend for the same
|
||
bottleneck instead of adding throughput. Raise this only on a machine with
|
||
bandwidth to spare, and measure before believing it helps. */
|
||
mtWorkers: 1,
|
||
/* Full precision, because translation runs natively here rather than in the
|
||
browser sandbox — see ensureTranslator() below for why. */
|
||
mtDtype: 'fp32',
|
||
autoConnect: true,
|
||
/* Setup-pane tuning that used to live only in the DOM. */
|
||
sensitivity: 3.5,
|
||
/*
|
||
* The speech gate, in dBFS, when the operator sets it by hand. null means
|
||
* follow the room automatically — `noiseFloor * sensitivity` — which is the
|
||
* default and what every existing install keeps.
|
||
*
|
||
* The automatic gate is right on a clean digital feed and wrong often enough
|
||
* on an analogue one: a preamp with real hiss learns a floor high enough that
|
||
* floor x 3.5 lands ABOVE a quiet talker, and the mic reads as dead while the
|
||
* meter plainly moves. No amount of adjusting sensitivity fixes that, because
|
||
* the thing being multiplied is itself wrong. An absolute number the operator
|
||
* can see on the meter does.
|
||
*/
|
||
threshDb: null,
|
||
uiMode: 'setup',
|
||
/*
|
||
* Premium cloud voices. Lives HERE — in the app's own config — and not in
|
||
* the congregation server's config on purpose: the server's config is
|
||
* readable by anyone with the operator PIN, including over the internet
|
||
* tunnel, and is snapshotted into its backups. API keys never take that
|
||
* path. 'off' means everything below is inert and Piper does all speaking.
|
||
*/
|
||
/*
|
||
* Per-language voice choices made by ear in the Voices card:
|
||
* { spa_Latn: { engine: "local", speaker: "ryan" }, ... }
|
||
* Empty means every language uses its shipped default.
|
||
*/
|
||
voiceChoices: {},
|
||
/*
|
||
* Which ALTERNATE voices a listener may pick for themselves, per language:
|
||
* { spa_Latn: ["em_santa"], ... }
|
||
* Keyed the same way voiceChoices is (speaker for Kokoro/local, bundle for a
|
||
* built-in). Empty means no chooser is offered and the whole room hears the
|
||
* operator's pick, which is the default and costs exactly what it always did
|
||
* — an alternate is only ever synthesised while somebody is listening to it.
|
||
*/
|
||
listenerVoices: {},
|
||
cloudTts: { provider: 'off', fishKey: '', fishModel: 's2.1-pro', fishVoice: '', googleKey: '',
|
||
localVoice: 'aiden', localPort: 43111,
|
||
/* Kokoro rides the same sidecar — small, fast, and the only
|
||
realistic voice measured able to cover every language at once. */
|
||
kokoroVoice: 'em_alex', kokoroModel: 'prince-canuma/Kokoro-82M',
|
||
localModel: 'mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-8bit', perLang: {} },
|
||
/* Follow the preacher's loudness with the translated voice — compressed to
|
||
about 14 dB, not the ~40 dB the room actually spans. Off by default: it
|
||
changes how every language sounds, so it should be a decision. */
|
||
dynamics: false,
|
||
partialsEnabled: true
|
||
};
|
||
|
||
/*
|
||
* Same protection as the congregation server's config, and for the same
|
||
* reason: this file holds the chosen audio device and channel, the port, and
|
||
* the model choices. Falling back to defaults silently means someone rediscovers
|
||
* which Dante channel the pulpit mic is on, mid-service.
|
||
*
|
||
* saveConfig() merges onto loadConfig(), so a single unreadable read followed
|
||
* by any save used to write the defaults permanently.
|
||
*/
|
||
const APP_BACKUP_DIR = () => path.join(path.dirname(CONFIG_PATH()), 'config-backups');
|
||
const APP_KEEP_BACKUPS = 30;
|
||
|
||
function backupAppConfig() {
|
||
try {
|
||
const p = CONFIG_PATH();
|
||
if (!fs.existsSync(p)) return;
|
||
const raw = fs.readFileSync(p, 'utf8');
|
||
JSON.parse(raw);
|
||
const dir = APP_BACKUP_DIR();
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||
const file = path.join(dir, 'app-' + stamp + '.json');
|
||
if (!fs.existsSync(file)) fs.writeFileSync(file, raw);
|
||
const all = fs.readdirSync(dir).filter((f) => /^app-.*\.json$/.test(f)).sort();
|
||
for (const old of all.slice(0, Math.max(0, all.length - APP_KEEP_BACKUPS))) {
|
||
try { fs.unlinkSync(path.join(dir, old)); } catch (_) {}
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function newestAppBackup() {
|
||
try {
|
||
const dir = APP_BACKUP_DIR();
|
||
const all = fs.readdirSync(dir).filter((f) => /^app-.*\.json$/.test(f)).sort();
|
||
for (let i = all.length - 1; i >= 0; i--) {
|
||
try { return { cfg: JSON.parse(fs.readFileSync(path.join(dir, all[i]), 'utf8')), file: all[i] }; }
|
||
catch (_) {}
|
||
}
|
||
} catch (_) {}
|
||
return null;
|
||
}
|
||
|
||
function loadConfig() {
|
||
let merged;
|
||
const p = CONFIG_PATH();
|
||
const existed = fs.existsSync(p);
|
||
try {
|
||
merged = Object.assign({}, DEFAULT_CONFIG, JSON.parse(fs.readFileSync(p, 'utf8')));
|
||
backupAppConfig();
|
||
} catch (err) {
|
||
let recoveredFrom = null;
|
||
if (existed) {
|
||
const back = newestAppBackup();
|
||
if (back) { merged = Object.assign({}, DEFAULT_CONFIG, back.cfg); recoveredFrom = back.file; }
|
||
try { fs.copyFileSync(p, p + '.broken-' + Date.now()); } catch (_) {}
|
||
}
|
||
if (!merged) merged = Object.assign({}, DEFAULT_CONFIG);
|
||
if (existed) {
|
||
console.log('[main] settings file was unreadable (' + err.message + ') — ' +
|
||
(recoveredFrom ? 'RESTORED from ' + recoveredFrom : 'no backup available, using defaults'));
|
||
}
|
||
}
|
||
|
||
/* Translation moved from the GPU to the native CPU runtime, where half
|
||
precision is not a thing. A config written before that move can still be
|
||
carrying fp16 or q4f16, and honouring it produces a model load that never
|
||
returns — so correct it rather than hang. */
|
||
if (/f16/i.test(String(merged.mtDtype || ''))) {
|
||
console.log('[main] mtDtype "' + merged.mtDtype + '" is not valid for the CPU runtime — using fp32');
|
||
merged.mtDtype = 'fp32';
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
function saveConfig(cfg) {
|
||
const merged = Object.assign(loadConfig(), cfg || {});
|
||
const p = CONFIG_PATH();
|
||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||
backupAppConfig();
|
||
|
||
const body = JSON.stringify(merged, null, 2);
|
||
JSON.parse(body); // fail before replacing a good file
|
||
|
||
/* tmp + fsync + rename: a crash or power cut mid-write must never be able to
|
||
leave a truncated settings file behind. */
|
||
const tmp = p + '.tmp';
|
||
const fd = fs.openSync(tmp, 'w');
|
||
try { fs.writeSync(fd, body); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
||
fs.renameSync(tmp, p);
|
||
return merged;
|
||
}
|
||
|
||
/*
|
||
* The congregation's web server, run as a child of this app.
|
||
*
|
||
* It is the same server that used to live on the NAS, unchanged — it just runs
|
||
* here now. Each campus runs its own copy, so there is nothing to deploy and no
|
||
* dependency on any other machine being up.
|
||
*
|
||
* Forked rather than required into this process: a crash takes down the server
|
||
* and not the app, and it keeps the HTTP work off the window's event loop.
|
||
*/
|
||
let srvChild = null;
|
||
let srvInfo = { port: null, engineKey: null, ready: false };
|
||
|
||
function dataDir() { return path.join(app.getPath('userData'), 'data'); }
|
||
|
||
/*
|
||
* THE LOG THIS APP NEVER HAD. Every child writes to stdout and main.js
|
||
* forwarded it to ITS stdout — which for an app launched from the Finder is
|
||
* nowhere. The morning a service lost its voice there was a caption transcript
|
||
* and not one line saying what the voice process had decided. Now everything
|
||
* is teed to data/logs/app.log (2 MB, three generations). See app-log.js.
|
||
*/
|
||
let appLog = null;
|
||
function getAppLog() {
|
||
if (appLog) return appLog;
|
||
try { appLog = require('./app-log.js').createAppLog(path.join(dataDir(), 'logs')); } catch (_) { appLog = null; }
|
||
if (appLog) {
|
||
const origLog = console.log.bind(console), origErr = console.error.bind(console);
|
||
console.log = (...a) => { origLog(...a); appLog.write('main', a.map(String).join(' ')); };
|
||
console.error = (...a) => { origErr(...a); appLog.write('main:err', a.map(String).join(' ')); };
|
||
appLog.write('main', '---- Church Translate ' + (require('./package.json').version || '') +
|
||
' build ' + (require('./package.json').buildNumber || '') + ' starting ----');
|
||
}
|
||
return appLog;
|
||
}
|
||
function teeChild(child, tag) { const l = getAppLog(); if (l) l.tee(child, tag); return child; }
|
||
|
||
function startServer() {
|
||
const cfg = loadConfig();
|
||
const port = Number(cfg.port) || 3100;
|
||
const nodeBin = findNodeBinary();
|
||
if (!nodeBin) throw new Error('Node.js was not found — the app needs it to serve the congregation page.');
|
||
|
||
fs.mkdirSync(dataDir(), { recursive: true });
|
||
|
||
srvChild = fork(path.join(__dirname, 'server', 'server.js'), [], {
|
||
execPath: nodeBin,
|
||
env: Object.assign({}, process.env, {
|
||
CT_FFMPEG: ffmpegPathForChildren(),
|
||
PORT: String(port),
|
||
DATA_DIR: dataDir(),
|
||
/* Where the spoken clips land, so the server can hand them to phones. */
|
||
CLIP_DIR: clipDir()
|
||
}),
|
||
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
|
||
});
|
||
if (srvChild.stdout) srvChild.stdout.on('data', (d) => process.stdout.write('[server] ' + d));
|
||
if (srvChild.stderr) srvChild.stderr.on('data', (d) => process.stderr.write('[server] ' + d));
|
||
teeChild(srvChild, 'server');
|
||
|
||
srvChild.on('exit', (code, signal) => {
|
||
console.error('[main] server exited (code=' + code + ' signal=' + signal + ')');
|
||
srvChild = null;
|
||
srvInfo.ready = false;
|
||
if (win && !win.isDestroyed()) win.webContents.send('server:down');
|
||
/* Without the server there is no congregation page at all, so it is worth
|
||
bringing straight back. */
|
||
if (!app.isQuittingForReal) setTimeout(() => { try { startServer(); } catch (_) {} }, 3000);
|
||
});
|
||
|
||
srvInfo.port = port;
|
||
waitForServer(port);
|
||
}
|
||
|
||
/* The server writes its own secrets on first boot; read them once it is up so
|
||
the operator never has to copy a key between two windows. */
|
||
function waitForServer(port, attempt = 0) {
|
||
const req = http.get({ host: '127.0.0.1', port, path: '/healthz', timeout: 1500 }, (res) => {
|
||
res.resume();
|
||
try {
|
||
const cfg = JSON.parse(fs.readFileSync(path.join(dataDir(), 'config.json'), 'utf8'));
|
||
srvInfo.engineKey = cfg.engineKey;
|
||
srvInfo.controlToken = cfg.controlToken;
|
||
srvInfo.operatorPin = cfg.operatorPin;
|
||
srvInfo.ready = true;
|
||
console.log('[main] server ready on :' + port);
|
||
if (win && !win.isDestroyed()) win.webContents.send('server:up', publicInfo());
|
||
} catch (e) {
|
||
if (attempt < 40) setTimeout(() => waitForServer(port, attempt + 1), 500);
|
||
}
|
||
});
|
||
req.on('error', () => { if (attempt < 60) setTimeout(() => waitForServer(port, attempt + 1), 500); });
|
||
req.on('timeout', () => { req.destroy(); });
|
||
}
|
||
|
||
/*
|
||
* en0 / en20 mean nothing to anyone. Ask the OS what they are actually called,
|
||
* and — more importantly — which one is Wi-Fi, because that is the one the
|
||
* congregation's phones can reach.
|
||
*
|
||
* Returns { <iface>: { label, virtual } }. `virtual` exists for Windows: a
|
||
* church PC routinely carries Hyper-V, WSL and VPN adapters holding
|
||
* routable-LOOKING addresses on subnets no phone can reach. Nothing on macOS
|
||
* is ever marked virtual, so behaviour there is exactly as before.
|
||
*/
|
||
let hardwarePortCache = null;
|
||
function hardwarePorts() {
|
||
if (hardwarePortCache) return hardwarePortCache;
|
||
hardwarePortCache = {};
|
||
try {
|
||
if (process.platform === 'win32') {
|
||
/* One call gives the friendly name, the description and a Virtual flag.
|
||
-NoProfile so a slow profile cannot stall startup; windowsHide so no
|
||
console window blinks at whoever is running the service. */
|
||
const ps = path.join(process.env.SystemRoot || 'C:\\Windows',
|
||
'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
||
const out = require('child_process').execFileSync(ps,
|
||
['-NoProfile', '-NonInteractive', '-Command',
|
||
'Get-NetAdapter | Select-Object Name,InterfaceDescription,Virtual | ConvertTo-Json -Compress'],
|
||
{ encoding: 'utf8', timeout: 6000, windowsHide: true });
|
||
let rows = JSON.parse(out);
|
||
if (!Array.isArray(rows)) rows = [rows]; // a single adapter is an object
|
||
for (const r of rows) {
|
||
if (!r || !r.Name) continue;
|
||
const desc = String(r.InterfaceDescription || '');
|
||
hardwarePortCache[r.Name] = {
|
||
label: String(r.Name),
|
||
virtual: !!r.Virtual || /hyper-v|virtual|vmware|virtualbox|loopback|wintun|tap-|tunnel/i.test(desc)
|
||
};
|
||
}
|
||
return hardwarePortCache;
|
||
}
|
||
const out = require('child_process')
|
||
.execFileSync('/usr/sbin/networksetup', ['-listallhardwareports'], { encoding: 'utf8' });
|
||
let current = null;
|
||
out.split('\n').forEach((line) => {
|
||
let m = line.match(/^Hardware Port:\s*(.+?)\s*$/);
|
||
if (m) { current = m[1]; return; }
|
||
m = line.match(/^Device:\s*(\S+)/);
|
||
if (m && current) hardwarePortCache[m[1]] = { label: current, virtual: false };
|
||
});
|
||
} catch (_) { /* fall back to raw interface names */ }
|
||
return hardwarePortCache;
|
||
}
|
||
|
||
/*
|
||
* Every address a phone on this building's network could actually use.
|
||
*
|
||
* Wi-Fi comes first, deliberately. This Mac sits on five networks — a couple of
|
||
* USB adapters, the NAS network, Dante's own NIC — and the OS lists them in an
|
||
* order that means nothing. Advertising whichever came first put a USB
|
||
* adapter's subnet on the QR code, so the page worked on the computer and
|
||
* nowhere else. Phones are on Wi-Fi, so Wi-Fi goes first.
|
||
*/
|
||
function lanAddresses() {
|
||
const ports = hardwarePorts();
|
||
const nets = require('os').networkInterfaces();
|
||
const out = [];
|
||
|
||
for (const name of Object.keys(nets)) {
|
||
for (const ni of nets[name] || []) {
|
||
if (ni.family !== 'IPv4' || ni.internal) continue;
|
||
/* 169.254.x means no DHCP server answered. Dante's NIC lives there and
|
||
nothing can route to it — offering it is offering a dead end. */
|
||
if (ni.address.indexOf('169.254.') === 0) continue;
|
||
|
||
const port = ports[name] || null;
|
||
const label = (port && port.label) || name;
|
||
out.push({
|
||
iface: name,
|
||
address: ni.address,
|
||
label: label,
|
||
/* On Windows the os.networkInterfaces() KEYS are already the friendly
|
||
names ('Wi-Fi', 'Ethernet 2'), so the key is worth testing too. */
|
||
wifi: /wi-?fi|airport|wlan|wireless/i.test(label) || /wi-?fi|wlan|wireless/i.test(name),
|
||
virtual: !!(port && port.virtual)
|
||
});
|
||
}
|
||
}
|
||
|
||
/* Wi-Fi first, then real adapters, and anything virtual LAST — a Hyper-V or
|
||
WSL switch holds a routable-looking address on a subnet no phone can see,
|
||
which is the same failure as the old USB-adapter bug this sort exists for. */
|
||
out.sort(function (a, b) {
|
||
if (a.virtual !== b.virtual) return a.virtual ? 1 : -1;
|
||
return (b.wifi ? 1 : 0) - (a.wifi ? 1 : 0);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/* Shown in the app's own title bar. The installer's only clue about what is
|
||
actually running used to be the DMG filename, and a build that silently
|
||
failed to install looked identical to one that worked. */
|
||
function appBuild() {
|
||
try {
|
||
const pkg = require('./package.json');
|
||
return { version: app.getVersion() || pkg.version, build: String(pkg.buildNumber || '?') };
|
||
} catch (_) { return { version: app.getVersion() || '?', build: '?' }; }
|
||
}
|
||
|
||
function publicInfo() {
|
||
return {
|
||
version: appBuild().version,
|
||
build: appBuild().build,
|
||
port: srvInfo.port,
|
||
ready: srvInfo.ready,
|
||
engineKey: srvInfo.engineKey,
|
||
controlToken: srvInfo.controlToken,
|
||
operatorPin: srvInfo.operatorPin,
|
||
addresses: lanAddresses(),
|
||
dataDir: dataDir()
|
||
};
|
||
}
|
||
|
||
ipcMain.handle('local:info', () => publicInfo());
|
||
|
||
/*
|
||
* Networks change under a running app — Wi-Fi joins after launch, a DHCP lease
|
||
* moves, a cable is pulled. The address list used to be read once at startup,
|
||
* so the share panel and QR kept advertising a network that no longer existed
|
||
* (or missed the one phones can actually reach) until someone relaunched the
|
||
* app. Poll cheaply; push only when something actually changed.
|
||
*/
|
||
let lastAddrKey = '';
|
||
setInterval(() => {
|
||
try {
|
||
const now = lanAddresses().map((a) => a.address).join(',');
|
||
if (now === lastAddrKey) return;
|
||
lastAddrKey = now;
|
||
hardwarePortCache = null; // interface names may have changed too
|
||
console.log('[main] network changed — advertising: ' + (now || '(no network)'));
|
||
if (win && !win.isDestroyed() && srvInfo.ready) {
|
||
win.webContents.send('server:up', publicInfo());
|
||
}
|
||
} catch (_) {}
|
||
}, 10000).unref();
|
||
|
||
function createWindow() {
|
||
win = new BrowserWindow({
|
||
width: 880,
|
||
height: 760,
|
||
minWidth: 640,
|
||
minHeight: 560,
|
||
title: 'Church Translate',
|
||
backgroundColor: '#0b0d12',
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.js'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
/* The model files are fetched from huggingface.co on first run and then
|
||
cached; everything after that is local. */
|
||
webSecurity: true
|
||
}
|
||
});
|
||
|
||
console.log('[main] serving from ' + assetOrigin);
|
||
/* CT_SELFTEST=1 boots the diagnostic page instead of the app: a known
|
||
sentence through both models, with timings. Use it to tell "the AI is
|
||
broken" apart from "the audio routing is wrong". */
|
||
/* CT_PAGE loads a diagnostic page instead of the app — used for the model
|
||
benchmarks. CT_SELFTEST is the shorthand for the standard one. */
|
||
const page = process.env.CT_PAGE ? '/' + process.env.CT_PAGE
|
||
: process.env.CT_SELFTEST ? '/selftest.html?' + (process.env.CT_SELFTEST_ARGS || '')
|
||
: '/index.html';
|
||
win.loadURL(assetOrigin + page);
|
||
|
||
win.webContents.on('did-finish-load', () => console.log('[main] page loaded'));
|
||
win.webContents.on('did-fail-load', (_e, code, desc, url) =>
|
||
console.error('[main] page FAILED to load: ' + code + ' ' + desc + ' ' + url));
|
||
win.webContents.on('render-process-gone', (_e, details) =>
|
||
console.error('[main] renderer gone: ' + JSON.stringify(details)));
|
||
win.webContents.on('preload-error', (_e, file, err) =>
|
||
console.error('[main] preload error in ' + file + ': ' + err.message));
|
||
|
||
/* Forward the renderer's console to stdout. This app runs backstage and
|
||
nobody is going to open devtools during a service; when something goes
|
||
wrong the terminal (or the launchd log) is where the answer needs to be. */
|
||
win.webContents.on('console-message', (_e, level, message) => {
|
||
const tag = level >= 2 ? 'ERR ' : ' ';
|
||
process.stdout.write('[renderer] ' + tag + message + '\n');
|
||
});
|
||
|
||
/* Grant the microphone to our own page only — this app requests media for
|
||
the audio device the operator picked and nothing else. */
|
||
win.webContents.session.setPermissionRequestHandler((wc, permission, callback) => {
|
||
callback(permission === 'media');
|
||
});
|
||
|
||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||
/*
|
||
* Our own pages — the operator console and the poster — open as app
|
||
* windows, not in a browser. They are part of the tool, and bouncing the
|
||
* operator into Chrome mid-service means hunting for the right tab.
|
||
* Anything genuinely external still goes to the browser.
|
||
*/
|
||
let local = false;
|
||
try {
|
||
const u = new URL(url);
|
||
local = (u.hostname === '127.0.0.1' || u.hostname === 'localhost');
|
||
} catch (_) { local = false; }
|
||
|
||
if (!local) { shell.openExternal(url); return { action: 'deny' }; }
|
||
|
||
return {
|
||
action: 'allow',
|
||
overrideBrowserWindowOptions: {
|
||
width: 980,
|
||
height: 860,
|
||
minWidth: 600,
|
||
minHeight: 500,
|
||
title: 'Church Translate',
|
||
backgroundColor: '#0b0d12',
|
||
webPreferences: { contextIsolation: true, nodeIntegration: false }
|
||
}
|
||
};
|
||
});
|
||
|
||
/* Closing this window mid-sermon takes the translation down for the whole
|
||
congregation, and the window looks idle while it works. Ask first. */
|
||
win.on('close', (e) => {
|
||
if (!engineIsLive || forceQuit) return;
|
||
e.preventDefault();
|
||
/* "Stop and quit" used to call win.close(), which on macOS closes the
|
||
window and leaves the app running — the button did not do what it said.
|
||
Route both doors through app.quit() so there is one way out. */
|
||
askBeforeQuitting();
|
||
});
|
||
}
|
||
|
||
/*
|
||
* THE ONE PLACE QUITTING IS CONFIRMED.
|
||
*
|
||
* Asked from BOTH doors — Cmd-Q (before-quit) and closing the window (close) —
|
||
* because they used to disagree: the window asked and then called win.close(),
|
||
* which on macOS closes a window and quits nothing, while Cmd-Q killed every
|
||
* child process BEFORE the window ever got to ask and then had its quit
|
||
* cancelled by the answer. Whichever door the operator uses, the answer now
|
||
* means the same thing.
|
||
*
|
||
* Re-entrant by design: `asking` stops a second Cmd-Q stacking dialogs while
|
||
* the first is open, which otherwise left a modal that could not be dismissed.
|
||
*/
|
||
let asking = false;
|
||
function askBeforeQuitting() {
|
||
if (asking) return;
|
||
asking = true;
|
||
const parent = (win && !win.isDestroyed()) ? win : null;
|
||
const opts = {
|
||
type: 'warning',
|
||
buttons: ['Keep running', 'Stop and quit'],
|
||
defaultId: 0,
|
||
cancelId: 0,
|
||
message: 'Translation is live right now.',
|
||
detail: 'Quitting stops the captions for everyone currently listening.'
|
||
};
|
||
const shown = parent ? dialog.showMessageBox(parent, opts) : dialog.showMessageBox(opts);
|
||
shown.then(({ response }) => {
|
||
asking = false;
|
||
if (response === 1) { forceQuit = true; app.quit(); }
|
||
}).catch(() => { asking = false; });
|
||
}
|
||
|
||
console.log('[main] waiting for app ready…');
|
||
app.whenReady().then(async () => {
|
||
console.log('[main] app ready');
|
||
try {
|
||
await startAssetServer();
|
||
} catch (e) {
|
||
dialog.showErrorBox('Church Translate',
|
||
'Could not start the local page server:\n' + e.message);
|
||
app.quit();
|
||
return;
|
||
}
|
||
|
||
/* The window comes up first, always.
|
||
Asking macOS for microphone access here instead would block on a system
|
||
dialog before anything is drawn — the app would look like it simply failed
|
||
to launch, and if that prompt is ever missed it hangs with no UI at all.
|
||
The renderer asks once the window is up and can explain why. */
|
||
createWindow();
|
||
try { startServer(); }
|
||
catch (e) { dialog.showErrorBox('Church Translate', 'Could not start the congregation server:\n' + e.message); }
|
||
/* Voices are a bonus on top of captions — if they fail to start, the service
|
||
still runs, so this never blocks or shows a dialog. */
|
||
try { startTts(); }
|
||
catch (e) { console.error('[main] built-in voices unavailable: ' + e.message); }
|
||
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
||
});
|
||
|
||
app.on('second-instance', () => {
|
||
/* Somebody tried to open it again — show them the copy that is already
|
||
running rather than silently doing nothing. */
|
||
if (win && !win.isDestroyed()) {
|
||
if (win.isMinimized()) win.restore();
|
||
win.show();
|
||
win.focus();
|
||
}
|
||
});
|
||
|
||
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
|
||
app.on('before-quit', () => { if (assetServer) { try { assetServer.close(); } catch (_) {} } });
|
||
|
||
// ---------------------------------------------------------------- ipc
|
||
|
||
// ---------------------------------------------------------------- translation
|
||
/*
|
||
* Translation runs HERE, in the Node process, not in the renderer with Whisper.
|
||
*
|
||
* ONNX Runtime *Web* stages weights through a 32-bit WASM heap capped at 4 GB.
|
||
* NLLB-200 needs roughly 1.7 GB of fp16 weights and about 2.5× that transiently
|
||
* while the session is built, so it aborts — in its own worker, with its own
|
||
* heap, at fp16 and at q4f16 alike. The only variant that fitted was int8, and
|
||
* int8 NLLB emits fluent-looking nonsense assembled from a dozen unrelated
|
||
* languages while running on the CPU at a minute a sentence.
|
||
*
|
||
* onnxruntime-node is native and 64-bit and has none of those limits. Measured
|
||
* on this M2 Max: fp32, correct output in every script tested, ~0.8 s per
|
||
* language. Whisper stays on WebGPU in the renderer, where it is 4.8× real time.
|
||
* Each model ends up on the runtime that can actually carry it.
|
||
*/
|
||
/*
|
||
* A pool of translation processes, not one.
|
||
*
|
||
* Sized to one by default, and the pool exists mainly so that is a considered
|
||
* choice rather than an accident. Transformers.js drives generation token by
|
||
* token on a single JS thread, so concurrent requests to one process interleave
|
||
* rather than overlap. Separate processes do run at once — but measurement said
|
||
* it does not help: five languages took 1.9 s in one process and 3.4 s across
|
||
* three, because NLLB is memory-bandwidth-bound and three 3.4 GB copies fight
|
||
* over the same bandwidth (and 17 GB of RAM).
|
||
*
|
||
* Also worth recording: quantized weights are NOT the shortcut they look like.
|
||
* In this native runtime q8 is correct but 2.4× slower than fp32 (4.6 s vs
|
||
* 1.9 s). In the browser runtime it was both slower and produced gibberish.
|
||
* fp32 is the right answer on both counts.
|
||
*/
|
||
const MT_MAX_RESTARTS = 3;
|
||
|
||
const mtPool = []; // { child, busy, ready }
|
||
let mtReady = false;
|
||
let mtLoading = null;
|
||
let mtSeq = 0;
|
||
let mtRestarts = 0;
|
||
const mtPending = new Map();
|
||
|
||
/*
|
||
* ONE translation process. MEASURED: three were SLOWER than one (3.4s vs 1.9s
|
||
* for five languages) and two ran at 0.92x — the model is memory-bandwidth-
|
||
* bound, so more copies of it just contend for the same bus. The fallbacks
|
||
* used to be 3, which is the one value the measurement rules out.
|
||
*/
|
||
function poolSize() {
|
||
const cfg = loadConfig();
|
||
const n = Number(cfg.mtWorkers);
|
||
return Math.max(1, Math.min(4, isFinite(n) && n > 0 ? n : 1));
|
||
}
|
||
|
||
/*
|
||
* Where to find a real Node. A copy shipped inside the app wins, so a packaged
|
||
* build does not depend on what happens to be installed on the machine.
|
||
*/
|
||
/*
|
||
* "Share to the internet" — a Cloudflare quick tunnel in front of the
|
||
* congregation server, so a YouTube viewer at home can open the same listener
|
||
* page as someone in the room. cloudflared (Apache-2.0, version-pinned,
|
||
* bundled in the app — nothing downloads on a Sunday morning) is spawned
|
||
* against 127.0.0.1 and prints a random https://….trycloudflare.com URL.
|
||
*
|
||
* The URL is NEW ON EVERY START — that is the nature of account-less quick
|
||
* tunnels. It is surfaced to the operator to paste into the stream
|
||
* description, and deliberately NEVER persisted into config.publicUrl: a
|
||
* stale tunnel URL on the printed QR poster would be worse than none.
|
||
*/
|
||
let tunnelChild = null;
|
||
let tunnelState = { running: false, url: '', error: '' };
|
||
|
||
function cloudflaredPath() {
|
||
const exe = process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
|
||
const bundled = path.join(process.resourcesPath || '', exe);
|
||
if (fs.existsSync(bundled)) return bundled;
|
||
const dev = path.join(__dirname, 'vendor', 'cloudflared');
|
||
return fs.existsSync(dev) ? dev : null;
|
||
}
|
||
|
||
function tunnelStatusPush() {
|
||
if (win && !win.isDestroyed()) {
|
||
try { win.webContents.send('tunnel:status', tunnelState); } catch (_) {}
|
||
}
|
||
}
|
||
|
||
function startTunnel() {
|
||
if (tunnelChild) return tunnelState;
|
||
const bin = cloudflaredPath();
|
||
if (!bin) {
|
||
tunnelState = { running: false, url: '', error: 'cloudflared is not bundled in this build' };
|
||
tunnelStatusPush();
|
||
return tunnelState;
|
||
}
|
||
const port = Number((loadConfig() || {}).port) || 3100;
|
||
tunnelState = { running: true, url: '', error: '' };
|
||
tunnelStatusPush();
|
||
|
||
/* http2, not QUIC: church networks filter UDP more often than TCP 443, and
|
||
QUIC datagram failures here look like a tunnel that randomly dies. */
|
||
tunnelChild = spawn(bin, ['tunnel', '--no-autoupdate', '--protocol', 'http2',
|
||
'--url', 'http://127.0.0.1:' + port], {
|
||
stdio: ['ignore', 'pipe', 'pipe']
|
||
});
|
||
|
||
const onData = (buf) => {
|
||
const s = buf.toString();
|
||
const hit = s.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||
if (hit && !tunnelState.url) {
|
||
tunnelState.url = hit[0];
|
||
console.log('[main] tunnel up at ' + tunnelState.url);
|
||
tunnelStatusPush();
|
||
}
|
||
};
|
||
tunnelChild.stdout.on('data', onData);
|
||
tunnelChild.stderr.on('data', onData);
|
||
|
||
tunnelChild.on('exit', (code) => {
|
||
const wanted = tunnelState.running;
|
||
tunnelChild = null;
|
||
if (wanted) {
|
||
/* It died while we still wanted it — say so and retry once shortly.
|
||
trycloudflare has no SLA; the LAN address keeps working regardless. */
|
||
tunnelState = { running: false, url: '', error: 'tunnel dropped (exit ' + code + ')' };
|
||
tunnelStatusPush();
|
||
setTimeout(() => { if (!tunnelChild && tunnelState.error) startTunnel(); }, 5000);
|
||
} else {
|
||
tunnelState = { running: false, url: '', error: '' };
|
||
tunnelStatusPush();
|
||
}
|
||
});
|
||
return tunnelState;
|
||
}
|
||
|
||
function stopTunnel() {
|
||
tunnelState.running = false;
|
||
tunnelState.url = '';
|
||
tunnelState.error = '';
|
||
if (tunnelChild) { try { tunnelChild.kill(); } catch (_) {} tunnelChild = null; }
|
||
tunnelStatusPush();
|
||
return tunnelState;
|
||
}
|
||
|
||
/*
|
||
* The local-voices sidecar: mlx-audio serving Qwen3-TTS on this Mac.
|
||
* Bought once for $0 — Apache-2.0 weights, no network after install, no
|
||
* metering. Installed per machine by tools/setup-local-voices.sh into a venv
|
||
* under Application Support (a venv cannot be copied between Macs).
|
||
*
|
||
* Lifecycle mirrors the tunnel: spawned only while the premium-voice provider
|
||
* is 'local', health-probed, warmed with one real synthesis (the first
|
||
* request pays the ~8s model load — Sunday must not), auto-restarted.
|
||
*/
|
||
let localVoiceChild = null;
|
||
let localVoiceState = { running: false, ready: false, error: '' };
|
||
|
||
function localVenvPython() {
|
||
const p = path.join(app.getPath('userData'), '..', 'Church Translate Local Voices', 'venv', 'bin', 'python3');
|
||
return fs.existsSync(p) ? p : null;
|
||
}
|
||
|
||
/*
|
||
* Two different 'ready's. The operator's pill may say the natural voices are
|
||
* running the moment the Kokoro pairs answer. The VOICE PROCESS must not be
|
||
* told so until this file has stopped using the sidecar: it is one inference
|
||
* thread, and while main.js is still loading the 1.7 GB Qwen3 model every live
|
||
* Kokoro request queues behind that load — review measured a 4.2 s cold Qwen3
|
||
* warm-up, so with the breaker armed those aborts would have been strikes and
|
||
* parked every natural voice for five minutes, at the very start of a service.
|
||
*/
|
||
let sidecarArmed = false;
|
||
let lastSidecarReadySent = null;
|
||
function pushSidecarToTts(force) {
|
||
const ready = !!(localVoiceState && localVoiceState.ready && sidecarArmed);
|
||
if (!force && lastSidecarReadySent === ready) return;
|
||
lastSidecarReadySent = ready;
|
||
if (ttsChild && ttsReady) { try { ttsChild.send({ type: 'sidecar', ready }); } catch (_) {} }
|
||
}
|
||
|
||
/* The last error written to app.log, so a steady state is said once. */
|
||
let lastVoiceErrLogged = null;
|
||
|
||
function localVoicesPush() {
|
||
/*
|
||
* THE REASON GOES IN THE LOG, NOT ONLY IN THE WINDOW.
|
||
*
|
||
* This state used to travel to the renderer and nowhere else, so app.log
|
||
* recorded only the downstream symptom — "premium miss not counted (cold
|
||
* load): fetch failed", forty times a service — and never the cause, which
|
||
* main.js knew exactly: "not installed — run tools/setup-local-voices.sh".
|
||
* That asymmetry is why a half-finished machine migration ran for weeks with
|
||
* every sentence in a fallback voice and nobody able to say why. The log is
|
||
* where anyone looks first; it is where the answer belongs.
|
||
*
|
||
* Once per transition, not per push: this is called on every readiness
|
||
* change and would otherwise repeat the same line all service.
|
||
*/
|
||
const err = (localVoiceState && localVoiceState.error) || '';
|
||
if (err !== lastVoiceErrLogged) {
|
||
lastVoiceErrLogged = err;
|
||
if (err) console.error('[voices] natural voices are NOT running: ' + err);
|
||
else if (localVoiceState && localVoiceState.ready) console.log('[voices] natural voices ready');
|
||
}
|
||
if (win && !win.isDestroyed()) {
|
||
try { win.webContents.send('localvoices:status', localVoiceState); } catch (_) {}
|
||
}
|
||
/* The voice process must know too: its breaker does not count a miss while
|
||
the sidecar is starting, and it unparks the moment the sidecar is ready. */
|
||
pushSidecarToTts(false);
|
||
}
|
||
|
||
/*
|
||
* Warm what is actually going to speak.
|
||
*
|
||
* This used to send one Qwen3 'aiden' sentence — a 1.7 GB model nobody had
|
||
* chosen — and then declare the sidecar ready, while the 82 MB Kokoro model
|
||
* that every pinned language actually used sat cold until the first sentence
|
||
* of the service. That first Kokoro answer takes ~4.3 s (measured), which the
|
||
* voice process's old fixed 2.5 s budget read as three failures, and the
|
||
* service spent its first five minutes in the wrong voice. The app had been
|
||
* up for 92 minutes.
|
||
*
|
||
* So: one short sentence for every distinct (model, voice, language) pair in
|
||
* the operator's choices, Qwen3 only where a choice or the global provider
|
||
* really uses it, and 'ready' means all of them answered. The voice process
|
||
* is told on every transition.
|
||
*/
|
||
function warmupPairs() {
|
||
const cfg = loadConfig();
|
||
const { KOKORO_MODEL, QWEN_MODEL, KOKORO_LANG, LOCAL_LANG } = require('./sidecar-langs.js');
|
||
const cloudCfg = cfg.cloudTts || {};
|
||
const seen = new Set();
|
||
const out = [];
|
||
const add = (model, voice, lang_code, sample) => {
|
||
const k = model + '|' + voice + '|' + lang_code;
|
||
if (!voice || !lang_code || seen.has(k)) return;
|
||
seen.add(k);
|
||
out.push({ model, voice, lang_code, input: sample });
|
||
};
|
||
const SAMPLE = { e: 'Buenos días, iglesia.', p: 'Bom dia, igreja.', f: 'Bonjour, église.', i: 'Buongiorno, chiesa.',
|
||
a: 'Good morning, church.', h: 'नमस्ते।', j: 'おはようございます。', z: '早上好。' };
|
||
/*
|
||
* Which languages will actually be spoken: the congregation server's pinned
|
||
* list. A Qwen3 voice chosen for a language nobody has pinned (deu_Latn at
|
||
* this church) is a 1.7 GB load that would hold the sidecar — and the
|
||
* breaker — for nothing; if a German listener turns up it loads then, on
|
||
* its own generous first budget. Kokoro is cheap and warms regardless.
|
||
*/
|
||
let pinned = null;
|
||
try {
|
||
const srv = JSON.parse(fs.readFileSync(path.join(dataDir(), 'config.json'), 'utf8'));
|
||
if (Array.isArray(srv.pinned)) pinned = new Set(srv.pinned);
|
||
} catch (_) {}
|
||
const spokenSoon = (lang) => !pinned || pinned.has(lang);
|
||
|
||
const choices = cfg.voiceChoices || {};
|
||
for (const lang of Object.keys(choices)) {
|
||
const c = choices[lang] || {};
|
||
if (c.engine === 'kokoro' && c.speaker) {
|
||
const code = KOKORO_LANG[lang];
|
||
add(cloudCfg.kokoroModel || KOKORO_MODEL, c.speaker, code, SAMPLE[code] || 'Hello.');
|
||
} else if (c.engine === 'local' && c.speaker && spokenSoon(lang) && process.platform !== 'win32') {
|
||
/* Qwen3 is MLX-only. On Windows that language falls back to its built-in
|
||
voice rather than warming a model the machine cannot run. */
|
||
add(cloudCfg.localModel || QWEN_MODEL, c.speaker, LOCAL_LANG[lang] || 'english', 'Ready.');
|
||
}
|
||
}
|
||
/* The GLOBAL Qwen3 provider only matters for a pinned language with no
|
||
choice of its own; warm its default voice once in that case. */
|
||
const needsGlobalQwen = process.platform !== 'win32' && cloudCfg.provider === 'local' &&
|
||
(pinned ? Array.from(pinned).some((l) => !(choices[l] && choices[l].engine) && LOCAL_LANG[l]) : true);
|
||
if (needsGlobalQwen) add(cloudCfg.localModel || QWEN_MODEL, cloudCfg.localVoice || 'aiden', 'english', 'Ready.');
|
||
if (cloudCfg.provider === 'kokoro') add(cloudCfg.kokoroModel || KOKORO_MODEL, cloudCfg.kokoroVoice || 'em_alex', 'e', SAMPLE.e);
|
||
return out;
|
||
}
|
||
|
||
/*
|
||
* One warm loop at a time, Kokoro first, and 'ready' the moment the voices the
|
||
* pinned languages actually speak have answered — the 1.7 GB Qwen3 model keeps
|
||
* loading behind that instead of holding readiness (and therefore the voice
|
||
* process's breaker) hostage. A pair the sidecar rejects outright is skipped
|
||
* after a log line rather than blocking everything; only a sidecar that never
|
||
* answers ANY pair is reported as never ready.
|
||
*/
|
||
const warmedPairs = new Set();
|
||
let warmInFlight = null;
|
||
let warmChild = null; // which sidecar process the in-flight loop serves
|
||
const pairId = (p) => p.model + '|' + p.voice + '|' + p.lang_code;
|
||
const isKokoroPair = (p) => p.model.indexOf('Kokoro') !== -1;
|
||
|
||
async function warmOne(port, p) {
|
||
const ctl = new AbortController();
|
||
const timer = setTimeout(() => ctl.abort(), 60000); // a cold 1.7 GB model can take a while
|
||
try {
|
||
const res = await fetch('http://127.0.0.1:' + port + '/v1/audio/speech', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ctl.signal,
|
||
body: JSON.stringify(Object.assign({ response_format: 'wav', temperature: 0 }, p,
|
||
isKokoroPair(p) ? {} : { max_tokens: 120 }))
|
||
});
|
||
if (!res.ok) { const e = new Error('warmup ' + res.status + ' for ' + p.voice); e.httpStatus = res.status; throw e; }
|
||
await res.arrayBuffer();
|
||
} finally { clearTimeout(timer); }
|
||
}
|
||
|
||
function warmLocalVoices(port, attempt = 0) {
|
||
if (warmInFlight) {
|
||
/* A loop for THIS sidecar: join it. A loop for a sidecar that has since
|
||
been stopped and restarted: let it wind down, then warm the new one —
|
||
otherwise the new process is never warmed and never armed. */
|
||
if (warmChild === localVoiceChild) return warmInFlight;
|
||
return warmInFlight.then(() => warmLocalVoices(port, attempt), () => warmLocalVoices(port, attempt));
|
||
}
|
||
const child = localVoiceChild;
|
||
if (!child) return Promise.resolve();
|
||
warmChild = child;
|
||
const fromStartup = attempt === 0;
|
||
sidecarArmed = false;
|
||
warmInFlight = (async () => {
|
||
const pairs = warmupPairs().filter((p) => !warmedPairs.has(pairId(p)));
|
||
pairs.sort((x, y) => Number(isKokoroPair(y)) - Number(isKokoroPair(x)));
|
||
const kokoroLeft = () => pairs.some((p) => isKokoroPair(p) && !warmedPairs.has(pairId(p)));
|
||
const declareReady = () => {
|
||
if (localVoiceState.ready) return;
|
||
localVoiceState.ready = true; localVoiceState.error = '';
|
||
console.log('[main] local voices ready (' + warmedPairs.size + ' voice' + (warmedPairs.size === 1 ? '' : 's') + ' warm)');
|
||
localVoicesPush();
|
||
};
|
||
let answered = 0;
|
||
for (const p of pairs) {
|
||
let ok = false;
|
||
for (let tries = 0; tries < 30 && !ok; tries++) {
|
||
if (localVoiceChild !== child || !child) return; // the sidecar restarted underneath us
|
||
try { await warmOne(port, p); ok = true; }
|
||
catch (err) {
|
||
if (err.httpStatus) { console.error('[main] warmup: sidecar rejected ' + p.voice + ' (' + p.lang_code + '): ' + err.message); break; }
|
||
await new Promise((r) => setTimeout(r, 2000)); // not up yet, or still loading
|
||
}
|
||
}
|
||
if (localVoiceChild !== child) return;
|
||
if (ok) { warmedPairs.add(pairId(p)); answered++; console.log('[main] warmed ' + p.voice + ' (' + p.lang_code + ')'); }
|
||
/* Ready as soon as every Kokoro pair is warm; Qwen3 keeps loading behind. */
|
||
if (answered && !kokoroLeft()) declareReady();
|
||
}
|
||
if (localVoiceChild !== child) return;
|
||
if (answered || warmedPairs.size) declareReady();
|
||
/* No pairs to warm means no natural voice has been CHOSEN yet — the
|
||
sidecar is up and idle, which is a perfectly good state. Reporting
|
||
"never became ready" there told the operator the engine was broken when
|
||
they had simply not picked a voice. */
|
||
else if (!pairs.length) declareReady();
|
||
else if (fromStartup) { localVoiceState.error = 'local voices never became ready'; localVoicesPush(); }
|
||
/* Only now is the sidecar free of this file's own requests. */
|
||
if (localVoiceState.ready) { sidecarArmed = true; pushSidecarToTts(false); }
|
||
})().finally(() => { warmInFlight = null; });
|
||
return warmInFlight;
|
||
}
|
||
|
||
/*
|
||
* Clear an ORPHANED voice service off our port before spawning.
|
||
*
|
||
* Measured on this Mac: an mlx_audio.server from a previous run had been
|
||
* reparented to launchd and was still up 29 HOURS later at 0.1% CPU. It
|
||
* answered /v1/models with 200 but never returned from /v1/audio/speech — so
|
||
* an audition sat there until it timed out and reported "that voice did not
|
||
* answer". Worse, it held port 43111, so this app's own child could not bind
|
||
* and exited 1, which the operator saw as "local voices stopped (exit 1)".
|
||
*
|
||
* The port belongs to this app. Anything else listening on it is wreckage.
|
||
* The command line is checked first so an unrelated process on the same port
|
||
* is never touched.
|
||
*/
|
||
function clearStaleLocalVoices(port) {
|
||
/*
|
||
* lsof/kill are Unix. The mlx sidecar is Apple-Silicon-only anyway, so on
|
||
* Windows there is nothing of ours holding this port. If a Windows voice
|
||
* backend ever binds it, the equivalent is:
|
||
* Get-NetTCPConnection -LocalPort N -State Listen | Select -Exp OwningProcess
|
||
*/
|
||
if (process.platform === 'win32') return;
|
||
let pids = '';
|
||
try {
|
||
pids = require('child_process')
|
||
.execSync('/usr/sbin/lsof -ti tcp:' + port + ' -sTCP:LISTEN', { encoding: 'utf8' }).trim();
|
||
} catch (_) { return; } // nothing listening
|
||
for (const pid of pids.split('\n').map((x) => x.trim()).filter(Boolean)) {
|
||
let cmd = '';
|
||
try {
|
||
cmd = require('child_process')
|
||
.execSync('/bin/ps -o command= -p ' + pid, { encoding: 'utf8' });
|
||
} catch (_) { continue; }
|
||
/*
|
||
* BOTH sidecars, not just the Python one. macOS falls back to
|
||
* kokoro-sidecar.js when no venv is installed, and that process orphans on
|
||
* exactly the path operators actually use — force-quitting the app, which
|
||
* is currently the only way to quit it. An orphan holds this port, the next
|
||
* launch cannot bind, and natural voices are dead again with the same
|
||
* "fetch failed" that took a machine migration to diagnose.
|
||
*/
|
||
if (cmd.indexOf('mlx_audio.server') === -1 &&
|
||
cmd.indexOf('kokoro-sidecar.js') === -1) continue; // not ours; leave it alone
|
||
console.log('[voices] clearing an orphaned voice service on port ' + port + ' (pid ' + pid + ')');
|
||
try { process.kill(Number(pid), 'SIGKILL'); } catch (_) {}
|
||
}
|
||
}
|
||
|
||
/* The Windows natural-voice sidecar: our own JS, run by the bundled node. */
|
||
function startJsVoices() {
|
||
const nodeBin = findNodeBinary();
|
||
if (!nodeBin) {
|
||
localVoiceState = { running: false, ready: false, error: 'Node.js was not found — natural voices need it' };
|
||
localVoicesPush();
|
||
return localVoiceState;
|
||
}
|
||
const port = Number((loadConfig().cloudTts || {}).localPort) || 43111;
|
||
/* Same reason the Python path does it: an orphan from a force-quit still
|
||
holds the port, and spawning into that gives a sidecar that exits on
|
||
EADDRINUSE and a voice engine that is silently absent. */
|
||
clearStaleLocalVoices(port);
|
||
localVoiceState = { running: true, ready: false, error: '' };
|
||
localVoicesPush();
|
||
|
||
localVoiceChild = spawn(nodeBin, [path.join(__dirname, 'kokoro-sidecar.js'), String(port)], {
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
windowsHide: true,
|
||
env: Object.assign({}, process.env, {
|
||
CT_MODELS_DIR: MODELS_DIR,
|
||
CT_KOKORO_VOICES: path.join(__dirname, 'node_modules', 'kokoro-js', 'voices')
|
||
})
|
||
});
|
||
|
||
let tail = [];
|
||
const keep = (d) => {
|
||
for (const line of String(d).split('\n')) {
|
||
const t = line.trim();
|
||
if (t) { tail.push(t); if (tail.length > 12) tail.shift(); }
|
||
}
|
||
};
|
||
localVoiceChild.stdout.on('data', keep);
|
||
localVoiceChild.stderr.on('data', keep);
|
||
teeChild(localVoiceChild, 'sidecar');
|
||
localVoiceChild.on('exit', (code) => {
|
||
const wanted = localVoiceState.running;
|
||
const why = tail.length ? tail[tail.length - 1].slice(0, 160) : '';
|
||
localVoiceChild = null;
|
||
localVoiceState = { running: false, ready: false,
|
||
error: wanted ? ('natural voices stopped (exit ' + code + ')' + (why ? ' — ' + why : '')) : '' };
|
||
warmedPairs.clear();
|
||
sidecarArmed = false;
|
||
localVoicesPush();
|
||
if (wanted) setTimeout(() => { if (sidecarWanted()) startLocalVoices(); }, 5000);
|
||
});
|
||
warmLocalVoices(port);
|
||
return localVoiceState;
|
||
}
|
||
|
||
function startLocalVoices() {
|
||
if (localVoiceChild) return localVoiceState;
|
||
|
||
/*
|
||
* WINDOWS TAKES A DIFFERENT ROAD TO THE SAME PLACE.
|
||
*
|
||
* The natural voices on macOS are Kokoro running on MLX inside a Python
|
||
* sidecar, and MLX is Apple-Silicon-only. Rather than leave Windows on the
|
||
* built-in voices, it runs kokoro-sidecar.js — the same Kokoro model through
|
||
* the ONNX runtime the app already ships — which serves the SAME HTTP API on
|
||
* the SAME port. Everything downstream (warm-up, readiness, the breaker,
|
||
* per-language voice choices) is identical because nothing downstream can
|
||
* tell the difference. Verified against the live MLX sidecar: identical
|
||
* clip durations, phoneme-for-phoneme identical, level matched.
|
||
*/
|
||
if (process.platform === 'win32') return startJsVoices();
|
||
|
||
const py = localVenvPython();
|
||
if (!py) {
|
||
/*
|
||
* NO PYTHON HERE — FALL BACK TO THE SAME KOKORO, THROUGH ONNX.
|
||
*
|
||
* This used to stop dead with "not installed", and natural voices simply
|
||
* never spoke: MEASURED on this machine, every sentence of every service
|
||
* fell back to Piper and logged "fetch failed", because the venv the
|
||
* installer builds had never been created (a venv copied from another Mac
|
||
* carries that Mac's absolute paths — its python3 symlink still pointed at
|
||
* /Users/Studio — so it cannot be moved, which is exactly what the project
|
||
* README warns).
|
||
*
|
||
* But the Python sidecar is only REQUIRED for Qwen3, which needs MLX. The
|
||
* JS sidecar this app already ships runs the same Kokoro model through the
|
||
* ONNX runtime, serves the identical HTTP API on the identical port, and
|
||
* is verified phoneme-for-phoneme identical (see startJsVoices). Measured
|
||
* cold on this Mac: model ready in 0.3s, 2.6s of Spanish synthesised in
|
||
* 0.44s. A Qwen3 request still gets an honest 501 and falls back to the
|
||
* built-in voice, which is what happens on Windows and is the correct
|
||
* outcome where MLX cannot run.
|
||
*
|
||
* So: Kokoro voices work with nothing to install. Running the installer
|
||
* still adds Qwen3 on an Apple-Silicon Mac; it is no longer the difference
|
||
* between natural voices and none.
|
||
*/
|
||
console.log('[voices] no Python venv — serving Kokoro through the bundled ONNX ' +
|
||
'sidecar instead (run tools/setup-local-voices.sh to add Qwen3)');
|
||
return startJsVoices();
|
||
}
|
||
const port = Number((loadConfig().cloudTts || {}).localPort) || 43111;
|
||
clearStaleLocalVoices(port);
|
||
localVoiceState = { running: true, ready: false, error: '' };
|
||
localVoicesPush();
|
||
/*
|
||
* Kokoro phonemises through espeak-ng, and phonemizer finds the library only
|
||
* by environment variable — without these it fails at synthesis with
|
||
* "espeak not installed on your system" even though espeakng-loader ships a
|
||
* perfectly good copy inside the venv. Set from the venv itself, so no
|
||
* Homebrew and nothing to install per campus.
|
||
*
|
||
* Harmless for Qwen3, which never looks at them.
|
||
*/
|
||
const venvRoot = path.dirname(path.dirname(py));
|
||
const espeakDir = path.join(venvRoot, 'lib', 'python3.13', 'site-packages', 'espeakng_loader');
|
||
const espeakEnv = {};
|
||
try {
|
||
const lib = path.join(espeakDir, 'libespeak-ng.dylib');
|
||
const data = path.join(espeakDir, 'espeak-ng-data');
|
||
if (fs.existsSync(lib)) espeakEnv.PHONEMIZER_ESPEAK_LIBRARY = lib;
|
||
if (fs.existsSync(data)) { espeakEnv.PHONEMIZER_ESPEAK_PATH = data; espeakEnv.ESPEAK_DATA_PATH = data; }
|
||
} catch (_) {}
|
||
|
||
/*
|
||
* A WRITABLE working directory, and this one line is the whole bug.
|
||
*
|
||
* mlx_audio.server does `self.log_dir.mkdir()` on a RELATIVE path, 'logs',
|
||
* so it lands wherever the process happens to be started from. Launched by
|
||
* the Finder a Mac app inherits '/' as its working directory, which is
|
||
* read-only on a sealed system volume — so the sidecar died on startup with
|
||
*
|
||
* OSError: [Errno 30] Read-only file system: 'logs'
|
||
*
|
||
* every single time, in under a second, before it loaded anything. Launched
|
||
* from a terminal it inherited a writable directory and worked perfectly,
|
||
* which is what made this look like an environment problem for so long. It
|
||
* was never the environment. It was the current directory.
|
||
*
|
||
* The symptom the operator saw was "fetch failed" on any natural voice, and
|
||
* the lasting consequence was that every language quietly ended up on Piper.
|
||
*/
|
||
const voiceCwd = path.join(dataDir(), 'local-voices');
|
||
try { fs.mkdirSync(voiceCwd, { recursive: true }); } catch (_) {}
|
||
|
||
localVoiceChild = spawn(py, ['-m', 'mlx_audio.server', '--host', '127.0.0.1', '--port', String(port)],
|
||
{ stdio: ['ignore', 'pipe', 'pipe'], cwd: voiceCwd,
|
||
env: Object.assign({}, process.env, espeakEnv) });
|
||
/*
|
||
* KEEP the output. Both streams used to be thrown away, and the cost of that
|
||
* was not theoretical: the service was starting and dying on every launch —
|
||
* port 43111 empty while the app ran with the provider set to local — and
|
||
* there was no way whatsoever to find out why. "local voices stopped (exit
|
||
* 1)" is not a diagnosis. The last few lines of stderr usually are.
|
||
*/
|
||
let voiceErrTail = [];
|
||
const keepTail = (d) => {
|
||
for (const line of String(d).split('\n')) {
|
||
const t = line.trim();
|
||
if (!t || /UserWarning|pkg_resources|warnings\.warn/.test(t)) continue;
|
||
voiceErrTail.push(t);
|
||
if (voiceErrTail.length > 12) voiceErrTail.shift();
|
||
}
|
||
};
|
||
localVoiceChild.stdout.on('data', keepTail);
|
||
localVoiceChild.stderr.on('data', keepTail);
|
||
teeChild(localVoiceChild, 'sidecar');
|
||
localVoiceChild.on('exit', (code) => {
|
||
const wanted = localVoiceState.running;
|
||
localVoiceChild = null;
|
||
/* Printed to the app's console AND carried into the status the operator
|
||
sees, because a failure nobody can read is a failure nobody can fix. */
|
||
if (wanted && voiceErrTail.length) {
|
||
const detail = '[' + new Date().toISOString() + '] exited ' + code + '\n ' +
|
||
voiceErrTail.join('\n ') + '\n';
|
||
console.error('[main] local voices exited ' + code + '; last output:\n ' +
|
||
voiceErrTail.join('\n '));
|
||
/*
|
||
* To a FILE as well. When the app is launched by the Finder its console
|
||
* output goes nowhere, and this failure only happens when it is launched
|
||
* that way — so the one channel that could explain it was the one that
|
||
* did not exist. Small file, truncated, next to the transcripts.
|
||
*/
|
||
try {
|
||
const lf = path.join(dataDir(), 'local-voices.log');
|
||
let prev = '';
|
||
try { prev = fs.readFileSync(lf, 'utf8'); } catch (_) {}
|
||
fs.writeFileSync(lf, (prev + detail).slice(-20000));
|
||
} catch (_) {}
|
||
}
|
||
const why = voiceErrTail.length ? voiceErrTail[voiceErrTail.length - 1].slice(0, 160) : '';
|
||
localVoiceState = { running: false, ready: false,
|
||
error: wanted ? ('local voices stopped (exit ' + code + ')' + (why ? ' — ' + why : '')) : '' };
|
||
warmedPairs.clear();
|
||
sidecarArmed = false;
|
||
localVoicesPush();
|
||
if (wanted) setTimeout(() => {
|
||
if (sidecarWanted()) startLocalVoices();
|
||
}, 5000);
|
||
});
|
||
warmLocalVoices(port);
|
||
return localVoiceState;
|
||
}
|
||
|
||
function stopLocalVoices() {
|
||
localVoiceState.running = false;
|
||
localVoiceState.ready = false;
|
||
warmedPairs.clear();
|
||
sidecarArmed = false;
|
||
if (localVoiceChild) { try { localVoiceChild.kill(); } catch (_) {} localVoiceChild = null; }
|
||
localVoicesPush();
|
||
}
|
||
|
||
/*
|
||
* Does anything actually need the on-Mac voice sidecar?
|
||
*
|
||
* Two engines ride it — Kokoro and Qwen3 — so the global provider alone is not
|
||
* the answer. And it was never quite right even for one engine: the Voices
|
||
* card lets an operator give ONE language a sidecar voice while the global
|
||
* provider stays 'off' (Spanish natural, the rest built-in), and the code
|
||
* claimed "the sidecar is running either way" while this function would have
|
||
* stopped it. So ask the per-language choices too.
|
||
*/
|
||
function sidecarWanted() {
|
||
const cfg = loadConfig();
|
||
const provider = ((cfg.cloudTts || {}).provider) || 'off';
|
||
if (provider === 'local' || provider === 'kokoro') return true;
|
||
const choices = cfg.voiceChoices || {};
|
||
for (const k of Object.keys(choices)) {
|
||
const e = choices[k] && choices[k].engine;
|
||
if (e === 'local' || e === 'kokoro') return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function syncLocalVoices() {
|
||
if (sidecarWanted()) startLocalVoices(); else stopLocalVoices();
|
||
}
|
||
|
||
function findNodeBinary() {
|
||
const win = process.platform === 'win32';
|
||
const candidates = win ? [
|
||
path.join(process.resourcesPath || '', 'node.exe'),
|
||
path.join(__dirname, 'vendor-win', 'node.exe'),
|
||
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs', 'node.exe'),
|
||
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'nodejs', 'node.exe')
|
||
] : [
|
||
path.join(process.resourcesPath || '', 'node'),
|
||
path.join(__dirname, 'vendor', 'node'),
|
||
'/usr/local/bin/node',
|
||
'/opt/homebrew/bin/node',
|
||
'/usr/bin/node'
|
||
];
|
||
for (const c of candidates) {
|
||
try { if (c && fs.existsSync(c) && fs.statSync(c).isFile()) return c; } catch (_) {}
|
||
}
|
||
try {
|
||
/* where.exe prints ONE LINE PER MATCH; which prints one. Take the first. */
|
||
const finder = win ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'where.exe') : '/usr/bin/which';
|
||
const out = require('child_process')
|
||
.execFileSync(finder, ['node' + (win ? '.exe' : '')], { encoding: 'utf8', windowsHide: true });
|
||
const found = String(out).split(/\r?\n/).map((x) => x.trim()).filter(Boolean)[0];
|
||
if (found && fs.existsSync(found)) return found;
|
||
} catch (_) {}
|
||
return null;
|
||
}
|
||
|
||
function spawnOne(index) {
|
||
const cfg = loadConfig();
|
||
|
||
/*
|
||
* Forked with a REAL Node binary — deliberately not Electron's.
|
||
*
|
||
* Chromium replaces the system allocator with PartitionAlloc, which traps on
|
||
* purpose instead of returning null when an allocation exceeds its cap.
|
||
* NLLB's fp32 embedding table is over a gigabyte in a single block, so ONNX
|
||
* Runtime's arena hits that cap and dies with SIGTRAP in posix_memalign —
|
||
* in the main process, in a utilityProcess, and in an ELECTRON_RUN_AS_NODE
|
||
* child alike, since they all inherit the allocator. Plain Node uses the
|
||
* system allocator and loads the same model without complaint.
|
||
*/
|
||
const nodeBin = findNodeBinary();
|
||
if (!nodeBin) throw new Error('Node.js was not found — translation needs it. Install Node 18 or newer.');
|
||
|
||
const child = fork(path.join(__dirname, 'mt-service.js'), [], {
|
||
execPath: nodeBin,
|
||
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
|
||
});
|
||
|
||
const slot = { child, busy: 0, ready: false, index };
|
||
|
||
if (child.stdout) child.stdout.on('data', (d) => process.stdout.write('[mt' + index + '] ' + d));
|
||
if (child.stderr) child.stderr.on('data', (d) => process.stderr.write('[mt' + index + '] ' + d));
|
||
|
||
child.on('message', (msg) => {
|
||
if (!msg) return;
|
||
|
||
if (msg.type === 'progress') {
|
||
/* Only the first process reports progress; the others are downloading
|
||
nothing (the cache is warm) and would fight over the bar. */
|
||
if (index === 0 && win && !win.isDestroyed()) win.webContents.send('mt:progress', msg.p);
|
||
return;
|
||
}
|
||
if (msg.type === 'ready') {
|
||
slot.ready = true;
|
||
if (mtPool.every((sl) => sl.ready)) {
|
||
mtReady = true;
|
||
mtRestarts = 0;
|
||
console.log('[main] translation ready (' + mtPool.length + ' process' + (mtPool.length > 1 ? 'es' : '') + ')');
|
||
if (mtLoading) { mtLoading.resolve(); mtLoading = null; }
|
||
}
|
||
return;
|
||
}
|
||
if (msg.type === 'loadError' || msg.type === 'fatal') {
|
||
console.error('[main] translation process ' + index + ' error: ' + msg.message);
|
||
if (mtLoading) { mtLoading.reject(new Error(msg.message)); mtLoading = null; }
|
||
return;
|
||
}
|
||
if (msg.type === 'note') { console.log('[mt' + index + '] ' + msg.message); return; }
|
||
if (msg.type === 'result' || msg.type === 'multi' || msg.type === 'error') {
|
||
slot.busy = Math.max(0, slot.busy - 1);
|
||
const waiting = mtPending.get(msg.id);
|
||
if (!waiting) return;
|
||
mtPending.delete(msg.id);
|
||
if (msg.type === 'result') waiting.resolve(msg.text);
|
||
else if (msg.type === 'multi') waiting.resolve(msg.texts || {});
|
||
else waiting.reject(new Error(msg.message));
|
||
}
|
||
});
|
||
|
||
child.on('exit', (code, signal) => {
|
||
console.error('[main] translation process ' + index + ' exited (code=' + code + ' signal=' + signal + ')');
|
||
slot.ready = false;
|
||
mtReady = false;
|
||
|
||
/* Fail this process's in-flight work rather than leaving it hanging. The
|
||
renderer treats a rejection as "this language missed a sentence" and
|
||
carries on; a silent stall it cannot recover from. */
|
||
for (const [id, waiting] of mtPending) {
|
||
if (waiting.slot === slot) { mtPending.delete(id); waiting.reject(new Error('translation process restarted')); }
|
||
}
|
||
|
||
const i = mtPool.indexOf(slot);
|
||
if (i !== -1) mtPool.splice(i, 1);
|
||
|
||
if (mtLoading) { mtLoading.reject(new Error('translation process exited during load')); mtLoading = null; }
|
||
|
||
/* Restarting on a loop hides the real fault and pins a CPU. A few tries,
|
||
then stop and say so. */
|
||
mtRestarts += 1;
|
||
const giveUp = mtRestarts > MT_MAX_RESTARTS;
|
||
if (win && !win.isDestroyed()) win.webContents.send('mt:down', { attempt: mtRestarts, giveUp });
|
||
if (giveUp) console.error('[main] translation failed ' + mtRestarts + ' times — not restarting');
|
||
});
|
||
|
||
child.send({ type: 'load', model: cfg.mtModel, dtype: cfg.mtDtype || 'fp32', cacheDir: MODELS_DIR });
|
||
return slot;
|
||
}
|
||
|
||
function ensureTranslator() {
|
||
if (mtReady && mtPool.length) return Promise.resolve();
|
||
if (mtLoading) return mtLoading.promise;
|
||
|
||
let resolve, reject;
|
||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||
mtLoading = { promise, resolve, reject };
|
||
|
||
try {
|
||
const want = poolSize();
|
||
/* The first one loads alone. The rest start once it is up, so a cold cache
|
||
downloads once instead of three times over. */
|
||
mtPool.push(spawnOne(0));
|
||
promise.then(() => {
|
||
for (let i = mtPool.length; i < want; i++) mtPool.push(spawnOne(i));
|
||
}).catch(() => {});
|
||
} catch (e) {
|
||
mtLoading = null;
|
||
return Promise.reject(e);
|
||
}
|
||
return promise;
|
||
}
|
||
|
||
/*
|
||
* Is this model already on disk?
|
||
*
|
||
* The library cannot answer this. transformers.js emits `download` and a full
|
||
* run of `progress` events when it reads a file out of the local cache — a
|
||
* fully cached NLLB produces 268 of them — so "bytes are moving" is true on
|
||
* every launch and cannot distinguish a first-run fetch from an ordinary
|
||
* start. The filesystem can answer it, and it is the only thing that can.
|
||
*/
|
||
/*
|
||
* What the DEVICE really offers, straight from Core Audio.
|
||
*
|
||
* Chromium hands this app 2 input channels whatever the device presents —
|
||
* MEASURED: a Dante Virtual Soundcard set to 8-in is delivered as 2, and
|
||
* `channelCount: {exact: 8}` is REJECTED with OverconstrainedError, which is
|
||
* Chromium stating outright that it cannot do more.
|
||
*
|
||
* Without this the app would show "2 channels" for a card the operator has
|
||
* just configured as 64x64 and look broken. With it, the app can say which
|
||
* number is the device's and which is the one it can actually reach, and what
|
||
* to do about the difference.
|
||
*/
|
||
let audioDevCache = { at: 0, list: [] };
|
||
/*
|
||
* ─────────────────── carrying a campus's setup to the next one ─────────────
|
||
*
|
||
* The corrections list is months of work — 49 lines of "have a cook =>
|
||
* Habakkuk" built up service by service — and rebuilding it by hand at each
|
||
* campus is the kind of thing that simply does not happen, so the other
|
||
* campuses quietly translate worse forever.
|
||
*
|
||
* Two lists, and the split is the whole point:
|
||
*
|
||
* PORTABLE what an operator DECIDED — corrections, languages, voices, the
|
||
* channel map. Worth carrying.
|
||
*
|
||
* NEVER what identifies THIS machine — the control token, the operator
|
||
* PIN, the engine key, the device ids, the port, the public URL.
|
||
* A settings file gets emailed and dropped on a shared drive;
|
||
* copying secrets into one hands every campus the same keys, and
|
||
* copying device ids sends the next Mac looking for hardware that
|
||
* was never plugged into it.
|
||
*
|
||
* Device NAMES do travel — they are how the far end finds the same interface
|
||
* again (see rescanOutputs) — but ids never do.
|
||
*/
|
||
const PORTABLE_APP = [
|
||
'deviceLabel', 'audioChannels', 'outputDeviceLabel', 'outputMap', 'outputGain',
|
||
'sensitivity', 'threshDb', 'partialsEnabled', 'voiceChoices', 'listenerVoices', 'uiMode'
|
||
];
|
||
const PORTABLE_SRV = [
|
||
'serviceName', 'welcome', 'sourceLanguage', 'featured', 'pinned',
|
||
'allowAllLanguages', 'streamDelaySec', 'companionVariable', 'glossary'
|
||
];
|
||
|
||
/* Premium-voice CHOICES travel; the API keys that pay for them do not. */
|
||
function portableCloudTts(c) {
|
||
if (!c || typeof c !== 'object') return null;
|
||
const out = Object.assign({}, c);
|
||
delete out.fishKey;
|
||
delete out.googleKey;
|
||
return out;
|
||
}
|
||
|
||
function pick(src, keys) {
|
||
const out = {};
|
||
for (const k of keys) if (src && src[k] !== undefined) out[k] = src[k];
|
||
return out;
|
||
}
|
||
|
||
async function serverConfig() {
|
||
const port = Number((loadConfig() || {}).port) || 3100;
|
||
const res = await fetch('http://127.0.0.1:' + port + '/api/config?pin=' +
|
||
encodeURIComponent(srvInfo.operatorPin || ''));
|
||
if (!res.ok) throw new Error('the congregation server did not answer (' + res.status + ')');
|
||
return (await res.json()).config || {};
|
||
}
|
||
|
||
async function writeServerConfig(patch) {
|
||
const port = Number((loadConfig() || {}).port) || 3100;
|
||
const res = await fetch('http://127.0.0.1:' + port + '/api/config?pin=' +
|
||
encodeURIComponent(srvInfo.operatorPin || ''), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(patch)
|
||
});
|
||
if (!res.ok) {
|
||
let why = res.status;
|
||
try { why = (await res.json()).error || why; } catch (_) {}
|
||
throw new Error(String(why));
|
||
}
|
||
}
|
||
|
||
ipcMain.handle('settings:export', async () => {
|
||
const app_ = loadConfig();
|
||
const srv = await serverConfig();
|
||
const body = {
|
||
churchTranslateSettings: 1,
|
||
exportedAt: new Date().toISOString(),
|
||
fromVersion: appBuild().version,
|
||
fromCampus: srv.serviceName || '',
|
||
app: Object.assign(pick(app_, PORTABLE_APP), { cloudTts: portableCloudTts(app_.cloudTts) }),
|
||
server: pick(srv, PORTABLE_SRV)
|
||
};
|
||
const stamp = new Date().toISOString().slice(0, 10);
|
||
const r = await dialog.showSaveDialog(win, {
|
||
title: 'Save settings for another campus',
|
||
defaultPath: 'church-translate-settings-' + stamp + '.json',
|
||
filters: [{ name: 'Church Translate settings', extensions: ['json'] }]
|
||
});
|
||
if (r.canceled || !r.filePath) return { ok: false, canceled: true };
|
||
fs.writeFileSync(r.filePath, JSON.stringify(body, null, 2));
|
||
return { ok: true, path: r.filePath,
|
||
fixes: ((srv.glossary || {}).fixes || []).length,
|
||
languages: (srv.featured || []).length };
|
||
});
|
||
|
||
ipcMain.handle('settings:import', async () => {
|
||
const r = await dialog.showOpenDialog(win, {
|
||
title: 'Open settings from another campus',
|
||
properties: ['openFile'],
|
||
filters: [{ name: 'Church Translate settings', extensions: ['json'] }]
|
||
});
|
||
if (r.canceled || !r.filePaths || !r.filePaths[0]) return { ok: false, canceled: true };
|
||
|
||
let body;
|
||
try { body = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8')); }
|
||
catch (e) { return { ok: false, error: 'that file is not readable JSON' }; }
|
||
if (!body || body.churchTranslateSettings !== 1) {
|
||
return { ok: false, error: 'that is not a Church Translate settings file' };
|
||
}
|
||
|
||
/* Filtered again on the way IN, not just on the way out. A hand-edited file
|
||
must not be able to set this machine's PIN or token. */
|
||
const appPatch = pick(body.app || {}, PORTABLE_APP);
|
||
if (body.app && body.app.cloudTts) {
|
||
const keep = loadConfig().cloudTts || {};
|
||
/* Keep whatever keys THIS machine already has; the file never carried any. */
|
||
appPatch.cloudTts = Object.assign({}, portableCloudTts(body.app.cloudTts),
|
||
{ fishKey: keep.fishKey || '', googleKey: keep.googleKey || '' });
|
||
}
|
||
const srvPatch = pick(body.server || {}, PORTABLE_SRV);
|
||
|
||
saveConfig(appPatch);
|
||
try { await writeServerConfig(srvPatch); }
|
||
catch (e) { return { ok: false, error: 'settings saved, but the server rejected part of them: ' + e.message }; }
|
||
|
||
return { ok: true,
|
||
from: body.fromCampus || '',
|
||
fixes: ((srvPatch.glossary || {}).fixes || []).length,
|
||
languages: (srvPatch.featured || []).length };
|
||
});
|
||
|
||
ipcMain.handle('audio:devices', () => {
|
||
if (Date.now() - audioDevCache.at < 4000) return audioDevCache.list;
|
||
/*
|
||
* macOS only. This exists to catch ONE thing — "the card says 8 channels,
|
||
* Chromium handed us 2" — and system_profiler is the only place that number
|
||
* lives. Windows has no equivalent that reports endpoint channel counts
|
||
* (Win32_SoundDevice gives names and nothing else), so rather than invent a
|
||
* number we return none: renderInputTruth() already treats an unknown count
|
||
* as "cannot compare" and simply says what arrived, which is honest.
|
||
*/
|
||
if (process.platform !== 'darwin') { audioDevCache = { at: Date.now(), list: [] }; return []; }
|
||
let list = [];
|
||
try {
|
||
const raw = require('child_process')
|
||
.execFileSync('/usr/sbin/system_profiler', ['SPAudioDataType', '-json'],
|
||
{ encoding: 'utf8', timeout: 6000, maxBuffer: 4 * 1024 * 1024 });
|
||
const walk = (n) => {
|
||
if (Array.isArray(n)) return n.forEach(walk);
|
||
if (!n || typeof n !== 'object') return;
|
||
if (n._name) {
|
||
const inCh = Number(n.coreaudio_device_input || 0);
|
||
const outCh = Number(n.coreaudio_device_output || 0);
|
||
if (inCh || outCh) list.push({ name: String(n._name), inputs: inCh, outputs: outCh });
|
||
}
|
||
for (const k of Object.keys(n)) if (typeof n[k] === 'object') walk(n[k]);
|
||
};
|
||
walk(JSON.parse(raw));
|
||
} catch (_) { list = []; }
|
||
audioDevCache = { at: Date.now(), list };
|
||
return list;
|
||
});
|
||
|
||
ipcMain.handle('models:cached', (_e, id) => {
|
||
try {
|
||
const dir = path.join(MODELS_DIR, String(id || '').replace(/\\/g, '/'));
|
||
const onnx = path.join(dir, 'onnx');
|
||
if (!fs.existsSync(onnx)) return false;
|
||
/* The weights are what take the time and the disk; a stray config left
|
||
behind by an interrupted fetch must not read as "cached". */
|
||
return fs.readdirSync(onnx).some((f) => f.endsWith('.onnx'));
|
||
} catch (_) { return false; }
|
||
});
|
||
|
||
ipcMain.handle('mt:ensure', async () => { await ensureTranslator(); return true; });
|
||
|
||
// ---------------------------------------------------------------- voices
|
||
/*
|
||
* Built-in voices. The Mac speaks; the phones just play what it sends.
|
||
*
|
||
* Same forked-plain-Node pattern as translation, for the same reason: model
|
||
* loads die inside any Electron process. See tts-service.js for the measured
|
||
* numbers and the thread-cap that makes it scale.
|
||
*
|
||
* Text covers ~200 languages and speech covers 31, so a listener may be reading
|
||
* a language this Mac cannot speak. That is surfaced rather than hidden — the
|
||
* page falls back to the device's own voice and says which one it is using.
|
||
*/
|
||
const voices = require('./voices.js');
|
||
|
||
function clipDir() { return path.join(app.getPath('temp'), 'church-translate-audio'); }
|
||
function voiceDir() { return path.join(appDataRoot(), 'Church Translate Voices'); }
|
||
|
||
let ttsChild = null;
|
||
let ttsReady = false;
|
||
let ttsSeq = 0;
|
||
const ttsPending = new Map();
|
||
|
||
function startTts() {
|
||
const nodeBin = findNodeBinary();
|
||
if (!nodeBin) { console.error('[main] no node binary — built-in voices unavailable'); return; }
|
||
|
||
ttsChild = fork(path.join(__dirname, 'tts-service.js'), [], {
|
||
execPath: nodeBin,
|
||
env: Object.assign({}, process.env, { CT_FFMPEG: ffmpegPathForChildren() }),
|
||
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
|
||
});
|
||
if (ttsChild.stdout) ttsChild.stdout.on('data', (d) => process.stdout.write('[tts] ' + d));
|
||
if (ttsChild.stderr) ttsChild.stderr.on('data', (d) => process.stderr.write('[tts] ' + d));
|
||
teeChild(ttsChild, 'tts');
|
||
|
||
ttsChild.on('message', (m) => {
|
||
if (!m) return;
|
||
if (m.type === 'ready') {
|
||
ttsReady = true;
|
||
console.log('[main] voices ready (' + m.spoken.length + ' languages, ' +
|
||
m.installed.length + ' already downloaded)');
|
||
if (win && !win.isDestroyed()) win.webContents.send('tts:ready', m);
|
||
pushCloudToTts();
|
||
pushVoicesToTts();
|
||
syncLocalVoices();
|
||
pushSidecarToTts(true); // a (re)started voice process starts out not knowing
|
||
return;
|
||
}
|
||
if (m.type === 'cloudStats') {
|
||
if (win && !win.isDestroyed()) win.webContents.send('tts:cloud', m.stats);
|
||
return;
|
||
}
|
||
if (m.type === 'cloudOk') {
|
||
console.log('[main] premium voices: ' + m.provider);
|
||
return;
|
||
}
|
||
if (m.type === 'voiceProgress') {
|
||
if (win && !win.isDestroyed()) win.webContents.send('tts:progress', m);
|
||
return;
|
||
}
|
||
if (m.type === 'audio' || m.type === 'error' || m.type === 'empty' || m.type === 'audition') {
|
||
const waiting = ttsPending.get(m.id);
|
||
if (!waiting) return;
|
||
ttsPending.delete(m.id);
|
||
/* 'empty' means the text normalised away to nothing worth saying — a
|
||
normal outcome, not a failure, and it must resolve or the sentence
|
||
stalls for the full timeout. */
|
||
if (m.type === 'audio' || m.type === 'empty' || m.type === 'audition') waiting.resolve(m);
|
||
else waiting.reject(new Error(m.message));
|
||
}
|
||
});
|
||
|
||
ttsChild.on('exit', (code, signal) => {
|
||
console.error('[main] voices process exited (code=' + code + ' signal=' + signal + ')');
|
||
ttsReady = false;
|
||
ttsChild = null;
|
||
for (const [, w] of ttsPending) w.reject(new Error('voice process restarted'));
|
||
ttsPending.clear();
|
||
/* Captions still work without it, so a quiet restart is the right response. */
|
||
if (!app.isQuittingForReal) setTimeout(() => { try { startTts(); } catch (_) {} }, 4000);
|
||
});
|
||
|
||
ttsChild.send({ type: 'config', voiceDir: voiceDir(), clipDir: clipDir() });
|
||
}
|
||
|
||
ipcMain.handle('tts:info', () => ({
|
||
ready: ttsReady,
|
||
spoken: voices.spokenLanguages()
|
||
}));
|
||
|
||
ipcMain.handle('tts:speak', (_e, req) => {
|
||
if (!ttsReady || !ttsChild) return Promise.reject(new Error('voices are not running'));
|
||
if (!voices.hasVoice(req.lang)) return Promise.reject(new Error('no built-in voice for ' + req.lang));
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const id = ++ttsSeq;
|
||
ttsPending.set(id, { resolve, reject });
|
||
/* A voice that has wedged must not hold a sentence forever — the caption
|
||
has already gone out, and silence beats a stall. */
|
||
setTimeout(() => {
|
||
if (ttsPending.has(id)) { ttsPending.delete(id); reject(new Error('voice timed out')); }
|
||
}, 15000);
|
||
ttsChild.send({ type: 'speak', id, lang: req.lang, text: String(req.text || ''),
|
||
lagSec: Number(req.lagSec) || 0,
|
||
gainDb: Number(req.gainDb) || 0,
|
||
/* Empty for the default voice, which is almost every clip. */
|
||
voice: String(req.voice || '') });
|
||
});
|
||
});
|
||
|
||
ipcMain.handle('tunnel:start', () => startTunnel());
|
||
ipcMain.handle('tunnel:stop', () => stopTunnel());
|
||
ipcMain.handle('tunnel:status', () => tunnelState);
|
||
ipcMain.handle('localvoices:status', () => localVoiceState);
|
||
|
||
function pushCloudToTts() {
|
||
if (ttsChild && ttsReady) {
|
||
try { ttsChild.send({ type: 'cloud', cloud: loadConfig().cloudTts || { provider: 'off' } }); } catch (_) {}
|
||
}
|
||
}
|
||
|
||
/* Push the operator's voice choices to the voice process. */
|
||
function pushVoicesToTts() {
|
||
if (ttsChild && ttsReady) {
|
||
try { ttsChild.send({ type: 'voices', choices: loadConfig().voiceChoices || {} }); } catch (_) {}
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Which alternates listeners may choose, per language.
|
||
*
|
||
* Validated against the catalog here rather than in the renderer: this list
|
||
* decides what a phone is allowed to ask for, so it is the security boundary
|
||
* for the listener-facing chooser and cannot be a UI convention.
|
||
*/
|
||
ipcMain.handle('tts:setListenerVoices', (_e, map) => {
|
||
const catalog = require('./voice-catalog.js');
|
||
const clean = {};
|
||
for (const lang of Object.keys(map || {})) {
|
||
const allowed = catalog.forLanguage(lang) || [];
|
||
const keys = allowed.map((v) =>
|
||
(v.engine === 'local' || v.engine === 'kokoro') ? v.speaker : v.bundle).filter(Boolean);
|
||
const want = Array.isArray(map[lang]) ? map[lang] : [];
|
||
const kept = want.map(String).filter((k) => keys.indexOf(k) !== -1);
|
||
if (kept.length) clean[lang] = Array.from(new Set(kept));
|
||
}
|
||
const merged = saveConfig({ listenerVoices: clean });
|
||
/* The SERVER learns this from the renderer over the existing socket, not
|
||
from a second config file — see sendVoiceOptions() in engine.js. Keeping
|
||
one owner for the list is what stops the two config.json files from
|
||
disagreeing about what a listener may pick. */
|
||
return merged.listenerVoices;
|
||
});
|
||
|
||
ipcMain.handle('tts:setVoices', (_e, choices) => {
|
||
const merged = saveConfig({ voiceChoices: choices || {} });
|
||
pushVoicesToTts();
|
||
/* A newly chosen natural voice is cold; warm it now, not at the first
|
||
sentence of the service. */
|
||
if (localVoiceChild && localVoiceState.running) {
|
||
const port = Number((loadConfig().cloudTts || {}).localPort) || 43111;
|
||
/* Only pairs not yet warm are requested (warmedPairs filters), and never
|
||
concurrently with a warm already in flight. */
|
||
Promise.resolve(warmInFlight).then(() => warmLocalVoices(port, 1)).catch(() => {});
|
||
}
|
||
/* Choosing a Kokoro or Qwen3 voice for one language is enough to need the
|
||
sidecar — and enough to stop needing it when the last one goes back to a
|
||
built-in. Without this, picking a natural voice did nothing until the app
|
||
was restarted. */
|
||
syncLocalVoices();
|
||
return merged.voiceChoices;
|
||
});
|
||
|
||
/*
|
||
* Audition: one sample sentence in one specific voice, returned as audio the
|
||
* operator window can play. Generous timeout — a built-in voice may have to
|
||
* download 60-200MB before it can say anything.
|
||
*/
|
||
/*
|
||
* Bring the voice sidecar up FOR AN AUDITION.
|
||
*
|
||
* The sidecar only ran when some language had already been given a natural
|
||
* voice (sidecarWanted reads voiceChoices). But the whole point of the Voices
|
||
* card is to LISTEN before choosing — so on a fresh install, pressing play on
|
||
* a Kokoro voice reached a port nothing was listening on and the operator got
|
||
* "fetch failed", which says nothing about what to do. Reported from a new
|
||
* Windows install; the same hole existed on macOS and was only hidden because
|
||
* a voice had been chosen there long ago.
|
||
*
|
||
* Resolves once the sidecar answers. The window is generous because the first
|
||
* natural voice on a new machine downloads its model (~320 MB on Windows).
|
||
*/
|
||
function waitForSidecar(timeoutMs) {
|
||
return new Promise((resolve) => {
|
||
if (!localVoiceChild) { try { startLocalVoices(); } catch (_) {} }
|
||
const deadline = Date.now() + (timeoutMs || 240000);
|
||
const port = Number((loadConfig().cloudTts || {}).localPort) || 43111;
|
||
const probe = () => {
|
||
if (Date.now() > deadline) return resolve(false);
|
||
const req = http.get({ host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, (res) => {
|
||
let body = '';
|
||
res.on('data', (d) => { body += d; });
|
||
res.on('end', () => {
|
||
/* Our JS sidecar reports whether the MODEL is loaded. mlx-audio has
|
||
no /healthz — any answer at all means its server is up, and its
|
||
own cold-load budget covers the first sentence. */
|
||
if (res.statusCode === 200 && body.indexOf('"ready":false') === -1) return resolve(true);
|
||
if (res.statusCode === 404) return resolve(true);
|
||
setTimeout(probe, 1000);
|
||
});
|
||
});
|
||
req.on('error', () => setTimeout(probe, 1000));
|
||
req.on('timeout', () => { req.destroy(); setTimeout(probe, 1000); });
|
||
};
|
||
probe();
|
||
});
|
||
}
|
||
ipcMain.handle('tts:audition', async (_e, req) => {
|
||
/* A natural voice needs the sidecar; a built-in one does not. */
|
||
const eng = (req && req.voice && req.voice.engine) || '';
|
||
if (eng === 'kokoro' || eng === 'local') {
|
||
const up = await waitForSidecar(240000);
|
||
if (!up) throw new Error('the natural-voice engine did not start — see data/logs/app.log');
|
||
}
|
||
return auditionOnChild(req);
|
||
});
|
||
|
||
function auditionOnChild(req) {
|
||
if (!ttsReady || !ttsChild) return Promise.reject(new Error('voices are not running'));
|
||
return new Promise((resolve, reject) => {
|
||
const id = ++ttsSeq;
|
||
ttsPending.set(id, { resolve, reject });
|
||
setTimeout(() => {
|
||
if (ttsPending.has(id)) { ttsPending.delete(id); reject(new Error('timed out — the voice may still be downloading')); }
|
||
}, 300000);
|
||
ttsChild.send({ type: 'audition', id, lang: req.lang, voice: req.voice, text: req.text,
|
||
instruct: String(req.instruct || '').slice(0, 300) });
|
||
});
|
||
}
|
||
|
||
ipcMain.handle('tts:preload', (_e, langs) => {
|
||
if (ttsReady && ttsChild) ttsChild.send({ type: 'preload', langs: langs || [] });
|
||
return true;
|
||
});
|
||
|
||
function mtDispatch(payload) {
|
||
const ready = mtPool.filter((sl) => sl.ready);
|
||
if (!ready.length) return Promise.reject(new Error('translation is not running'));
|
||
|
||
/* Least-busy wins, so one slow sentence does not hold up the rest. */
|
||
const slot = ready.reduce((a, b) => (b.busy < a.busy ? b : a));
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const id = ++mtSeq;
|
||
mtPending.set(id, { resolve, reject, slot });
|
||
slot.busy += 1;
|
||
/* A wedged model must not hold a sentence forever; the other languages
|
||
still need to go out. */
|
||
setTimeout(() => {
|
||
if (mtPending.has(id)) {
|
||
mtPending.delete(id);
|
||
slot.busy = Math.max(0, slot.busy - 1);
|
||
reject(new Error('translation timed out'));
|
||
}
|
||
}, 20000);
|
||
slot.child.send(Object.assign({ id }, payload));
|
||
});
|
||
}
|
||
|
||
ipcMain.handle('mt:translate', (_e, req) => mtDispatch({
|
||
type: 'translate',
|
||
text: String(req.text || ''),
|
||
src: String(req.src || 'eng_Latn'),
|
||
tgt: String(req.tgt || 'eng_Latn')
|
||
}));
|
||
|
||
/*
|
||
* Every language for one sentence in a single decode — see mt-service.js. The
|
||
* renderer gathers the languages that ask for the same sentence inside one
|
||
* short window and sends them together.
|
||
*/
|
||
ipcMain.handle('mt:translateMulti', (_e, req) => mtDispatch({
|
||
type: 'translateMulti',
|
||
text: String(req.text || ''),
|
||
src: String(req.src || 'eng_Latn'),
|
||
tgts: Array.isArray(req.tgts) ? req.tgts.map(String) : []
|
||
}));
|
||
|
||
app.on('before-quit', (e) => {
|
||
/*
|
||
* ASK FIRST. THEN GUT IT.
|
||
*
|
||
* Electron runs every before-quit listener to completion BEFORE any window's
|
||
* close handler, so this block used to kill all four children and only then
|
||
* let win.on('close') call preventDefault() — cancelling the quit and leaving
|
||
* a live-looking app with no server, no translator, no voices and no way out
|
||
* but SIGKILL. MEASURED across 13 quit attempts in one afternoon: every one
|
||
* of them left the process alive, and the supervisors then restarted the
|
||
* children underneath it.
|
||
*
|
||
* The guard has to sit in FRONT of the killing, in this handler, because this
|
||
* is the only one whose event can still stop the quit.
|
||
*/
|
||
if (engineIsLive && !forceQuit) {
|
||
e.preventDefault();
|
||
askBeforeQuitting();
|
||
return;
|
||
}
|
||
app.isQuittingForReal = true;
|
||
/* Do not leave a voice service reparented to launchd holding the port. */
|
||
try { if (localVoiceChild) localVoiceChild.kill('SIGKILL'); } catch (_) {}
|
||
for (const sl of mtPool) { try { sl.child.kill(); } catch (_) {} }
|
||
if (srvChild) { try { srvChild.kill(); } catch (_) {} }
|
||
if (ttsChild) { try { ttsChild.kill(); } catch (_) {} }
|
||
});
|
||
|
||
ipcMain.handle('config:get', () => loadConfig());
|
||
ipcMain.handle('config:set', (_e, patch) => {
|
||
const merged = saveConfig(patch);
|
||
/* Premium-voice settings reach the running voice process immediately —
|
||
saving a key and having nothing change until a relaunch reads as broken. */
|
||
if (patch && patch.cloudTts) { pushCloudToTts(); syncLocalVoices(); }
|
||
return merged;
|
||
});
|
||
|
||
ipcMain.handle('mic:status', () => {
|
||
/* Electron implements getMediaAccessStatus on win32 as well as darwin — a PC
|
||
with "Let desktop apps access your microphone" off reports 'denied', and
|
||
hard-coding 'granted' would have shown the operator a working meter and no
|
||
audio with nothing to explain it. */
|
||
if (process.platform !== 'darwin' && process.platform !== 'win32') return 'granted';
|
||
try { return systemPreferences.getMediaAccessStatus('microphone'); } catch (_) { return 'unknown'; }
|
||
});
|
||
|
||
ipcMain.handle('mic:ask', async () => {
|
||
if (process.platform !== 'darwin') return true;
|
||
try { return await systemPreferences.askForMediaAccess('microphone'); } catch (_) { return false; }
|
||
});
|
||
|
||
ipcMain.handle('app:openPrivacy', () => {
|
||
if (process.platform === 'win32') {
|
||
/* Windows has no per-app prompt to raise; send them to the setting itself. */
|
||
shell.openExternal('ms-settings:privacy-microphone').catch(() => {});
|
||
return true;
|
||
}
|
||
shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone');
|
||
return true;
|
||
});
|
||
|
||
ipcMain.handle('app:cacheInfo', () => {
|
||
const dir = app.getPath('userData');
|
||
/* Counts the profile (where Whisper's weights are cached by the browser)
|
||
plus the translation weights folder. */
|
||
let bytes = 0;
|
||
const walk = (d) => {
|
||
let entries;
|
||
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
|
||
for (const e of entries) {
|
||
const full = path.join(d, e.name);
|
||
if (e.isDirectory()) walk(full);
|
||
else { try { bytes += fs.statSync(full).size; } catch (_) {} }
|
||
}
|
||
};
|
||
walk(dir);
|
||
walk(MODELS_DIR);
|
||
return { dir: MODELS_DIR, bytes };
|
||
});
|
||
|
||
/* navigator.clipboard needs both a secure context and a focused document, and
|
||
quietly refuses when either is missing — which is why Copy address failed
|
||
with nothing but a log line. Electron's own clipboard has neither condition. */
|
||
ipcMain.handle('app:copy', (_e, text) => { clipboard.writeText(String(text || '')); return true; });
|
||
|
||
ipcMain.handle('app:revealCache', () => { shell.openPath(MODELS_DIR); });
|
||
|
||
ipcMain.on('engine:live', (_e, live) => { engineIsLive = !!live; });
|
||
/* The operator Log panel's lines are the most useful operational record there
|
||
is ("voice failed for spa_Latn", "dropped filler"); they lived only on
|
||
screen, 200 lines deep. */
|
||
ipcMain.on('log:line', (_e, line) => { const l = getAppLog(); if (l) l.write('app', String(line || '').slice(0, 2000)); });
|