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