Files
church-translate/app/selftest.js
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

154 lines
7.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Proves the model half of the engine works without needing any audio hardware:
* a known WAV goes in, a transcript and translations come out. When something
* is wrong on a Sunday, this separates "the AI is broken" from "the audio
* routing is wrong" in about a minute.
*/
const out = document.getElementById('out');
const lines = [];
function say(msg, cls) {
lines.push(msg);
out.innerHTML = lines.map((l) => l.startsWith('FAIL') ? '<span class="bad">' + l + '</span>'
: l.startsWith('PASS') ? '<span class="ok">' + l + '</span>' : l).join('\n');
console.log('[selftest] ' + msg);
}
function makeWorker(label, task, model, dtype, fallback) {
return new Promise((resolve, reject) => {
const w = new Worker(new URL('./model-worker.js', import.meta.url), { type: 'module' });
w.onerror = (e) => reject(new Error(e.message || 'worker error'));
w.onmessage = (e) => {
const m = e.data;
if (m.type === 'gpu') { say(' gpu: ' + (m.features.includes('shader-f16') ? 'shader-f16 present' : 'no shader-f16')); return; }
if (m.type === 'ready') { resolve(w); return; }
if (m.type === 'loadError') {
if (fallback && /f16|fp16/i.test(m.message)) {
say(' ' + label + ': no fp16, retrying with integer weights');
w.terminate();
makeWorker(label, task, model, fallback, null).then(resolve, reject);
return;
}
reject(new Error(m.message));
}
};
w.postMessage({ type: 'load', label, task, model, dtype });
});
}
let seq = 0;
const pending = new Map();
function run(worker, input, options, transfer) {
return new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
const prev = worker.onmessage;
worker.onmessage = (e) => {
const m = e.data;
if (m.type === 'result' || m.type === 'error') {
const p = pending.get(m.id);
if (p) { pending.delete(m.id); m.type === 'result' ? p.resolve(m.out) : p.reject(new Error(m.message)); }
return;
}
if (prev) prev(e);
};
worker.postMessage({ type: 'run', id, input, options }, transfer || []);
});
}
const ARGS = new URLSearchParams(location.search);
(async () => {
let failures = 0;
try {
const cfg = await window.engineHost.getConfig();
/* ?mt=q4f16 overrides the configured weights, and ?skipAsr=1 skips the
speech model — loading it costs ~50s and it is not what we are testing
when we are iterating on translation. */
const mtDtype = ARGS.get('mt') || cfg.mtDtype || 'fp16';
const skipAsr = ARGS.get('skipAsr') === '1';
say('translation weights under test: ' + mtDtype + (skipAsr ? ' (speech model skipped)' : ''));
say('loading audio…');
const buf = await (await fetch('./selftest.wav')).arrayBuffer();
const ctx = new OfflineAudioContext(1, 1, 16000);
const decoded = await ctx.decodeAudioData(buf);
const audio = decoded.getChannelData(0);
say(' ' + decoded.duration.toFixed(1) + 's at ' + decoded.sampleRate + ' Hz');
let text = 'Good morning, church. Turn with me to Habakkuk, chapter one. ' +
'How long, O Lord, must I call for help, and you will not listen?';
if (!skipAsr) {
say('loading speech model…');
const t0 = performance.now();
const asr = await makeWorker('speech', 'automatic-speech-recognition', cfg.asrModel,
{ encoder_model: 'fp16', decoder_model_merged: 'q4' },
{ encoder_model: 'q4', decoder_model_merged: 'q4' });
say(' ready in ' + ((performance.now() - t0) / 1000).toFixed(1) + 's');
say('transcribing…');
const t1 = performance.now();
const res = await run(asr, audio.slice(), { language: 'en', task: 'transcribe', return_timestamps: false });
const elapsed = (performance.now() - t1) / 1000;
text = (Array.isArray(res) ? res.map((r) => r.text).join(' ') : res.text).trim();
say(' "' + text + '"');
say(' ' + elapsed.toFixed(1) + 's for ' + decoded.duration.toFixed(1) + 's of audio ('
+ (decoded.duration / elapsed).toFixed(1) + '× real time)');
const lower = text.toLowerCase();
if (lower.includes('habakkuk')) say('PASS heard "Habakkuk"');
else { say('FAIL did not hear "Habakkuk" — got: ' + text); failures++; }
if (lower.includes('how long')) say('PASS heard "how long"');
else { say('FAIL did not hear "how long"'); failures++; }
if (decoded.duration / elapsed > 1) say('PASS faster than real time');
else { say('FAIL slower than real time — will fall behind a preacher'); failures++; }
}
say('loading translation model…');
const t2 = performance.now();
window.engineHost.onMtProgress(() => {});
await window.engineHost.mtEnsure();
say(' ready in ' + ((performance.now() - t2) / 1000).toFixed(1) + 's');
/* Catch the failure mode that quantized NLLB produces: fluent-looking
output made of tokens from a dozen unrelated languages. Checking the
script is cheap and catches it every time — an empty-or-identical check
does not. */
const SCRIPT = {
spa_Latn: { want: /[a-z\u00c0-\u024f]/i, banned: /[\u4e00-\u9fff\u0600-\u06ff\uac00-\ud7af\u0400-\u04ff\u0900-\u097f]/ },
hat_Latn: { want: /[a-z\u00c0-\u024f]/i, banned: /[\u4e00-\u9fff\u0600-\u06ff\uac00-\ud7af\u0400-\u04ff\u0900-\u097f]/ },
zho_Hans: { want: /[\u4e00-\u9fff]/, banned: /[\uac00-\ud7af\u0600-\u06ff]/ },
kor_Hang: { want: /[\uac00-\ud7af]/, banned: /[\u0600-\u06ff]/ },
arb_Arab: { want: /[\u0600-\u06ff]/, banned: /[\u4e00-\u9fff\uac00-\ud7af]/ }
};
function scriptOk(text, lang) {
const rule = SCRIPT[lang];
if (!rule) return true;
if (!rule.want.test(text)) return false;
if (rule.banned.test(text)) return false;
return true;
}
const targets = ['spa_Latn', 'zho_Hans', 'kor_Hang', 'hat_Latn', 'arb_Arab'];
say('translating into ' + targets.length + ' languages…');
const t3 = performance.now();
for (const tgt of targets) {
const one = performance.now();
const got = (await window.engineHost.mtTranslate({ text, src: 'eng_Latn', tgt })).trim();
const name = window.Languages.get(tgt).name;
say(' ' + name.padEnd(20) + ((performance.now() - one) / 1000).toFixed(2) + 's ' + got.slice(0, 120));
if (!got || got === text) { say('FAIL ' + name + ' came back empty or untranslated'); failures++; }
else if (!scriptOk(got, tgt)) { say('FAIL ' + name + ' is not in the right script — quantized-model gibberish'); failures++; }
else say('PASS ' + name + ' is real ' + name);
}
const total = (performance.now() - t3) / 1000;
say(' ' + total.toFixed(1) + 's for all ' + targets.length);
if (total < 5) say('PASS all languages inside 5s');
else { say('FAIL ' + total.toFixed(1) + 's is too slow for live use'); failures++; }
} catch (e) {
say('FAIL ' + (e && e.message || e));
failures++;
}
say(failures ? ('SELFTEST: ' + failures + ' FAILED') : 'SELFTEST: all passed');
})();