Settings gains an Updates block: it asks the church Gitea for the newest release, compares the tag against the running build, and if there is a newer one shows what changed and offers to download the installer. Deliberately not electron-updater. That wants a static feed published beside the artifacts, and this build is ad-hoc signed with no Team ID, so a silent in-place swap would meet Gatekeeper on a machine nobody is sitting at. And nothing here happens on its own: a background check that announces itself three minutes into a sermon is a hazard, so the operator presses the button, and presses a second one to download. The file lands in Downloads and is revealed; installing is a drag across when the room is empty. Tags are v<version>-<build>, compared numerically field by field so 3.9.0 does not read as newer than 3.23.2 and build 113 beats build 112. Two things only real testing found. Gitea answers with whatever address it was reached on, so the release API returned asset URLs on 10.3.8.90 while the forge is configured by hostname — the same-host guard refused a perfectly good installer. The URL is now rebuilt on the configured origin, which keeps the property that matters (this only ever fetches from the server the operator configured) and survives a forge that knows itself by more than one name. And a private repository answers 404 rather than 401, so 'no releases published yet' told the operator the opposite of the truth; without a token that case now names the remedy instead. The token is this machine's credential: it stays out of PORTABLE_APP, so it does not ride along in an exported settings file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2328 lines
100 KiB
JavaScript
2328 lines
100 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,
|
||
/*
|
||
* WHERE UPDATES COME FROM.
|
||
*
|
||
* The church's own Gitea, not the internet: a campus Mac may have no route
|
||
* out, and an installer that cannot be fetched at the venue is worse than no
|
||
* updater at all. Nothing here is checked automatically — this app is often
|
||
* mid-service, and a download starting on its own is the last thing anyone
|
||
* wants. The operator presses the button.
|
||
*
|
||
* `token` is only needed while the repository is private, and is deliberately
|
||
* NOT in PORTABLE_APP: it is this machine's credential and must not ride along
|
||
* in an exported settings file.
|
||
*/
|
||
updateForge: 'http://chronicles-ab.waterschurch.org:3000',
|
||
updateRepo: 'christian/church-translate',
|
||
updateToken: '',
|
||
/* 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 (_) {} }
|
||
});
|
||
|
||
/*
|
||
* ─────────────────────────── updates from our own forge ───────────────────
|
||
*
|
||
* Gitea's release API, read directly. Deliberately NOT electron-updater: that
|
||
* wants a static feed (latest-mac.yml) published beside the artifacts, and the
|
||
* build here is ad-hoc signed with no Team ID, so a silent in-place swap would
|
||
* trip Gatekeeper on a machine nobody is sitting at. Checking, downloading and
|
||
* telling the operator where the file is does the useful 90% and cannot leave
|
||
* a campus with a half-replaced app.
|
||
*
|
||
* Tags are `v<version>-<build>` — v3.23.2-112. The build number is what really
|
||
* orders two releases of the same version, which is why it is compared second
|
||
* and numerically.
|
||
*/
|
||
function parseTag(tag) {
|
||
const m = String(tag || '').match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-.](\d+))?/);
|
||
if (!m) return null;
|
||
return { major: +m[1], minor: +m[2], patch: +m[3], build: m[4] === undefined ? 0 : +m[4] };
|
||
}
|
||
|
||
/* Positive when a is newer than b. */
|
||
function compareBuilds(a, b) {
|
||
if (!a) return -1;
|
||
if (!b) return 1;
|
||
for (const k of ['major', 'minor', 'patch', 'build']) {
|
||
if (a[k] !== b[k]) return a[k] > b[k] ? 1 : -1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function forgeRequest(urlStr, token, cb) {
|
||
let u;
|
||
try { u = new URL(urlStr); } catch (e) { return cb(e); }
|
||
const mod = u.protocol === 'https:' ? require('https') : http;
|
||
const headers = { 'Accept': 'application/json', 'User-Agent': 'church-translate' };
|
||
if (token) headers.Authorization = 'token ' + token;
|
||
const req = mod.get(urlStr, { headers, timeout: 15000 }, (res) => {
|
||
/* Gitea hands out asset downloads as a redirect to /attachments/<uuid>. */
|
||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||
res.resume();
|
||
return forgeRequest(new URL(res.headers.location, urlStr).toString(), token, cb);
|
||
}
|
||
let body = '';
|
||
res.setEncoding('utf8');
|
||
res.on('data', (d) => { body += d; });
|
||
res.on('end', () => cb(null, res.statusCode, body));
|
||
});
|
||
req.on('timeout', () => { req.destroy(new Error('the forge did not answer within 15s')); });
|
||
req.on('error', (e) => cb(e));
|
||
}
|
||
|
||
ipcMain.handle('update:check', async () => {
|
||
const cfg = loadConfig();
|
||
const forge = String(cfg.updateForge || '').replace(/\/+$/, '');
|
||
const repo = String(cfg.updateRepo || '');
|
||
const here = appBuild();
|
||
const current = parseTag('v' + here.version + '-' + here.build);
|
||
if (!forge || !repo) return { ok: false, error: 'No update server is configured.', current: here };
|
||
|
||
return await new Promise((resolve) => {
|
||
forgeRequest(forge + '/api/v1/repos/' + repo + '/releases/latest', cfg.updateToken, (err, status, body) => {
|
||
if (err) return resolve({ ok: false, error: err.message, current: here });
|
||
/*
|
||
* A PRIVATE REPOSITORY ANSWERS 404, NOT 401 — Gitea hides its existence
|
||
* from a caller who cannot see it. Measured against this church's own
|
||
* forge: with no token the releases endpoint returns 404, so reporting
|
||
* "no releases published yet" told the operator the opposite of the
|
||
* truth and pointed them away from the one thing that would fix it.
|
||
* Without a token, 404 is ambiguous; say so and name the remedy.
|
||
*/
|
||
if (status === 404) {
|
||
return resolve(cfg.updateToken
|
||
? { ok: true, current: here, latest: null, newer: false, note: 'No releases published yet.' }
|
||
: { ok: false, current: here,
|
||
error: 'Nothing found there. If the repository is private, set an access token below.' });
|
||
}
|
||
if (status === 401 || status === 403) {
|
||
return resolve({ ok: false, current: here,
|
||
error: 'The update server refused the request. If the repository is private, set an access token below.' });
|
||
}
|
||
if (status !== 200) return resolve({ ok: false, error: 'Update server returned ' + status, current: here });
|
||
let rel;
|
||
try { rel = JSON.parse(body); } catch (_) { return resolve({ ok: false, error: 'Unreadable answer from the update server.', current: here }); }
|
||
const there = parseTag(rel.tag_name);
|
||
const asset = (rel.assets || []).find((a) => /\.dmg$/i.test(a.name || ''));
|
||
resolve({
|
||
ok: true,
|
||
current: here,
|
||
latest: { tag: rel.tag_name, title: rel.title || rel.name || rel.tag_name,
|
||
notes: rel.body || '', published: rel.published_at || '',
|
||
asset: asset ? { name: asset.name, size: asset.size, url: asset.browser_download_url } : null },
|
||
newer: compareBuilds(there, current) > 0
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
/*
|
||
* Download to the Downloads folder and reveal it. Not installed automatically:
|
||
* replacing a running app from inside itself needs a helper process, and doing
|
||
* that on a machine that might be live in front of a congregation is not a
|
||
* trade worth making. The operator drags it across when the room is empty.
|
||
*/
|
||
ipcMain.handle('update:download', async (_e, url, name) => {
|
||
const cfg = loadConfig();
|
||
const dest = path.join(app.getPath('downloads'), String(name || 'church-translate.dmg').replace(/[^\w.\-]/g, '_'));
|
||
return await new Promise((resolve) => {
|
||
let u;
|
||
try { u = new URL(String(url)); } catch (e) { return resolve({ ok: false, error: 'Bad download address.' }); }
|
||
/*
|
||
* PIN THE DOWNLOAD TO THE CONFIGURED FORGE, don't merely check it.
|
||
*
|
||
* Gitea answers with whatever address IT was reached on, which is not
|
||
* necessarily the one configured here: this church's forge is reachable as
|
||
* chronicles-ab.waterschurch.org and as 10.3.8.90, and the release API
|
||
* hands back the IP form. A plain same-host check therefore REFUSED a
|
||
* perfectly good installer — measured, on the very first real release.
|
||
*
|
||
* Rebuilding the URL on the configured origin keeps the property that
|
||
* matters (this only ever fetches from the server the operator configured,
|
||
* so a tampered API answer cannot redirect it elsewhere) while surviving a
|
||
* forge that knows itself by more than one name.
|
||
*/
|
||
let base;
|
||
try { base = new URL(cfg.updateForge); } catch (_) { return resolve({ ok: false, error: 'The update server address is not valid.' }); }
|
||
u = new URL(u.pathname + u.search, base.origin);
|
||
|
||
const go = (target, hops) => {
|
||
if (hops > 5) return resolve({ ok: false, error: 'Too many redirects.' });
|
||
const mod = target.startsWith('https:') ? require('https') : http;
|
||
const headers = { 'User-Agent': 'church-translate' };
|
||
if (cfg.updateToken) headers.Authorization = 'token ' + cfg.updateToken;
|
||
mod.get(target, { headers, timeout: 60000 }, (res) => {
|
||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||
res.resume();
|
||
return go(new URL(res.headers.location, target).toString(), hops + 1);
|
||
}
|
||
if (res.statusCode !== 200) { res.resume(); return resolve({ ok: false, error: 'Download failed (' + res.statusCode + ').' }); }
|
||
const total = Number(res.headers['content-length'] || 0);
|
||
let got = 0, lastPush = 0;
|
||
const out = fs.createWriteStream(dest);
|
||
res.on('data', (d) => {
|
||
got += d.length;
|
||
const now = Date.now();
|
||
if (now - lastPush > 400 && win && !win.isDestroyed()) {
|
||
lastPush = now;
|
||
try { win.webContents.send('update:progress', { got, total }); } catch (_) {}
|
||
}
|
||
});
|
||
res.pipe(out);
|
||
out.on('finish', () => out.close(() => {
|
||
/* A truncated installer is worse than none: it looks present and
|
||
fails at the venue. Refuse it rather than leave it lying there. */
|
||
if (total && got !== total) {
|
||
try { fs.unlinkSync(dest); } catch (_) {}
|
||
return resolve({ ok: false, error: 'The download ended early and was discarded.' });
|
||
}
|
||
resolve({ ok: true, path: dest, bytes: got });
|
||
}));
|
||
out.on('error', (e) => resolve({ ok: false, error: e.message }));
|
||
}).on('error', (e) => resolve({ ok: false, error: e.message }))
|
||
.on('timeout', function () { this.destroy(new Error('the download stalled')); });
|
||
};
|
||
go(u.toString(), 0);
|
||
});
|
||
});
|
||
|
||
ipcMain.handle('update:reveal', (_e, p2) => { try { shell.showItemInFolder(String(p2)); return true; } catch (_) { return false; } });
|
||
|
||
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)); });
|