2026-09-23 15:41:21 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* 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,
|
2026-09-24 12:09:08 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* 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: '',
|
2026-09-23 15:41:21 -04:00
|
|
|
|
/* 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 (_) {} }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 12:09:08 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* ─────────────────────────── 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; } });
|
|
|
|
|
|
|
2026-09-23 15:41:21 -04:00
|
|
|
|
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)); });
|