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,340 @@
|
||||
/*
|
||||
* out-routing.js — send each language's spoken translation to its own physical
|
||||
* output channels.
|
||||
*
|
||||
* English on Dante 1+2, Spanish on 3, Portuguese on 4, and so on: one Mac
|
||||
* feeding a console or a set of assistive-listening transmitters, instead of
|
||||
* every language sharing one stereo pair.
|
||||
*
|
||||
* WHY THIS IS POSSIBLE AT ALL, and why the input side is not:
|
||||
*
|
||||
* MEASURED on this Mac, inside Electron (not the sandboxed preview):
|
||||
*
|
||||
* OUTPUT Dante Virtual Soundcard destination.maxChannelCount = 8
|
||||
* NDI Audio = 6
|
||||
* everything else = 2
|
||||
*
|
||||
* INPUT the same Dante device, an AudioWorklet counting real channels:
|
||||
* no constraint / 4 / 8 / {ideal:8} / 16 / 64 -> 2 every time
|
||||
* channelCount: {exact: 8} -> OverconstrainedError
|
||||
* channelCount: {min: 3} -> OverconstrainedError
|
||||
*
|
||||
* Those two rejections are Chromium stating outright that it cannot deliver
|
||||
* more than two input channels for this device, so picking "channel 16" of a
|
||||
* Dante input cannot be done from here at any channel count the card is set
|
||||
* to. OUTPUT has no such cap — the device's real count comes through — so
|
||||
* this module exists and its input-side twin cannot.
|
||||
*
|
||||
* Nothing here assumes a number. The count is read from the hardware every
|
||||
* time the device is opened, so a card raised from 8x8 to 64x64 mid-week
|
||||
* needs a Rescan and nothing else.
|
||||
*
|
||||
* Device-neutral by construction. Dante, Waves SoundGrid, an Antelope box, a
|
||||
* console over USB — they are all just Core Audio devices with a channel
|
||||
* count, and nothing below asks which one it is.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
/* Scheduling headroom. A buffer started at exactly currentTime is already
|
||||
late by the time the render quantum runs, which clicks. */
|
||||
const LEAD = 0.08;
|
||||
/* If a language's queue has drifted further behind than this, stop trying to
|
||||
honour the backlog and speak the newest sentence now. A translation that
|
||||
is a minute late is worse than a gap. */
|
||||
const MAX_BACKLOG = 8;
|
||||
|
||||
const state = {
|
||||
ctx: null,
|
||||
merger: null,
|
||||
deviceId: '',
|
||||
channels: 0,
|
||||
map: {}, // lang -> [channel index, ...]
|
||||
gain: {}, // lang -> dB trim, for matching a hardware input
|
||||
gainNodes: {}, // lang -> GainNode, built lazily and reused
|
||||
nextAt: {}, // lang -> AudioContext time its queue is free
|
||||
onLog: function () {},
|
||||
lastError: '',
|
||||
played: 0, // clips that reached the card since this graph opened
|
||||
failed: 0, // clips that did not, for the same period
|
||||
lastFailError: ''
|
||||
};
|
||||
|
||||
function dbToLinear(db) {
|
||||
const n = Number(db);
|
||||
if (!isFinite(n) || n === 0) return 1;
|
||||
return Math.pow(10, Math.max(-40, Math.min(20, n)) / 20);
|
||||
}
|
||||
|
||||
/*
|
||||
* One gain node per language, living for as long as the graph does.
|
||||
*
|
||||
* A transmitter's line input wants a level the synthesizer knows nothing
|
||||
* about, and every language is a different voice at a different loudness —
|
||||
* so the trim is per language and it is set once, not per clip.
|
||||
*/
|
||||
function gainFor(lang, chans) {
|
||||
let g = state.gainNodes[lang];
|
||||
if (!g) {
|
||||
g = state.ctx.createGain();
|
||||
g._wired = '';
|
||||
state.gainNodes[lang] = g;
|
||||
}
|
||||
g.gain.value = dbToLinear(state.gain[lang]);
|
||||
|
||||
/*
|
||||
* Rewire only when the destination actually changed.
|
||||
*
|
||||
* This node outlives individual clips, so a blanket disconnect/reconnect
|
||||
* on every buffer would cut whatever is still playing through it —
|
||||
* sentences are scheduled ahead of time and overlap the call that queues
|
||||
* the next one.
|
||||
*/
|
||||
const sig = chans.join(',');
|
||||
if (g._wired !== sig) {
|
||||
try { g.disconnect(); } catch (_) {}
|
||||
for (const ch of chans) { try { g.connect(state.merger, 0, ch); } catch (_) {} }
|
||||
g._wired = sig;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function log(msg, kind) { try { state.onLog(msg, kind); } catch (_) {} }
|
||||
|
||||
function active() {
|
||||
return !!(state.ctx && state.merger && state.channels > 0);
|
||||
}
|
||||
|
||||
/* Which physical channels a language plays on, or null for "not routed". */
|
||||
function channelsFor(lang) {
|
||||
const list = state.map && state.map[lang];
|
||||
if (!Array.isArray(list) || !list.length) return null;
|
||||
const out = list.map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < state.channels);
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
const ctx = state.ctx;
|
||||
state.ctx = null; state.merger = null; state.nextAt = {}; state.gainNodes = {};
|
||||
if (ctx) { try { await ctx.close(); } catch (_) {} }
|
||||
}
|
||||
|
||||
/*
|
||||
* Build (or rebuild) the output graph.
|
||||
*
|
||||
* `channels` is what the operator asked for; the device decides what it can
|
||||
* actually do, and asking for more than maxChannelCount throws rather than
|
||||
* degrading — so it is clamped and the real number is reported back.
|
||||
*/
|
||||
async function configure(opts) {
|
||||
opts = opts || {};
|
||||
const deviceId = String(opts.deviceId || '');
|
||||
state.map = opts.map || {};
|
||||
if (opts.gain) state.gain = opts.gain;
|
||||
state.onLog = opts.onLog || state.onLog;
|
||||
state.lastError = '';
|
||||
|
||||
if (!deviceId) { await teardown(); return { ok: true, channels: 0, off: true }; }
|
||||
|
||||
/*
|
||||
* Rebuilding tears down running audio, so only do it when something that
|
||||
* matters changed. A channel-map edit needs no new graph at all.
|
||||
*
|
||||
* `force` exists because a card reconfigured on the fly — Dante Virtual
|
||||
* Soundcard raised from 8x8 to 64x64 — keeps the SAME device id. Without a
|
||||
* way to say "re-read it", the app would go on offering the old count
|
||||
* forever, and the operator would reasonably conclude it was broken.
|
||||
*/
|
||||
if (!opts.force && state.ctx && state.deviceId === deviceId && state.channels > 0) {
|
||||
return { ok: true, channels: state.channels };
|
||||
}
|
||||
|
||||
await teardown();
|
||||
state.deviceId = deviceId;
|
||||
|
||||
try {
|
||||
const ctx = new AudioContext({ latencyHint: 'interactive' });
|
||||
if (typeof ctx.setSinkId !== 'function') throw new Error('this build cannot choose an output device');
|
||||
await ctx.setSinkId(deviceId);
|
||||
/* Only after the sink is set does maxChannelCount describe the real
|
||||
device — before that it is the default output's. */
|
||||
/*
|
||||
* Whatever the device says, with no ceiling of our own. 2, 8, 16, 64 —
|
||||
* the count comes from the hardware every time it is opened, so raising
|
||||
* a card's channel count and pressing Rescan is all it takes.
|
||||
*/
|
||||
const max = ctx.destination.maxChannelCount || 2;
|
||||
const want = Math.max(1, Math.min(max, Number(opts.channels) || max));
|
||||
ctx.destination.channelCount = want;
|
||||
ctx.destination.channelCountMode = 'explicit';
|
||||
/* DISCRETE, emphatically. The default 'speakers' interpretation would
|
||||
treat channels 1-8 as a surround layout and helpfully fold Spanish
|
||||
into a rear-left downmix. Discrete means channel 3 is channel 3. */
|
||||
ctx.destination.channelInterpretation = 'discrete';
|
||||
|
||||
const merger = ctx.createChannelMerger(want);
|
||||
merger.channelCountMode = 'explicit';
|
||||
merger.channelInterpretation = 'discrete';
|
||||
merger.connect(ctx.destination);
|
||||
|
||||
if (ctx.state === 'suspended') { try { await ctx.resume(); } catch (_) {} }
|
||||
|
||||
state.ctx = ctx;
|
||||
state.merger = merger;
|
||||
state.channels = want;
|
||||
state.nextAt = {};
|
||||
/* Per graph, not per service: picking a different device is a fresh
|
||||
question about whether THAT device is working. */
|
||||
state.played = 0; state.failed = 0; state.lastFailError = '';
|
||||
log('audio output: ' + want + ' channel' + (want === 1 ? '' : 's') +
|
||||
(want < max ? ' (device offers ' + max + ')' : ''), 'ok');
|
||||
return { ok: true, channels: want, max: max };
|
||||
} catch (e) {
|
||||
state.lastError = e.message || String(e);
|
||||
await teardown();
|
||||
state.channels = 0;
|
||||
log('audio output unavailable: ' + state.lastError, 'err');
|
||||
return { ok: false, channels: 0, error: state.lastError };
|
||||
}
|
||||
}
|
||||
|
||||
function setMap(map) { state.map = map || {}; }
|
||||
|
||||
function setGain(gain) {
|
||||
state.gain = gain || {};
|
||||
/* Live, on nodes that already exist — changing a trim while a sentence is
|
||||
playing should move that sentence's level, not the next one's. */
|
||||
for (const lang of Object.keys(state.gainNodes)) {
|
||||
try { state.gainNodes[lang].gain.value = dbToLinear(state.gain[lang]); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/* Does this language go anywhere? feedMic asks before doing any work. */
|
||||
function routed(lang) { return active() && !!channelsFor(lang); }
|
||||
|
||||
/*
|
||||
* Schedule one already-decoded buffer on a language's channels.
|
||||
*
|
||||
* Shared by the clip path and the live room-mic path, because the only thing
|
||||
* that differs between them is where the samples came from.
|
||||
*/
|
||||
function schedule(lang, buf) {
|
||||
const chans = channelsFor(lang);
|
||||
if (!chans) return;
|
||||
const ctx = state.ctx;
|
||||
const now = ctx.currentTime;
|
||||
let at = state.nextAt[lang] || 0;
|
||||
if (at < now + LEAD) at = now + LEAD;
|
||||
if (at - now > MAX_BACKLOG) at = now + LEAD;
|
||||
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
/* A MONO source on two channels is the same signal on both — dual mono,
|
||||
which is what feeds a stereo pair on a console or a transmitter's line
|
||||
input. Nothing here invents width the voice never had. */
|
||||
const g = gainFor(lang, chans);
|
||||
const splitter = buf.numberOfChannels > 1
|
||||
? ctx.createChannelSplitter(buf.numberOfChannels) : null;
|
||||
if (splitter) { src.connect(splitter); splitter.connect(g); }
|
||||
else src.connect(g);
|
||||
src.start(at);
|
||||
state.nextAt[lang] = at + buf.duration;
|
||||
}
|
||||
|
||||
/*
|
||||
* Raw PCM straight out, for the live room microphone.
|
||||
*
|
||||
* The SOURCE language never gets a synthesized clip — speakOne skips it,
|
||||
* there is nothing to translate English into for an English service — so
|
||||
* routing English to channels 1+2 produced silence. The audio English
|
||||
* actually has is the room feed, and this is how it reaches the hardware.
|
||||
*
|
||||
* Continuous, not sentence-shaped: each chunk is scheduled immediately after
|
||||
* the last so the feed plays as one unbroken stream rather than a queue.
|
||||
*/
|
||||
function playPcm(lang, samples, sampleRate) {
|
||||
if (!active() || !channelsFor(lang) || !samples || !samples.length) return;
|
||||
try {
|
||||
const ctx = state.ctx;
|
||||
const buf = ctx.createBuffer(1, samples.length, sampleRate || 16000);
|
||||
buf.copyToChannel ? buf.copyToChannel(samples, 0) : buf.getChannelData(0).set(samples);
|
||||
schedule(lang, buf);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/*
|
||||
* Play one clip on one language's channels.
|
||||
*
|
||||
* Deliberately fire-and-forget and deliberately never throws: this is a
|
||||
* parallel output path, and a failure here must cost the captions and the
|
||||
* phone stream nothing.
|
||||
*/
|
||||
async function play(lang, url) {
|
||||
if (!active()) return;
|
||||
const chans = channelsFor(lang);
|
||||
if (!chans) return; // this language is not routed anywhere
|
||||
|
||||
try {
|
||||
/* no-store, deliberately. Each clip is fetched exactly once, so an HTTP
|
||||
cache buys nothing here and cost a great deal: with `force-cache` and
|
||||
the server's `immutable`, a name reused after a restart replayed the
|
||||
previous service's audio. The names are unique now (see tts-service's
|
||||
seq); this makes a stale hit impossible rather than merely unlikely. */
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
if (!res.ok) { noteFail(lang, 'clip ' + res.status); return; }
|
||||
const bytes = await res.arrayBuffer();
|
||||
const ctx = state.ctx;
|
||||
if (!ctx || ctx !== state.ctx) return; // reconfigured while we were fetching
|
||||
const buf = await ctx.decodeAudioData(bytes);
|
||||
if (!state.ctx || ctx !== state.ctx) return;
|
||||
|
||||
schedule(lang, buf);
|
||||
state.played++;
|
||||
} catch (e) {
|
||||
/* A clip that will not decode is one missed sentence on the hardware
|
||||
outputs. Everything else about it already went out — but see noteFail:
|
||||
one missed sentence is nothing, and a thousand is a dead feed. */
|
||||
noteFail(lang, (e && e.message) || 'decode failed');
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* SAY SOMETHING WHEN THE HARDWARE FEED IS FAILING.
|
||||
*
|
||||
* This path is deliberately fire-and-forget: a sound card must never be able
|
||||
* to take the captions or the phone stream down with it, so every failure
|
||||
* above is swallowed. That was right, and it was also why a wrong clip name
|
||||
* left every Dante and transmitter output SILENT FOR WEEKS while the app
|
||||
* looked perfectly healthy — 404 on every clip, and not one line anywhere
|
||||
* saying so.
|
||||
*
|
||||
* Swallowing the failure and reporting it are not the same thing. Nothing
|
||||
* here throws or blocks; it counts, and it speaks up on the first failure of
|
||||
* a run and every 20th after, so a persistent fault is loud and an occasional
|
||||
* one is a footnote.
|
||||
*/
|
||||
function noteFail(lang, why) {
|
||||
state.failed++;
|
||||
state.lastFailError = String(why || '');
|
||||
if (state.failed === 1 || state.failed % 20 === 0) {
|
||||
log('hardware output: ' + state.failed + ' clip' + (state.failed === 1 ? '' : 's') +
|
||||
' did not reach the outputs (' + lang + ': ' + state.lastFailError + ')' +
|
||||
(state.played ? '' : ' — nothing has played on this device yet'), 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function status() {
|
||||
return {
|
||||
on: active(),
|
||||
deviceId: state.deviceId,
|
||||
channels: state.channels,
|
||||
error: state.lastError,
|
||||
played: state.played,
|
||||
failed: state.failed,
|
||||
lastFailError: state.lastFailError,
|
||||
routed: Object.keys(state.map || {}).filter((l) => channelsFor(l))
|
||||
};
|
||||
}
|
||||
|
||||
global.OutRouting = { configure, setMap, setGain, play, playPcm, routed, status, teardown };
|
||||
})(window);
|
||||
Reference in New Issue
Block a user