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>
This commit is contained in:
2026-09-23 15:41:21 -04:00
co-authored by Claude Opus 5
commit c9eef0672c
118 changed files with 36031 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
/*
* check-packaged-files.js — refuse to build an app that is missing a module.
*
* package.json's build.files is an explicit ALLOW-LIST. Adding a new module and
* forgetting to list it produces a DMG that installs cleanly, launches cleanly,
* and then throws at the first require() — which is exactly what shipped once:
* three new files were left out, `require('./sidecar-langs.js')` threw inside
* the voice process, and the natural voices silently never started. Nothing in
* the build said a word.
*
* So the build now reads what the code actually loads — every local require()
* in the main/child processes and every <script src="./…"> in the pages — and
* fails if any of it would not be packaged.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const APP = path.join(__dirname, '..');
const pkg = JSON.parse(fs.readFileSync(path.join(APP, 'package.json'), 'utf8'));
const patterns = (pkg.build && pkg.build.files) || [];
/* Does the allow-list cover this path? Only the two glob shapes this file
actually uses — "server/**\/*" and plain paths. */
/* electron-builder always includes these, whatever the allow-list says. */
const ALWAYS = new Set(['package.json']);
/* The allow-list in package.json is forward-slash; path.join() gives
backslashes when this runs on Windows, so every server/** module would read
as un-packaged and refuse the build. Compare in one notation. */
const toPosix = (p) => String(p).split(path.sep).join('/');
function covered(rel) {
rel = toPosix(rel);
if (ALWAYS.has(rel)) return true;
return patterns.some((p) => {
if (p === rel) return true;
const star = p.indexOf('**');
if (star !== -1) return rel.startsWith(p.slice(0, star));
return false;
});
}
/* Entry points whose dependency graph must be packaged. */
const ROOTS = ['main.js', 'preload.js', 'engine.js', 'tts-service.js', 'mt-service.js',
'model-worker.js', 'server/server.js', 'kokoro-sidecar.js'];
const HTML = ['index.html', 'server/public/index.html'];
const seen = new Set();
const missing = [];
function scanJs(rel) {
if (seen.has(rel)) return;
seen.add(rel);
const abs = path.join(APP, rel);
let src;
try { src = fs.readFileSync(abs, 'utf8'); } catch (_) { return; }
const dir = toPosix(path.dirname(rel));
const re = /require\(\s*['"](\.[^'"]+)['"]\s*\)/g;
let m;
while ((m = re.exec(src))) {
let dep = toPosix(path.normalize(path.join(dir, m[1])));
if (!/\.[a-z]+$/.test(dep)) dep += '.js';
if (dep.startsWith('..')) continue; // outside the app dir
if (!fs.existsSync(path.join(APP, dep))) continue; // optional/native, let node fail loudly
if (!covered(dep)) missing.push(dep + ' (required by ' + rel + ')');
scanJs(dep);
}
}
for (const r of ROOTS) scanJs(r);
for (const page of HTML) {
let src;
try { src = fs.readFileSync(path.join(APP, page), 'utf8'); } catch (_) { continue; }
const dir = toPosix(path.dirname(page));
const re = /<script\s+src=["'](\.\/[^"']+)["']/g;
let m;
while ((m = re.exec(src))) {
const dep = toPosix(path.normalize(path.join(dir, m[1])));
if (!fs.existsSync(path.join(APP, dep))) continue;
if (!covered(dep)) missing.push(dep + ' (script in ' + page + ')');
if (dep.endsWith('.js')) scanJs(dep);
}
}
if (missing.length) {
console.error('\nBUILD REFUSED — these files are loaded at runtime but are NOT in package.json build.files:\n');
for (const f of Array.from(new Set(missing))) console.error(' ' + f);
console.error('\nAdd them to build.files, or the installed app will throw on first use.\n');
process.exit(1);
}
console.log('[check] all ' + seen.size + ' runtime modules are packaged');
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
#
# install-latest.sh — quit, replace, relaunch. One step, repeatable.
#
# Doing this by hand meant three builds in a row were staged and never
# installed, so the app under test was two versions behind what the log said
# was fixed. That is worse than no build at all.
#
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
REL="$ROOT/Releases"
DIST="$ROOT/app/dist"
APPS="/Applications"
NAME="Church Translate.app"
# Both places, newest first. `npm run dist` writes to app/dist and only a
# release copy lands in Releases/ — looking in one of them installed a build
# that was two versions behind the one just compiled, and said "3.8.0 -> 3.8.0"
# while doing it. That is the exact failure this script was written to stop.
DMG="$(ls -t "$REL"/*.dmg "$DIST"/*.dmg 2>/dev/null | head -1)"
[ -n "$DMG" ] || { echo "No .dmg in $REL or $DIST"; exit 1; }
echo "installing: $(basename "$DMG")"
# Stale volumes from earlier installs keep the old version's name around and
# make it genuinely hard to tell what is mounted.
for v in /Volumes/*Church\ Translate*; do
[ -d "$v" ] && { echo " ejecting stale $v"; hdiutil detach "$v" -quiet -force || true; }
done
# NEVER install over a live service. main.js deliberately blocks its own window
# close with a confirmation dialog while translation is live, so that nobody
# takes the captions down mid-sermon — and this script's force-terminate would
# walk straight through that protection. Ask the server instead.
if pgrep -f "$NAME/Contents/MacOS" >/dev/null; then
CFG="$HOME/Library/Application Support/Church Translate/data/config.json"
KEY="$(/usr/bin/python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get('controlToken',''))" "$CFG" 2>/dev/null || true)"
if [ -n "$KEY" ]; then
ST="$(curl -s --max-time 4 "http://127.0.0.1:3100/api/status.txt?key=$KEY" 2>/dev/null || true)"
if [ "$ST" = "live" ]; then
echo "REFUSING TO INSTALL: translation is LIVE right now."
echo "Stop the service first — installing would cut the captions for everyone listening."
exit 2
fi
[ -n "$ST" ] && echo " service is '$ST' (not live) — safe to replace"
fi
echo " quitting the running app"
osascript -e 'tell application "Church Translate" to quit' 2>/dev/null || true
for _ in $(seq 1 50); do # 25s: it shuts three model children down first
pgrep -f "$NAME/Contents/MacOS" >/dev/null || break
sleep 0.5
done
# The forked children (server, mt, tts) exit with the parent; if the parent
# is wedged, take it down rather than installing under a live process.
pgrep -f "$NAME/Contents/MacOS" >/dev/null && {
echo " did not quit in 25s — terminating"
pkill -f "$NAME/Contents/MacOS" || true
sleep 2
}
fi
MNT="$(mktemp -d /tmp/ct-mnt.XXXXXX)"
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MNT"
trap 'hdiutil detach "$MNT" -quiet -force >/dev/null 2>&1 || true; rmdir "$MNT" 2>/dev/null || true' EXIT
# Prove the replacement exists BEFORE removing what works.
[ -d "$MNT/$NAME" ] || { echo "no $NAME inside the dmg"; exit 1; }
NEW="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$MNT/$NAME/Contents/Info.plist")"
NEWB="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$MNT/$NAME/Contents/Info.plist" 2>/dev/null || echo '?')"
OLD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APPS/$NAME/Contents/Info.plist" 2>/dev/null || echo 'none')"
OLDB="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APPS/$NAME/Contents/Info.plist" 2>/dev/null || echo '0')"
echo " $OLD (build $OLDB) -> $NEW (build $NEWB)"
# Installing the same build over itself looks identical to a successful
# install and is how a fix gets reported as shipped when it is not on disk.
if [ "$OLD" = "$NEW" ] && [ "$OLDB" = "$NEWB" ]; then
echo " NOTE: that is the build already installed — nothing changed."
echo " Did you forget to bump version/buildNumber, or to run npm run dist?"
fi
rm -rf "$APPS/$NAME"
ditto "$MNT/$NAME" "$APPS/$NAME"
# The build is deliberately unsigned, so a fresh copy off a disk image carries
# a quarantine flag and Gatekeeper refuses to open it without a right-click.
# This is the user's own locally built app being installed at their request.
xattr -dr com.apple.quarantine "$APPS/$NAME" 2>/dev/null || true
open -a "$APPS/$NAME"
echo " launched"
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# One-time, per-Mac installer for the free local voices
# (Kokoro and Qwen3-TTS, both via mlx-audio).
# Bought once for $0: Apache-2.0 weights, runs entirely on this Mac.
# The venv CANNOT be copied between machines — run this on each Mac.
set -euo pipefail
DIR="$HOME/Library/Application Support/Church Translate Local Voices"
VENV="$DIR/venv"
PORT="${1:-43111}"
echo "== Church Translate — local voices installer =="
# macOS ships Python 3.9, which mlx-audio cannot use — so FIND a 3.10+ python
# rather than assuming the one on PATH. Checked in order: uv-managed (this Mac
# has them), Homebrew, versioned names on PATH, then plain python3.
PY=""
pick_py() {
for c in "$@"; do
[ -x "$c" ] || continue
"$c" -c 'import sys; sys.exit(0 if sys.version_info >= (3,10) else 1)' 2>/dev/null && { PY="$c"; return 0; }
done
return 1
}
pick_py $(ls -d "$HOME/.local/share/uv/python/"*/bin/python3 2>/dev/null | sort -r) \
/opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11 \
/opt/homebrew/bin/python3 /usr/local/bin/python3 \
"$(command -v python3.13 || true)" "$(command -v python3.12 || true)" \
"$(command -v python3.11 || true)" "$(command -v python3 || true)" || {
echo "No Python 3.10+ found. Install one, then re-run:"
echo " brew install python@3.12 # or: xcode-select --install"
exit 1
}
echo "using python: $PY ($("$PY" --version 2>&1))"
mkdir -p "$DIR"
[ -d "$VENV" ] || "$PY" -m venv "$VENV"
"$VENV/bin/pip" install --upgrade pip >/dev/null
# The exact dependency set, learned from consecutive boot failures:
# mlx_audio.server needs uvicorn/fastapi/python-multipart; webrtcvad still
# imports pkg_resources, which setuptools>=81 removed.
"$VENV/bin/pip" install 'setuptools<81' mlx-audio uvicorn fastapi python-multipart webrtcvad
# Kokoro's text processing. mlx-audio ships the model code but not these, and
# without them synthesis fails at the last step with an ImportError for
# 'misaki' — after the 330MB model has already downloaded. espeakng-loader
# carries its own libespeak-ng, so no Homebrew and nothing system-wide.
"$VENV/bin/pip" install misaki phonemizer-fork espeakng-loader
echo "== prefetching the model (one-time download, ~2GB) =="
"$VENV/bin/python3" - <<'PY'
from huggingface_hub import snapshot_download
snapshot_download('mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-8bit')
print('model cached')
PY
echo "== self-test: boot the server and synthesize one sentence =="
"$VENV/bin/python3" -m mlx_audio.server --host 127.0.0.1 --port "$PORT" &
SRV=$!
trap 'kill $SRV 2>/dev/null' EXIT
for i in $(seq 1 30); do curl -s -o /dev/null "http://127.0.0.1:$PORT/v1/models" && break; sleep 1; done
OUT="$DIR/selftest.wav"
curl -s -X POST "http://127.0.0.1:$PORT/v1/audio/speech" \
-H 'Content-Type: application/json' \
-d '{"model":"mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-8bit","voice":"aiden","input":"The Lord bless you and keep you.","lang_code":"english","response_format":"wav","max_tokens":300,"temperature":0.2}' \
-o "$OUT"
head -c 4 "$OUT" | grep -q RIFF && echo "SELF-TEST OK — $OUT ($(stat -f%z "$OUT") bytes)" || { echo "SELF-TEST FAILED"; exit 1; }
kill $SRV 2>/dev/null; trap - EXIT
echo
echo "Done. In Church Translate: Operator pane → Premium voices → 'Local (this Mac — free)'."
+43
View File
@@ -0,0 +1,43 @@
/*
* sync-build-version.js — make `buildNumber` mean something.
*
* package.json carried TWO build numbers: a top-level `buildNumber` that was
* dutifully bumped every release and read by absolutely nothing, and
* `build.buildVersion` — the one electron-builder writes to CFBundleVersion —
* hardcoded at "51" since 3.4.0. So the installed app reported build 51 while
* the release was build 55, and the DMG filename was the only honest clue
* anywhere. Runs as `predist`, so bumping buildNumber alone is now enough.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const p = path.join(__dirname, '..', 'package.json');
const raw = fs.readFileSync(p, 'utf8');
const pkg = JSON.parse(raw);
const want = String(pkg.buildNumber || '').trim();
if (!want) {
console.error('package.json has no buildNumber — refusing to build a build with no number');
process.exit(1);
}
if (!/^\d+$/.test(want)) {
console.error('buildNumber must be digits, got "' + want + '"');
process.exit(1);
}
const have = String((pkg.build && pkg.build.buildVersion) || '');
if (have === want) {
console.log('build number ' + want + ' (CFBundleVersion already matches)');
process.exit(0);
}
/* Rewrite the one value textually rather than re-serialising the whole file, so
this never reformats a config the operator's settings depend on. */
const re = /("buildVersion"\s*:\s*")([^"]*)(")/;
if (!re.test(raw)) {
console.error('could not find build.buildVersion in package.json');
process.exit(1);
}
fs.writeFileSync(p, raw.replace(re, '$1' + want + '$3'));
console.log('CFBundleVersion ' + (have || '(unset)') + ' -> ' + want);
+200
View File
@@ -0,0 +1,200 @@
/*
* 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);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff