Files
church-translate/app/out-routing.js
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

341 lines
14 KiB
JavaScript

/*
* 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);