Files
church-translate/app/_bench-batch.mjs
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

119 lines
4.7 KiB
JavaScript
Raw 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.
/*
* Does one batched decode across N target languages beat N serial calls?
*
* The premise (verified in the installed tokenizer source): NLLB's tokenizer
* prefixes the ENCODER input with the SOURCE language only — the target is
* applied purely as `forced_bos_token_id` on the decoder. So one encoder input
* serves every target, and the only thing that differs per row is the first
* decoder token.
*/
import { pipeline, env, LogitsProcessor, LogitsProcessorList } from '@huggingface/transformers';
import os from 'os'; import path from 'path';
env.allowLocalModels = false;
env.cacheDir = path.join(os.homedir(), 'Library/Application Support/Church Translate Models');
const TEXT = 'And I want you to understand that God has a plan for your life';
const LANGS = ['spa_Latn','por_Latn','fra_Latn','zho_Hans','hat_Latn','hin_Deva','kor_Hang','vie_Latn','arb_Arab'];
const SRC = 'eng_Latn';
/* Forces a DIFFERENT language token on each row of the batch. The stock
ForcedBOSTokenLogitsProcessor holds a single id for the whole batch. */
class PerRowForcedBOS extends LogitsProcessor {
constructor(ids, eos) { super(); this.ids = ids; 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) {
/* First decoder step: this row's target language. */
d.fill(-Infinity);
d[this.ids[i]] = 0;
continue;
}
/*
* transformers.js only breaks the decode loop once EVERY row is done
* (models.js: `if (stop.every(x => x)) break`) and nothing pads a row
* that already finished — so a short language keeps generating free
* text while a long one catches up. Pin finished rows to EOS; the
* trailing tokens are then stripped by skip_special_tokens.
*/
if (row[row.length - 1] === this.eos) {
d.fill(-Infinity);
d[Number(this.eos)] = 0;
}
}
return logits;
}
}
const pipe = await pipeline('translation', 'Xenova/nllb-200-distilled-600M', { dtype: 'fp32' });
const tok = pipe.tokenizer, model = pipe.model;
console.log('tokenizer:', tok.constructor.name,
'| lang_to_token:', typeof tok.lang_to_token,
'| post_processor:', !!(tok.post_processor && tok.post_processor.config));
/* Pin the source language into the post-processor exactly as the library 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 EOS = BigInt(model.generation_config.eos_token_id ?? tok.model.convert_tokens_to_ids(['</s>'])[0]);
console.log('eos token id:', EOS.toString());
const bosOf = (l) => tok.model.convert_tokens_to_ids([tok.lang_to_token(l)])[0];
/* Confirm the encoder input really is target-independent. */
const a = tok(TEXT).input_ids.tolist()[0];
console.log('encoder ids (src-prefixed):', a.slice(0, 4).join(','), '… len', a.length);
async function batched(langs) {
const enc = tok(Array(langs.length).fill(TEXT), { padding: true, truncation: true });
const lp = new LogitsProcessorList();
lp.push(new PerRowForcedBOS(langs.map(bosOf), EOS));
const out = await model.generate({
...enc,
logits_processor: lp,
forced_bos_token_id: null,
max_new_tokens: 128,
});
const rows = tok.batch_decode(out, { skip_special_tokens: true });
const o = {}; langs.forEach((l, i) => o[l] = rows[i].trim());
return o;
}
async function serial(langs) {
const o = {};
for (const l of langs) {
const r = await pipe(TEXT, { src_lang: SRC, tgt_lang: l });
o[l] = (Array.isArray(r) ? r[0] : r).translation_text.trim();
}
return o;
}
/* warm both paths */
await serial(['spa_Latn']); await batched(['spa_Latn']);
console.log('\n N | serial | batched | speedup');
console.log('---+----------+----------+---------');
const sample = {};
for (const n of [1, 2, 3, 5, 9]) {
const langs = LANGS.slice(0, n);
let s = Date.now(); const so = await serial(langs); s = Date.now() - s;
let b = Date.now(); const bo = await batched(langs); b = Date.now() - b;
console.log(String(n).padStart(2) + ' | ' + (s + 'ms').padStart(8) + ' | ' + (b + 'ms').padStart(8) +
' | ' + (s / b).toFixed(2) + '×');
if (n === 9) { sample.serial = so; sample.batched = bo; }
}
console.log('\n--- identical output? ---');
let same = 0;
for (const l of LANGS) {
const ok = sample.serial[l] === sample.batched[l];
if (ok) same++; else console.log(' DIFFER ' + l + '\n serial : ' + sample.serial[l] + '\n batched: ' + sample.batched[l]);
}
console.log(' ' + same + '/' + LANGS.length + ' identical');
console.log(' spa batched: ' + sample.batched.spa_Latn);
console.log(' zho batched: ' + sample.batched.zho_Hans);