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>
119 lines
4.7 KiB
JavaScript
119 lines
4.7 KiB
JavaScript
/*
|
||
* 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);
|