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>
99 lines
5.1 KiB
JavaScript
99 lines
5.1 KiB
JavaScript
import { pipeline, env } from '@huggingface/transformers';
|
|
env.allowLocalModels = false;
|
|
env.cacheDir = '/Users/Studio/Library/Application Support/Church Translate Models';
|
|
|
|
// --- chrF (character n-gram F-score, beta=2), written from scratch: no npm pkg exists for node
|
|
function ngrams(s, n) {
|
|
const t = s.replace(/\s+/g, '');
|
|
const m = new Map();
|
|
for (let i = 0; i + n <= t.length; i++) { const g = t.slice(i, i + n); m.set(g, (m.get(g) || 0) + 1); }
|
|
return m;
|
|
}
|
|
function chrF(hyp, ref, N = 6, beta = 2) {
|
|
let pS = 0, rS = 0, cnt = 0;
|
|
for (let n = 1; n <= N; n++) {
|
|
const H = ngrams(hyp, n), R = ngrams(ref, n);
|
|
let hs = 0, rs = 0, ov = 0;
|
|
for (const [g, c] of H) { hs += c; ov += Math.min(c, R.get(g) || 0); }
|
|
for (const [, c] of R) rs += c;
|
|
if (hs === 0 || rs === 0) continue;
|
|
pS += ov / hs; rS += ov / rs; cnt++;
|
|
}
|
|
if (!cnt) return 0;
|
|
const p = pS / cnt, r = rS / cnt;
|
|
if (p + r === 0) return 0;
|
|
return (1 + beta * beta) * p * r / (beta * beta * p + r);
|
|
}
|
|
|
|
const PAIRS = [
|
|
["Habakkuk stood upon his watchtower and waited to see what the Lord would say.", "And the Lord answered him and told him to write the vision plainly upon tablets."],
|
|
["Turn with me to Second Chronicles chapter seven verse fourteen.", "If my people who are called by my name will humble themselves and pray, I will heal their land."],
|
|
["For by grace you have been saved through faith, and that not of yourselves.", "It is the gift of God, not of works, so that no one may boast."],
|
|
["Melchizedek, king of Salem, brought out bread and wine.", "He was priest of God Most High, and he blessed Abram that day."],
|
|
["The Lord is my shepherd; I shall not want.", "He maketh me to lie down in green pastures and leadeth me beside the still waters."],
|
|
["Jesus wept at the tomb of Lazarus.", "Then he cried with a loud voice and called his friend out of the grave."],
|
|
["Paul wrote to the church at Philippi from a prison cell.", "He told them to rejoice in the Lord always, and again he said rejoice."],
|
|
["The elders fell down before the Lamb with harps and golden bowls of incense.", "And they sang a new song, saying, worthy art thou to take the scroll."],
|
|
];
|
|
const LANGS = [['spa_Latn','Spanish'],['hat_Latn','Haitian'],['arb_Arab','Arabic'],['kor_Hang','Korean'],['vie_Latn','Vietnamese']];
|
|
|
|
const t0 = Date.now();
|
|
const pipe = await pipeline('translation', 'Xenova/nllb-200-distilled-600M', { dtype: 'fp32' });
|
|
console.log(`MODEL LOAD: ${((Date.now()-t0)/1000).toFixed(1)}s`);
|
|
|
|
const tr = async (text, tgt) => {
|
|
const t = Date.now();
|
|
const r = await pipe(text, { src_lang: 'eng_Latn', tgt_lang: tgt });
|
|
return { text: (Array.isArray(r)?r[0]:r).translation_text.trim(), ms: Date.now()-t };
|
|
};
|
|
await tr(PAIRS[0][0], 'spa_Latn'); // warmup
|
|
|
|
const rows = [];
|
|
const tStart = Date.now();
|
|
for (const [code, name] of LANGS) {
|
|
for (const [s1, s2] of PAIRS) {
|
|
const a = await tr(s1, code);
|
|
const b = await tr(s2, code);
|
|
const c = await tr(s1 + ' ' + s2, code);
|
|
const simA = chrF(c.text, a.text);
|
|
const covB = chrF(c.text, b.text);
|
|
const lenRatio = c.text.length / a.text.length;
|
|
// clean-prefix test: does a.text appear verbatim at the head of c.text?
|
|
const prefixExact = c.text.startsWith(a.text);
|
|
// fuzzy prefix: chrF of the first a.text.length chars of c vs a
|
|
const prefixFuzzy = chrF(c.text.slice(0, a.text.length), a.text);
|
|
const dropped = simA > 0.85 && lenRatio < 1.35;
|
|
rows.push({ name, code, s1: s1.slice(0,28), a: a.text, b: b.text, c: c.text,
|
|
simA:+simA.toFixed(3), covB:+covB.toFixed(3), lenRatio:+lenRatio.toFixed(2),
|
|
prefixExact, prefixFuzzy:+prefixFuzzy.toFixed(3), dropped,
|
|
msA:a.ms, msB:b.ms, msC:c.ms });
|
|
}
|
|
}
|
|
const wall = (Date.now()-tStart)/1000;
|
|
|
|
console.log(`\nWALL CLOCK for ${rows.length*3} calls (${PAIRS.length} pairs x ${LANGS.length} langs x 3): ${wall.toFixed(1)}s`);
|
|
console.log(`=> per-call avg ${(wall*1000/(rows.length*3)).toFixed(0)}ms\n`);
|
|
|
|
console.log('lang dropRate meanCovB prefixExact prefixFuzzy meanLenRatio msA msC ratio');
|
|
for (const [code,name] of LANGS) {
|
|
const R = rows.filter(r=>r.code===code);
|
|
const f=(k)=> (R.reduce((s,r)=>s+(typeof r[k]==='boolean'? (r[k]?1:0) : r[k]),0)/R.length);
|
|
console.log(
|
|
name.padEnd(12) +
|
|
(f('dropped')*100).toFixed(0).padStart(6)+'% ' +
|
|
f('covB').toFixed(3).padStart(8) + ' ' +
|
|
(f('prefixExact')*100).toFixed(0).padStart(10)+'% ' +
|
|
f('prefixFuzzy').toFixed(3).padStart(10) + ' ' +
|
|
f('lenRatio').toFixed(2).padStart(12) + ' ' +
|
|
f('msA').toFixed(0).padStart(4) + ' ' + f('msC').toFixed(0).padStart(4) + ' ' +
|
|
(f('msC')/f('msA')).toFixed(2)
|
|
);
|
|
}
|
|
console.log('\n--- samples ---');
|
|
for (const r of rows.filter(r=>r.code==='hat_Latn'||r.code==='arb_Arab').slice(0,6)) {
|
|
console.log(`\n[${r.name}] ${r.s1}... dropped=${r.dropped} covB=${r.covB} prefixExact=${r.prefixExact}`);
|
|
console.log(' a:', r.a); console.log(' b:', r.b); console.log(' c:', r.c);
|
|
}
|
|
import fs from 'fs';
|
|
fs.writeFileSync('/private/tmp/claude-502/-Users-Studio-Documents-BandLineupDisplay/cee248f6-d861-4845-bd13-03867cf46596/scratchpad/rows.json', JSON.stringify(rows,null,1));
|