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:
@@ -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');
|
||||
Executable
+90
@@ -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"
|
||||
Executable
+70
@@ -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)'."
|
||||
@@ -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);
|
||||
@@ -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
Reference in New Issue
Block a user