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,238 @@
|
||||
/*
|
||||
* mt-service.js — the translation model, in a process of its own.
|
||||
*
|
||||
* Three runtimes were tried before this one, and the reasons matter:
|
||||
*
|
||||
* • ONNX Runtime *Web* in the renderer: its weights go through a 32-bit WASM
|
||||
* heap capped at 4 GB. NLLB aborts at fp16 and at q4f16. The only variant
|
||||
* that fits is int8, which emits fluent-looking nonsense stitched together
|
||||
* from a dozen unrelated languages.
|
||||
*
|
||||
* • onnxruntime-node inside Electron's main process: crashes outright —
|
||||
* EXC_BREAKPOINT in CPUAllocator::Alloc on CrBrowserMain while building the
|
||||
* session. A multi-gigabyte synchronous allocation on the browser thread is
|
||||
* not something that thread will tolerate.
|
||||
*
|
||||
* • onnxruntime-node in a plain Node process: works. Correct output in every
|
||||
* script tested, about 0.8 s per language on this M2 Max.
|
||||
*
|
||||
* So translation lives here, in a forked plain-Node process (the parent runs it
|
||||
* with ELECTRON_RUN_AS_NODE), with a normal 64-bit heap and nothing else
|
||||
* sharing it. Electron's own utilityProcess was tried first and exits with a
|
||||
* bare code 5 and no output. If this dies, the parent restarts it — with
|
||||
* backoff — and the app keeps running.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
pipeline, env, LogitsProcessor, LogitsProcessorList
|
||||
} = require('@huggingface/transformers');
|
||||
|
||||
let pipe = null;
|
||||
|
||||
/*
|
||||
* ─────────────────────────── batched multi-target decode ───────────────────
|
||||
*
|
||||
* NLLB picks its target language with `forced_bos_token_id` — the FIRST token
|
||||
* the decoder is made to emit. The encoder input carries only the SOURCE
|
||||
* language tag (tokenizers.js `_build_translation_inputs` rewrites the
|
||||
* post-processor's special token to the source, and nothing else). So one
|
||||
* encoder input serves every target, and the only per-language difference is
|
||||
* one token at decode step 1.
|
||||
*
|
||||
* That makes N languages one batched decode instead of N passes over the same
|
||||
* 600M weights. This model is memory-bandwidth-bound — three worker processes
|
||||
* measured SLOWER than one (3.4s vs 1.9s for five languages) — and batching is
|
||||
* the right shape of fix for that: the weights are read once per step for the
|
||||
* whole batch instead of once per step per language.
|
||||
*
|
||||
* MEASURED on this M2 Max, one sentence, fp32, versus the same work serially:
|
||||
*
|
||||
* 2 langs 1.59x 3 langs 1.93x 5 langs 3.06x 9 langs 3.89x
|
||||
*
|
||||
* with output identical to the serial path in 9 of 9 languages.
|
||||
*
|
||||
* Two things had to be built for it:
|
||||
*
|
||||
* • PER-ROW target forcing. The stock ForcedBOSTokenLogitsProcessor holds a
|
||||
* single id for the whole batch, so every row would come out in the same
|
||||
* language.
|
||||
*
|
||||
* • STICKY EOS. transformers.js only leaves the decode loop once EVERY row
|
||||
* is done (`if (stop.every(x => x)) break`) and nothing pads a row that
|
||||
* already finished — its own source says `TODO: ensure all_input_ids is
|
||||
* padded correctly`. Untreated, a short language keeps generating free
|
||||
* text while a long one catches up: Spanish finished correctly and then
|
||||
* drifted through Czech and Romanian for another hundred tokens. Pinning
|
||||
* a finished row to EOS makes the tail strippable as special tokens.
|
||||
*/
|
||||
class BatchTargets extends LogitsProcessor {
|
||||
constructor(bosIds, eos) { super(); this.bosIds = bosIds; this.eos = eos; }
|
||||
_call(input_ids, logits) {
|
||||
for (let i = 0; i < input_ids.length; ++i) {
|
||||
const row = input_ids[i];
|
||||
const d = logits[i].data;
|
||||
if (row.length === 1) {
|
||||
/* Decode step 1: this row's target language, and nothing else. */
|
||||
d.fill(-Infinity);
|
||||
d[this.bosIds[i]] = 0;
|
||||
} else if (row[row.length - 1] === this.eos) {
|
||||
/* Already finished — hold it there rather than let it wander. */
|
||||
d.fill(-Infinity);
|
||||
d[Number(this.eos)] = 0;
|
||||
}
|
||||
}
|
||||
return logits;
|
||||
}
|
||||
}
|
||||
|
||||
/* The batched path needs tokenizer internals. If a future version of the
|
||||
library moves them, say so once and keep translating one at a time. */
|
||||
function batchable(tok) {
|
||||
return !!(tok &&
|
||||
typeof tok.lang_to_token === 'function' &&
|
||||
tok.languageRegex instanceof RegExp &&
|
||||
tok.post_processor && tok.post_processor.config &&
|
||||
Array.isArray(tok.post_processor.config.single) &&
|
||||
tok.model && typeof tok.model.convert_tokens_to_ids === 'function');
|
||||
}
|
||||
|
||||
let warnedNoBatch = false;
|
||||
|
||||
async function translateMulti(text, src, tgts) {
|
||||
const tok = pipe.tokenizer, model = pipe.model;
|
||||
if (!batchable(tok)) {
|
||||
if (!warnedNoBatch) {
|
||||
warnedNoBatch = true;
|
||||
reply({ type: 'note', message: 'batched translation unavailable; using one pass per language' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Pin the source language into the post-processor, the way the library's own
|
||||
translation path does. */
|
||||
for (const item of tok.post_processor.config.single) {
|
||||
if ('SpecialToken' in item && tok.languageRegex.test(item.SpecialToken.id)) {
|
||||
item.SpecialToken.id = tok.lang_to_token(src);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const bosIds = tgts.map((t) => tok.model.convert_tokens_to_ids([tok.lang_to_token(t)])[0]);
|
||||
if (bosIds.some((id) => id === undefined || id === null)) return null;
|
||||
|
||||
const enc = tok(new Array(tgts.length).fill(text), { padding: true, truncation: true });
|
||||
const inLen = enc.input_ids.dims[1];
|
||||
const eos = BigInt(model.generation_config.eos_token_id);
|
||||
|
||||
const lp = new LogitsProcessorList();
|
||||
lp.push(new BatchTargets(bosIds, eos));
|
||||
|
||||
const out = await model.generate({
|
||||
...enc,
|
||||
logits_processor: lp,
|
||||
forced_bos_token_id: null,
|
||||
/* A row that never emits EOS would otherwise hold the whole batch to the
|
||||
model's 200-token ceiling. No translation of a sentence runs to four
|
||||
times its source length. */
|
||||
max_new_tokens: Math.min(200, inLen * 4 + 24)
|
||||
});
|
||||
|
||||
const rows = tok.batch_decode(out, { skip_special_tokens: true });
|
||||
const texts = {};
|
||||
tgts.forEach((t, i) => { texts[t] = (rows[i] || '').trim(); });
|
||||
return texts;
|
||||
}
|
||||
|
||||
function reply(msg) {
|
||||
try { if (process.send) process.send(msg); } catch (_) {}
|
||||
}
|
||||
|
||||
process.on('message', async (msg) => {
|
||||
msg = msg || {};
|
||||
|
||||
try {
|
||||
if (msg.type === 'load') {
|
||||
env.allowLocalModels = false;
|
||||
if (msg.cacheDir) env.cacheDir = msg.cacheDir;
|
||||
|
||||
const load = () => pipeline('translation', msg.model, {
|
||||
dtype: msg.dtype || 'fp32',
|
||||
progress_callback: (p) => reply({ type: 'progress', p })
|
||||
});
|
||||
|
||||
try {
|
||||
pipe = await load();
|
||||
} catch (err) {
|
||||
/*
|
||||
* A download interrupted partway leaves a truncated .onnx in the cache,
|
||||
* and every launch after that dies on "Protobuf parsing failed" — the
|
||||
* app is bricked until somebody knows to delete a folder. Throw the
|
||||
* cached copy away and fetch it once more instead.
|
||||
*/
|
||||
const looksCorrupt = /protobuf|parsing failed|invalid model|corrupt|unexpected end/i
|
||||
.test((err && err.message) || '');
|
||||
if (!looksCorrupt) throw err;
|
||||
|
||||
const dir = path.join(env.cacheDir, msg.model.replace(/\\/g, '/'));
|
||||
reply({ type: 'progress', p: { status: 'initiate', file: 're-downloading damaged model files' } });
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {}
|
||||
pipe = await load();
|
||||
}
|
||||
|
||||
reply({ type: 'ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'translateMulti') {
|
||||
if (!pipe) throw new Error('translation model is not loaded yet');
|
||||
const text = String(msg.text || '');
|
||||
const src = String(msg.src || 'eng_Latn');
|
||||
const tgts = (msg.tgts || []).map(String);
|
||||
if (!tgts.length) { reply({ type: 'multi', id: msg.id, texts: {} }); return; }
|
||||
|
||||
let texts = null;
|
||||
if (tgts.length > 1) texts = await translateMulti(text, src, tgts);
|
||||
|
||||
/* One language, or a library that no longer exposes what batching needs:
|
||||
the ordinary path still answers, just without the speed-up. */
|
||||
if (!texts) {
|
||||
texts = {};
|
||||
for (const t of tgts) {
|
||||
const o = await pipe(text, { src_lang: src, tgt_lang: t });
|
||||
const g = Array.isArray(o) ? o[0] : o;
|
||||
texts[t] = (g && g.translation_text || '').trim();
|
||||
}
|
||||
}
|
||||
reply({ type: 'multi', id: msg.id, texts });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'translate') {
|
||||
if (!pipe) throw new Error('translation model is not loaded yet');
|
||||
const out = await pipe(String(msg.text || ''), {
|
||||
src_lang: String(msg.src || 'eng_Latn'),
|
||||
tgt_lang: String(msg.tgt || 'eng_Latn')
|
||||
});
|
||||
const got = Array.isArray(out) ? out[0] : out;
|
||||
reply({ type: 'result', id: msg.id, text: (got && got.translation_text || '').trim() });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
reply({
|
||||
type: msg.type === 'load' ? 'loadError' : 'error',
|
||||
id: msg.id,
|
||||
message: (err && err.message) || String(err)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* A crash here must be a visible, recoverable event in the parent — never a
|
||||
silent stall that looks like a slow model. */
|
||||
process.on('uncaughtException', (e) => {
|
||||
reply({ type: 'fatal', message: e.message });
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user