Files
church-translate/app/tools/verify-windows.js
T
christianandClaude Opus 5 c9eef0672c Church Translate, as it runs at the campuses
Live sermon translation into 100+ languages, running entirely on one Mac per
campus: audio in, voice detection, Whisper on the GPU, NLLB-200 in its own
process, and a built-in web server the congregation reads from on their
phones. No audio leaves the building.

This is the source only. What used to sit beside it — 14 GB of speech and
translation models, 8 GB of voice bundles, 6 GB of natural voices, the Electron
caches, and the app data holding the operator PIN and every service transcript
— is machine state and downloaded assets, and stays in the NAS backup where it
belongs. The vendored node, cloudflared and Windows ffmpeg binaries are out too
(533 MB, re-fetched for packaging); the browser-side runtime beside them is
kept, so a checkout still serves the congregation page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:41:21 -04:00

201 lines
11 KiB
JavaScript

/*
* verify-windows.js — run this ONCE on a Windows machine after installing.
*
* Everything in the Windows port that can be checked from a Mac was checked
* before shipping: the right binaries are in the package, no Mach-O leaked in,
* the sherpa DLLs are present. What could NOT be checked is anything that
* requires actually running Windows. This script checks those things, and it
* is deliberately blunt about the one number that matters most.
*
* "C:\Program Files\Church Translate\resources\node.exe" ^
* "C:\Program Files\Church Translate\resources\app\tools\verify-windows.js"
*
* (Adjust the path if you installed somewhere else. Both paths are printed in
* the app's log at data\logs\app.log.)
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
let pass = 0, fail = 0;
const ok = (label, good, detail) => {
console.log((good ? ' PASS ' : ' FAIL ') + label + (detail ? ' ' + detail : ''));
good ? pass++ : fail++;
};
console.log('\nChurch Translate — Windows verification\n');
console.log(' platform: ' + process.platform + ' ' + process.arch + ', node ' + process.version);
if (process.platform !== 'win32') {
console.log('\n This checks the WINDOWS build. On ' + process.platform +
' the expected answers are different (afconvert instead of ffmpeg,\n' +
' MLX instead of ONNX), so it would only report confusing failures. Nothing run.\n');
process.exit(0);
}
const APP = path.join(__dirname, '..');
const RES = path.join(APP, '..'); // resources/
/* 1. the bundled binaries exist and run */
const ffmpeg = path.join(RES, 'ffmpeg.exe');
ok('ffmpeg.exe is present', fs.existsSync(ffmpeg), ffmpeg);
let ffver = '';
try {
ffver = String(execFileSync(ffmpeg, ['-hide_banner', '-version'], { encoding: 'utf8', windowsHide: true })).split('\n')[0];
ok('ffmpeg.exe runs', true, ffver);
} catch (e) { ok('ffmpeg.exe runs', false, e.message); }
/* 2. it encodes ADTS at each rate the app pins, and the app's own reader agrees */
let adts = null;
try { adts = require(path.join(APP, 'server', 'live-audio.js')); } catch (e) {
console.log(' (could not load live-audio.js: ' + e.message + ')');
}
const { encodeAdts, PRIMING_FRAMES } = require(path.join(APP, 'server', 'aac-encode.js'));
console.log(' PRIMING_FRAMES on this platform: ' + PRIMING_FRAMES + ' (must be 1 on Windows)');
ok('PRIMING_FRAMES is the Windows value', PRIMING_FRAMES === 1);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ctverify-'));
function makeWav(rate, secs, tone) {
const n = Math.round(rate * secs), b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12);
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34);
b.write('data', 36); b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) b.writeInt16LE(tone(i, rate), 44 + i * 2);
return b;
}
for (const rate of [16000, 22050, 24000]) {
try {
const w = path.join(tmp, rate + '.wav'), a = path.join(tmp, rate + '.aac');
fs.writeFileSync(w, makeWav(rate, 2, (i, r) => Math.round(Math.sin(i / r * 2 * Math.PI * 220) * 8000)));
encodeAdts(w, a, rate);
const buf = fs.readFileSync(a);
const frames = adts ? adts.adtsFrames(buf).length : -1;
const got = adts ? adts.adtsSampleRate(buf) : rate;
ok('encodes ADTS at ' + rate + ' Hz', got === rate && buf.length > 512, frames + ' frames, ' + buf.length + ' bytes');
} catch (e) { ok('encodes ADTS at ' + rate + ' Hz', false, e.message); }
}
/*
* 3. THE ONE NUMBER THAT COULD NOT BE MEASURED FROM macOS.
*
* The stream drops PRIMING_FRAMES frames off the front of every clip to remove
* the encoder's priming. That was measured as 1024 samples on ffmpeg 6.0; the
* bundled build is newer. If this reports anything other than ~1024, tell
* Claude — every spoken sentence would be losing its first syllable.
*/
try {
const rate = 24000;
const w = path.join(tmp, 'click.wav'), a = path.join(tmp, 'click.aac'), d = path.join(tmp, 'click.dec.wav');
/* A signal that is loud from sample 0, so the offset is unambiguous. */
fs.writeFileSync(w, makeWav(rate, 1, (i, r) => Math.round(Math.sin(i / r * 2 * Math.PI * 440) * 12000)));
encodeAdts(w, a, rate);
execFileSync(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', '-i', a,
'-f', 'wav', '-c:a', 'pcm_s16le', '-ar', String(rate), '-ac', '1', d], { windowsHide: true });
const src = fs.readFileSync(w), dec = fs.readFileSync(d);
const S = (b) => { const n = (b.length - 44) >> 1; const o = new Int16Array(n); for (let i = 0; i < n; i++) o[i] = b.readInt16LE(44 + i * 2); return o; };
const a0 = S(src), b0 = S(dec);
let best = 0, bestScore = -Infinity;
for (let lag = 0; lag <= 4096; lag++) {
let dot = 0;
for (let i = 0; i < 4000; i++) dot += a0[i] * (b0[i + lag] || 0);
if (dot > bestScore) { bestScore = dot; best = lag; }
}
const frames = Math.round(best / 1024);
ok('encoder priming is ' + best + ' samples (~' + frames + ' frame' + (frames === 1 ? '' : 's') + ')',
frames === PRIMING_FRAMES,
frames === PRIMING_FRAMES ? 'matches PRIMING_FRAMES' : 'MISMATCH — speech onsets will be clipped, report this');
} catch (e) { ok('measure encoder priming', false, e.message); }
/* 3b. THE NATURAL VOICES. Kokoro runs here through ONNX, not MLX. */
try {
const vdir = path.join(APP, 'node_modules', 'kokoro-js', 'voices');
const male = ['em_alex', 'em_santa', 'pm_alex', 'pm_santa'].filter((v) => fs.existsSync(path.join(vdir, v + '.bin')));
ok('Spanish/Portuguese male style vectors present', male.length === 4, male.join(', '));
ok('espeak-ng (phonemiser) present', fs.existsSync(path.join(APP, 'node_modules', 'espeak-ng')));
ok('kokoro-sidecar.js present', fs.existsSync(path.join(APP, 'kokoro-sidecar.js')));
const ort = path.join(APP, 'node_modules', 'onnxruntime-node', 'bin', 'napi-v3');
const plats = fs.existsSync(ort) ? fs.readdirSync(ort) : [];
ok('onnxruntime has the win32 binaries', plats.includes('win32'), plats.join(', '));
} catch (e) { ok('natural-voice files present', false, e.message); }
/* 4. the native voice engine loads its Windows DLLs */
try {
require(path.join(APP, 'node_modules', 'sherpa-onnx-node'));
ok('sherpa-onnx native module loads', true);
} catch (e) { ok('sherpa-onnx native module loads', false, e.message.slice(0, 120)); }
/* 5. the big directories are on the LOCAL profile, not roaming */
const local = process.env.LOCALAPPDATA || '';
ok('LOCALAPPDATA is set', !!local, local);
for (const d of ['Church Translate', 'Church Translate Models', 'Church Translate Voices']) {
const p = path.join(local, d);
console.log(' ' + (fs.existsSync(p) ? 'exists ' : 'not yet ') + p);
}
const roaming = process.env.APPDATA || '';
if (roaming) {
const bad = ['Church Translate Models', 'Church Translate Voices'].filter((d) => fs.existsSync(path.join(roaming, d)));
ok('nothing heavy landed in the ROAMING profile', bad.length === 0, bad.join(', ') || roaming);
}
/* 6. interface labelling — the QR must not land on a virtual adapter */
try {
const ps = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
const out = execFileSync(ps, ['-NoProfile', '-NonInteractive', '-Command',
'Get-NetAdapter | Select-Object Name,InterfaceDescription,Virtual | ConvertTo-Json -Compress'],
{ encoding: 'utf8', timeout: 10000, windowsHide: true });
let rows = JSON.parse(out); if (!Array.isArray(rows)) rows = [rows];
ok('Get-NetAdapter works (QR can tell Wi-Fi from Hyper-V)', rows.length > 0, rows.length + ' adapters');
const nets = os.networkInterfaces();
for (const name of Object.keys(nets)) {
for (const ni of nets[name] || []) {
if (ni.family !== 'IPv4' || ni.internal || ni.address.startsWith('169.254.')) continue;
const r = rows.find((x) => x && x.Name === name);
const virt = r ? (!!r.Virtual || /hyper-v|virtual|vmware|wsl|loopback/i.test(String(r.InterfaceDescription || ''))) : false;
const wifi = /wi-?fi|wlan|wireless/i.test(name) || (r && /wi-?fi|wlan|wireless/i.test(String(r.InterfaceDescription || '')));
console.log(' ' + ni.address.padEnd(16) + name.padEnd(24) + (wifi ? 'WIFI ' : ' ') + (virt ? 'virtual (will be sorted last)' : ''));
}
}
} catch (e) { ok('Get-NetAdapter works', false, e.message.slice(0, 120)); }
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
/* 7. the natural-voice sidecar actually speaks. The slow one: it downloads
~320 MB of Kokoro on first run, so allow a few minutes on a cold cache. */
if (process.argv.includes('--voices')) {
const { spawnSync, spawn } = require('child_process');
const port = 43117;
console.log('\n starting the Kokoro sidecar on ' + port + ' (first run downloads ~320 MB)…');
const child = spawn(process.execPath, [path.join(APP, 'kokoro-sidecar.js'), String(port)],
{ stdio: ['ignore', 'inherit', 'inherit'], windowsHide: true,
env: Object.assign({}, process.env, { CT_MODELS_DIR: path.join(process.env.LOCALAPPDATA || '', 'Church Translate Models') }) });
const deadline = Date.now() + 15 * 60 * 1000;
const poll = () => new Promise((res) => {
require('http').get({ host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, (r) => {
let b = ''; r.on('data', (d) => b += d); r.on('end', () => res(b.indexOf('"ready":true') !== -1));
}).on('error', () => res(false)).on('timeout', function () { this.destroy(); res(false); });
});
(async () => {
while (Date.now() < deadline && !(await poll())) await new Promise((r) => setTimeout(r, 3000));
const body = JSON.stringify({ model: 'prince-canuma/Kokoro-82M', voice: 'em_alex',
input: 'Bendecimos el nombre de Jesús.', lang_code: 'e', speed: 1.0, response_format: 'wav' });
const got = await new Promise((res) => {
const req = require('http').request({ host: '127.0.0.1', port, path: '/v1/audio/speech', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 120000 },
(r) => { const c = []; r.on('data', (d) => c.push(d)); r.on('end', () => res({ status: r.statusCode, buf: Buffer.concat(c) })); });
req.on('error', (e) => res({ status: 0, buf: Buffer.alloc(0), err: e.message }));
req.end(body);
});
const n = got.buf.length > 44 ? (got.buf.length - 44) / 2 / 24000 : 0;
ok('Kokoro speaks Spanish (em_alex)', got.status === 200 && n > 0.5,
'HTTP ' + got.status + ', ' + n.toFixed(2) + 's of audio' + (got.err ? ' — ' + got.err : ''));
try { child.kill(); } catch (_) {}
console.log('\n' + (fail ? fail + ' FAILED — send this output to Claude' : 'all ' + pass + ' checks passed') + '\n');
process.exit(fail ? 1 : 0);
})();
} else {
console.log('\n (add --voices to also test the natural voices — downloads ~320 MB the first time)');
console.log('\n' + (fail ? fail + ' FAILED — send this output to Claude' : 'all ' + pass + ' checks passed') + '\n');
process.exit(fail ? 1 : 0);
}