Files
church-translate/app/engine.js
christianandClaude Fable 5.1 81da657094 One voice per listener, and the voice process stops stalling itself
Build 127. Reported from a live test: part of the translation came out in a
different voice on an Android phone, and iPhones were skipping and repeating.
Three investigations against the campus Mac's own log and transcript, one
test harness driving the voice process directly, and the answer is not what
it sounded like.

WHAT THE MACHINE DID. One voice process ran all afternoon. Every language
kept a single resident engine from its first sentence (14:54:26) to STOP
(15:23:18); 2,800 clips, 560 per language, every one filed under its own
language and spoken by its own bundle. No synthesis-side voice change was
possible with that configuration. What the log shows at the time of the
complaint instead:

  - Someone on the English stream heard the ROOM MICROPHONE for 21 s
    (15:09:10-32). English was the first button on every phone's list, forced
    there by the page whatever the operator had chosen (fixed in build 125).
  - A listener tapped Chinese at 15:11:17. The 121 MB zh model was fetched
    and unpacked THREE TIMES AT ONCE on the voice process's only thread.
    Every stream stalled for about a minute, 26 sentences were dropped across
    all five languages, and the catch-up rushed the Spanish voices to their
    +16% cap. Same voice, sounding wrong.

WHAT WAS WRONG, AND IS NOW FIXED.

The live path filed every engine as an "audition". speak() resolved the
language's default voice, which always has a bundle, and passed it as
`explicit`, so the key was 'audition:'+bundle for every ordinary sentence
while the preload warmed copies under the language name that no sentence
ever read. Five languages asked for nine engines against a cap of six; every
service's first sentence built its real engines on the spot (1.3-2 s); the
operator's voice save flushed nothing that mattered. Engines are now keyed
by bundle and shared by every language, variant and audition that uses one,
auditions take at most two slots of their own and can never evict a live
engine, and the cap is 8, sized to the largest campus from measurement
(~165 MB per engine once it has spoken, 1.3 GB for eight).

Voice installs ran on the thread that answers every sentence, and were not
de-duplicated. They now run in a worker thread (voice-install.js), one per
bundle however many ask, staged and renamed into place whole so a half-
written bundle can never pass isInstalled().

Listener rows are { code, count } and engine.js read { lang, n } in five
places, so the set of "languages someone is on" was always empty — and a late
sentence keeps speech only for those. Under load the app dropped EVERY
language's speech instead of only the unchosen ones. Late became silent.

HLS segment numbers restarted at 1 for every new stream object, under a
'max-age=60, immutable' header. A phone back from the lock screen could be
answered from its cache with the previous stream's audio under the new
number: an earlier sentence, replayed. Numbers are seeded from the clock.

In clips mode a clip still downloading when the listener switched language
landed and played in the voice they had left; the frame now carries its
language and voice and the page checks both when the bytes land. In stream
mode the old playlist stayed loaded through a switch and three things could
restart it; it is stopped, not paused. A listener's alternate voice no longer
loses a round trip to the default on the original-text toggle.

One genuine wrong-voice path, dormant today because natural voices are off on
this campus: a variant waiting on another variant's premium synthesis
inherited that variant's Piper FALLBACK, in that variant's bundle — spa_ES
received the byte-identical Mexican clip and its own voice never loaded. A
follower now shares a fallback only when it is its own voice.

An audition (the Voices card's play button) played on the system default
output, which on the campus Mac is the Dante card feeding the hardware
channels. It now plays on another output, or refuses and says why.

VERIFIED: a harness drives the voice process through the baseline, a batch
of four auditions during live sentences, a voices save mid-stream, and nine
live languages against the cache — every clip spoken by its own bundle, and
auditions of a live voice now cost 78-311 ms with nothing rebuilt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 16:05:15 -04:00

5262 lines
224 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* engine.js — transcription and translation, on this Mac's GPU.
*
* The shape of the thing:
*
* CoreAudio input ──▶ AudioWorklet ──▶ voice detection ──▶ segments
* │ │
* (Dante / Waves / console USB) ▼
* Whisper (WebGPU)
* │
* NLLB-200 (WebGPU)
* │
* text ──▶ church server
*
* No audio ever leaves this machine. Only finished sentences go out, which is
* why a full auditorium costs the NAS nothing.
*/
/* The models live in workers (see model-worker.js) — one apiece, so each gets
its own ONNX WASM heap. Nothing here touches Transformers.js directly. */
const SAMPLE_RATE = 16000;
const FRAME = 1024; // 64 ms per frame at 16 kHz
const FRAME_MS = (FRAME / SAMPLE_RATE) * 1000;
/*
* The single biggest lever on how far behind the preacher we are.
*
* Every millisecond here is a millisecond the congregation waits, because
* nothing is transcribed until the sentence is judged finished. But cut it too
* fine and clause-length pauses split sentences in half — which costs more than
* it saves, since NLLB translates a sentence at a time and a fragment
* translates badly.
*
* Inter-word pauses run 100–300 ms and clause pauses 300–500 ms, so 550 ms sits
* just above the speech and below a real full stop.
*/
const SILENCE_TO_END = Math.round(550 / FRAME_MS);
const MIN_SPEECH = Math.round(320 / FRAME_MS); // ignore coughs and chair scrapes
/*
* Continuous speech must not become a paragraph. A 15-second ceiling meant the
* first word of a run-on chunk was already 15s old before transcription even
* started — the single biggest term in the measured 16-27s end-to-end delay.
*
* Past SOFT_CUT the segment closes at the next breath (a dip of SOFT_DIP,
* far shorter than a full stop's pause). If the speaker truly never breathes,
* HARD_CUT starts looking for any dip at all, and HARD_CUT_MAX chops mid-word
* as the last resort — cutting a word costs less than shipping a paragraph.
*
* TIGHTENED for fast preachers. These were 5000/192/7000/9000, tuned against a
* speaker who breathes. Someone who talks quickly never produces a 192ms dip,
* so every one of their segments ran to the 9s ceiling: the first word was 9
* seconds old before transcription began, and the room heard one long burst of
* speech every nine seconds with a hole between them. That reads as "delayed
* and not real time" even when the pipeline behind it is keeping up.
*
* Same direction the 15s -> 9s change took, for the same measured reason.
* 130ms is still an order of magnitude above the gaps between phonemes, so it
* finds the seams in fast speech without cutting inside words.
*/
const SOFT_CUT = Math.round(3200 / FRAME_MS);
const SOFT_DIP = Math.round(130 / FRAME_MS);
const HARD_CUT = Math.round(4200 / FRAME_MS);
const HARD_CUT_MAX = Math.round(5500 / FRAME_MS);
/*
* When the live preview's text already ends in sentence punctuation, the
* sentence is over — the 550ms full-stop pause is just confirmation. Close at
* the next short breath instead. Guarded hard: only once the segment is long
* enough that a false period costs little (Whisper loves to invent periods
* mid-clause), only on a fresh preview, and never on a tail window.
*/
const EARLY_CLOSE = Math.round(280 / FRAME_MS);
const EARLY_MIN_SEG = Math.round(2400 / FRAME_MS);
const PREVIEW_FRESH_MS = 600;
const PREROLL = Math.round(320 / FRAME_MS); // keep audio from before onset, or first words clip
/* Floor between in-progress guesses. It is only a floor: each partial costs a
whole transcription, and the work queue runs one job at a time, so partials
naturally pace themselves to whatever this machine can actually sustain
rather than piling up. */
const PARTIAL_EVERY = 350;
/*
* How much recent audio a partial re-transcribes.
*
* Whisper is not incremental — every partial re-runs the audio it is given. Fed
* the whole in-progress sentence, a 15-second one costs about 3 seconds to
* transcribe, so the preview arrives already stale AND delays the finished line
* queued behind it. Six seconds costs roughly 1.2 s and keeps the preview
* moving. Sentences shorter than this — nearly all of them — are unaffected.
*/
const PARTIAL_WINDOW = Math.round(6000 / FRAME_MS);
const PARTIAL_AFTER = Math.round(600 / FRAME_MS); // start previewing as soon as there are words
/*
* How many languages get a TRANSLATED preview.
*
* This used to be measured against the languages that are merely turned on,
* so a church with nine on the front page got NO translated preview at all —
* every non-English listener watched a typing indicator for the whole
* sentence and then received the finished line seconds later. That wait IS
* the latency people complain about; the translation itself was never the
* slow part.
*
* It is now measured against the languages someone is actually LISTENING to,
* most-listened first. Previewing a language nobody has open is work that
* reaches no one.
*
* The number is a time budget, not a taste: NLLB fp32 measured 339ms mean per
* language on this Mac (q8 was 2.5x SLOWER — 832ms — so quantising is not a
* way out). Preview ASR on a 6s window is about 1.2s, so three languages puts
* a preview cycle near 2.2s, which is as fast as this pipeline can refresh
* text without the previews falling behind the speech.
*
* Batched decoding (see mtq) changed that arithmetic. MEASURED through the
* real translation process, one preview-length sentence:
*
* 3 languages 561ms batched vs 1610ms one at a time
* 9 languages 2401ms batched vs 6334ms one at a time
*
* Swept N=1..9 through the real translation process, 3 interleaved reps each,
* output identical to serial at every N (27/27 at nine languages):
*
* N 1 2 3 4 5 6 7 8 9
* serial 358 765 1209 1621 1892 2308 2572 3208 3555 ms
* batch 356 451 519 577 582 698 804 763 954 ms
* gain 1.01 1.70 2.33 2.81 3.25 3.31 3.20 4.20 3.73
*
* Adding a language costs ~75ms, not ~400ms, because the weights are already
* being read — the bandwidth-bound signature again. Nine languages of MT is
* now 954ms, so the preview cycle is bounded by ASR (~1.2s), not by
* translation, and the whole active set fits inside the original 2.2s target.
* The cap stays only so that an operator who pins 100 languages does not turn
* previews into the slow thing.
*/
const PARTIAL_MAX_LANGS = 9;
const $ = (id) => document.getElementById(id);
const state = {
cfg: null,
local: null, // the built-in server: port, key, addresses
ws: null,
wsRetry: 0,
wsTimer: null,
live: false, // the server has told us to translate
source: 'eng_Latn',
targets: [],
listeners: 0, // people reading right now
byLanguage: [], // …and which language each of them chose
listenerDetail: [], // …and which device each one is
voiceDemand: {}, // …and which alternate voice, when they picked one
cidSeq: 0, // our id for a sentence, so its clips can find it
ttsReady: false, // built-in voices available
spokenLanguages: [], // …and which languages this Mac can actually speak
deviceChangeTimer: null,
channels: 0,
glossary: { fixes: [], overrides: {} },
audioCtx: null,
stream: null,
node: null,
// voice detection
noiseFloor: 0.001,
sensitivity: 3.5,
/* dBFS when the operator has pinned the gate; null = follow the room. */
threshDb: null,
rms: 0,
peak: 0, // loudest frame since the last status report
speaking: false,
silence: 0,
dip: 0, // consecutive frames below the ENTRY threshold
carry: [], // audio carried across a forced cut (see 9b)
segment: [],
preroll: [],
// model + work queue
asrWorker: null, // accurate — finished sentences
asrFastWorker: null, // fast — the live preview
modelsReady: false,
busy: false,
lastPartialAt: 0,
lastFinalAt: 0,
lagMs: 0,
/* preview-assisted segmentation (see EARLY_CLOSE) */
previewPunct: false,
previewAt: 0,
/* health counters, reported to the operator page */
degradedFinals: 0, // finals that shipped with fewer languages (stale)
captionOnlyFinals: 0, // finals that shipped with no translations at all
captionOnlyAt: 0, // when the last one happened
degradedAt: 0
};
/*
* THE SHAPE OF A LISTENER ROW. The server sends { code, count } — see
* listenerCounts() in server.js — and this file read { lang, n } in five
* places, so every set built from the rows was EMPTY: no language ever counted
* as being listened to. Mostly harmless, except in the one place it mattered.
* When a sentence arrives late, speech is kept only for the languages someone
* is actually on, and "someone is on" was never true for any of them — so
* under load the app dropped the speech of EVERY language, for everyone,
* instead of only the languages nobody had chosen. Late became silent.
* MEASURED on a campus Mac, 2026-10-15 15:11-15:12: 26 sentences dropped
* across five languages during a stall, every one of them with listeners
* present. Both spellings are read, so a server one build older or newer
* still counts.
*/
function rowLang(r) { return r ? (r.code || r.lang || '') : ''; }
function rowCount(r) { return r ? (Number(r.count != null ? r.count : r.n) || 0) : 0; }
/*
* FOLLOW THE BOTTOM ONLY WHILE THE OPERATOR IS AT THE BOTTOM.
*
* Every one of these boxes used to end its update with
* `box.scrollTop = box.scrollHeight`, unconditionally. While the service is
* running that fires on every sentence, so scrolling back to re-read what was
* just said was impossible: the box yanked itself to the live edge a fraction
* of a second later, every time. Reported from the desk as "can't scroll on
* what's being said when it's live", which is exactly what it was.
*
* A box follows the live edge until the operator scrolls away from it, and
* starts following again when they come back to it. Nothing has to be pressed
* to get out, and nothing has to be pressed to get back in.
*
* The slack matters. A programmatic jump to the bottom also raises a scroll
* event, and it lands AT the bottom, so sticking survives its own scrolling.
* Sub-pixel layout and a growing last line can leave a couple of pixels
* behind, which with a zero threshold would unstick the box on its own output.
*/
/*
* Trimming the TOP of a box the operator is reading moves the text under
* their eyes — the other half of "it keeps jumping". While they are scrolled
* away the cap is suspended, so the lines they are looking at stay put; when
* they return to the live edge the backlog is trimmed in one go. The ceiling
* keeps a box that is left scrolled up all service from growing without end.
*/
function trimTop(box, cap) {
if (!box) return;
const held = box.dataset.stick === '0';
const limit = held ? cap * 6 : cap;
while (box.children.length > limit) box.removeChild(box.firstChild);
}
const STICK_SLACK_PX = 48;
const stickWired = new WeakSet();
function followBottom(box) {
if (!box) return;
if (!stickWired.has(box)) {
stickWired.add(box);
box.dataset.stick = '1';
box.addEventListener('scroll', () => {
const atBottom =
box.scrollHeight - box.scrollTop - box.clientHeight <= STICK_SLACK_PX;
box.dataset.stick = atBottom ? '1' : '0';
}, { passive: true });
}
/* Scrolled away: leave the view exactly where they put it. New content
grows BELOW the viewport, so not touching scrollTop is what holds the
reader's place — there is nothing else to do here. */
if (box.dataset.stick !== '0') box.scrollTop = box.scrollHeight;
}
// ────────────────────────────────────────────────────────── logging
function log(msg, kind) {
/* Mirrored to the console so it reaches stdout (and the launchd log) — the
on-screen log is useless when the app is failing to start. */
if (kind === 'err') console.error(msg); else console.log(msg);
/* And to data/logs/app.log, via main — 200 lines on screen is not a record. */
try { if (window.engineHost && window.engineHost.logLine) window.engineHost.logLine((kind || 'info') + ' ' + msg); } catch (_) {}
const el = $('log');
const line = document.createElement('div');
if (kind) line.className = kind;
line.textContent = new Date().toLocaleTimeString() + ' ' + msg;
el.appendChild(line);
trimTop(el, 200);
followBottom(el);
}
function hero(stateName, title, body) {
$('hero').dataset.state = stateName;
$('heroTitle').textContent = title;
$('heroBody').textContent = body;
}
// ────────────────────────────────────────────────────────── audio
async function listDevices(preselect) {
const devices = await navigator.mediaDevices.enumerateDevices();
const inputs = devices.filter((d) => d.kind === 'audioinput');
const sel = $('device');
sel.textContent = '';
if (!inputs.length) {
const o = document.createElement('option');
o.textContent = 'No audio inputs found';
o.value = '';
sel.appendChild(o);
return [];
}
inputs.forEach((d) => {
const o = document.createElement('option');
o.value = d.deviceId;
o.textContent = d.label || ('Input ' + d.deviceId.slice(0, 8));
sel.appendChild(o);
});
log('inputs: ' + inputs.map((d) => d.label || d.deviceId.slice(0, 8)).join(' | '));
/*
* Find the saved device again. Ids are not stable — CoreAudio issues new ones
* when hardware is re-plugged or the Mac reboots — so the name is the durable
* handle. Chromium also decorates the current default as "Default - X" and
* lists it twice, so compare with that prefix stripped, or a saved
* "BlackHole 2ch" silently loses to whatever happens to be the default.
*/
const strip = (l) => String(l || '').toLowerCase().replace(/^default\s*-\s*/, '').trim();
const wanted = strip(state.cfg.deviceLabel);
const real = inputs.filter((d) => d.deviceId !== 'default' && d.deviceId !== 'communications');
const pool = real.length ? real : inputs;
const byId = preselect && pool.find((d) => d.deviceId === preselect);
const byExact = wanted && pool.find((d) => strip(d.label) === wanted);
const byPartial = wanted && pool.find((d) => strip(d.label).includes(wanted));
const chosen = byId || byExact || byPartial || pool[0];
sel.value = chosen.deviceId;
if (wanted && !byId && !byExact && !byPartial) {
log('saved input "' + state.cfg.deviceLabel + '" is not connected — using "' + chosen.label + '"', 'err');
}
return inputs;
}
async function startCapture(deviceId) {
await stopCapture();
const constraints = {
audio: {
deviceId: deviceId ? { exact: deviceId } : undefined,
/* Every one of these would wreck a line-level feed from a console.
They exist for phone calls, not for a preacher on a wireless mic. */
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
/* No channelCount constraint: a Dante or SoundGrid device presents all
its channels and we choose one below. Forcing mono here would let the
browser downmix 64 channels and bury the voice. */
}
};
state.stream = await navigator.mediaDevices.getUserMedia(constraints);
const track = state.stream.getAudioTracks()[0];
const label = (track && track.label) || 'Unknown input';
/* Running the graph at 16 kHz means the browser resamples once, on the audio
thread, and Whisper gets exactly the rate it wants. */
state.audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE, latencyHint: 'interactive' });
if (state.audioCtx.state === 'suspended') await state.audioCtx.resume();
await state.audioCtx.audioWorklet.addModule('./capture-worklet.js');
const src = state.audioCtx.createMediaStreamSource(state.stream);
state.node = new AudioWorkletNode(state.audioCtx, 'capture', {
numberOfInputs: 1,
numberOfOutputs: 0,
processorOptions: {
frameSize: FRAME,
channels: pickedChannels()
}
});
state.node.port.onmessage = (e) => {
const d = e.data;
/* Frames are a bare Float32Array, so a named property means this is news
about the device rather than audio. */
if (d && d.channels !== undefined) { onChannelCount(d.channels); return; }
if (d && d.levels !== undefined) { onChannelLevels(d.levels); return; }
onFrame(d);
};
src.connect(state.node);
state.cfg = await window.engineHost.setConfig({ deviceId: deviceId, deviceLabel: label });
log('listening on "' + label + '" at ' + state.audioCtx.sampleRate + ' Hz', 'ok');
return label;
}
/*
* ───────────────────────── which channels carry the voice ─────────────────
*
* Dante and SoundGrid present every channel the device has — 16, 32, 64 — and
* the preacher is on one of them. Mixing all of them buries the voice under
* dozens of silent channels and makes a working feed look like silence.
*
* Selection is a SET, not one index: usually one person is speaking, but two
* people on separate channels should be able to share a service. Picked
* channels are averaged.
*
* Every channel shows a live level whether or not it is picked, because that
* is the only way to find the right one — a list of sixty-four identical names
* is not a choice anyone can make, and the alternative feedback is "the whole
* app appears broken".
*/
function pickedChannels() {
const cfg = state.cfg || {};
if (Array.isArray(cfg.audioChannels)) {
const out = cfg.audioChannels.map(Number).filter((n) => Number.isInteger(n) && n >= 0);
return out.length ? out : null; // empty set means mix all
}
/* Migration from the single-channel setting: -1 meant mix everything. */
if (typeof cfg.audioChannel === 'number') {
return cfg.audioChannel >= 0 ? [cfg.audioChannel] : null;
}
return null;
}
async function setChannels(list) {
const clean = (list || []).map(Number).filter((n) => Number.isInteger(n) && n >= 0)
.sort((a, b) => a - b);
state.cfg = await window.engineHost.setConfig({ audioChannels: clean });
/* Applied by MESSAGE, not by rebuilding the capture graph — so this works
mid-service, which is exactly when you find out you are on the wrong
channel. */
if (state.node) {
try { state.node.port.postMessage({ t: 'channels', channels: clean }); } catch (_) {}
}
renderChannels();
log(clean.length
? 'listening on channel' + (clean.length > 1 ? 's ' : ' ') + clean.map((c) => c + 1).join(', ')
: 'mixing all channels', 'ok');
}
function onChannelCount(chans) {
if (state.channels === chans) return;
state.channels = chans;
log((state.cfg.deviceLabel || 'input') + ': ' + chans + ' channel' + (chans === 1 ? '' : 's'));
renderChannels();
renderInputTruth();
}
function onChannelLevels(rms) {
const list = $('chanList');
if (!list || !list.children.length) return;
for (const row of list.children) {
const i = Number(row.dataset.ch);
const fill = row.firstChild && row.lastChild && row.lastChild.firstChild;
if (!fill) continue;
/* Same curve as the main meter, so a channel that looks busy here looks
busy there too. */
const v = Math.min(1, Math.sqrt((rms[i] || 0) * 12));
fill.style.width = (v * 100).toFixed(1) + '%';
fill.classList.toggle('hot', (rms[i] || 0) > 0.004);
}
}
function renderChannels() {
const box = $('channelBox');
const list = $('chanList');
if (!box || !list) return;
const chans = state.channels || 0;
box.hidden = chans < 1;
if (chans < 1) return;
const picked = pickedChannels();
const set = new Set(picked || []);
$('chanNote').textContent = picked
? (picked.length === 1 ? 'channel ' + (picked[0] + 1)
: picked.length + ' channels mixed')
: 'mixing all ' + chans;
$('chanMixAll').disabled = !picked;
/* Rebuild only when the count changes — these rows carry live meters, and
replacing them on every tick would reset every bar to zero. */
if (list.children.length !== chans) {
list.textContent = '';
for (let i = 0; i < chans; i++) {
const row = document.createElement('button');
row.type = 'button';
row.className = 'chanrow';
row.dataset.ch = String(i);
const n = document.createElement('b');
n.textContent = String(i + 1);
const bar = document.createElement('span');
bar.className = 'chanbar';
const fill = document.createElement('i');
fill.className = 'chanbar-fill';
bar.appendChild(fill);
row.appendChild(n);
row.appendChild(bar);
row.addEventListener('click', () => {
const cur = new Set(pickedChannels() || []);
if (cur.has(i)) cur.delete(i); else cur.add(i);
setChannels(Array.from(cur));
});
list.appendChild(row);
}
}
for (const row of list.children) {
row.classList.toggle('on', set.has(Number(row.dataset.ch)));
}
renderSummaries();
}
async function stopCapture() {
if (state.node) { try { state.node.port.onmessage = null; state.node.disconnect(); } catch (_) {} state.node = null; }
if (state.audioCtx) { try { await state.audioCtx.close(); } catch (_) {} state.audioCtx = null; }
if (state.stream) { state.stream.getTracks().forEach((t) => { try { t.stop(); } catch (_) {} }); state.stream = null; }
state.speaking = false;
state.segment = [];
state.preroll = [];
state.silence = 0;
}
// ────────────────────────────────────────────────────────── voice detection
/*
* Deliberately simple: an adaptive noise floor with a hangover, rather than a
* neural VAD. The input here is a mixer's direct out of one microphone, not a
* noisy room, so the energy contrast is large and dependable — and one fewer
* model is one fewer thing to fail on a Sunday.
*/
function onFrame(frame) {
let sum = 0, peak = 0;
for (let i = 0; i < frame.length; i++) {
const v = frame[i];
sum += v * v;
const a = v < 0 ? -v : v;
if (a > peak) peak = a;
}
const rms = Math.sqrt(sum / frame.length);
state.rms = rms;
/* Hold the peak between status reports. Sampling the instantaneous level
almost always lands in the gap between syllables and reads as silence,
which makes a perfectly good feed look dead from the operator page. */
if (rms > state.peak) state.peak = rms;
/*
* Track the quiet floor quickly downward, and upward ONLY in silence.
*
* The floor used to creep upward on every frame INCLUDING during speech —
* a two-minute time constant, which over a long loud passage dragged the
* threshold (floor × sensitivity) above quiet speech. The mic looked dead
* while the meter clearly moved: sentences silently vanished. That was the
* "randomly skipping words" report.
*
* The vestigial in-speech drift (10× slower) is deliberate, not a leftover:
* with a total freeze, band bleed or walk-in music that latches "speaking"
* would pin the floor low forever, and the old slow creep was the only
* self-healing path out. Deafness now takes hours; music unlatches in
* minutes.
*/
if (rms < state.noiseFloor) state.noiseFloor = rms * 0.25 + state.noiseFloor * 0.75;
else if (!state.speaking) state.noiseFloor = state.noiseFloor * 0.9995 + rms * 0.0005;
else state.noiseFloor = state.noiseFloor * 0.99995 + rms * 0.00005;
/*
* AUTOMATIC unless the operator has pinned it.
*
* The automatic gate tracks the room, which is right on a clean digital feed
* and wrong on a noisy analogue one: a preamp with real hiss teaches the
* floor a high value, floor x sensitivity lands above a quiet talker, and the
* mic reads as dead while the meter plainly moves. A pinned gate is an
* absolute level that ignores the floor entirely, so what the operator sees
* on the meter is exactly what decides.
*/
const threshold = state.threshDb === null
? Math.max(state.noiseFloor * state.sensitivity, 0.0012)
: dbToAmp(state.threshDb);
/*
* Two thresholds, one decision each:
* - ENTRY (full threshold): does speech begin / is this a breath-dip a
* soft cut may use? Unchanged behavior.
* - EXIT (0.65×): has speech ENDED? Trailing words are quieter than
* openings, and gating them at the entry level clipped the ends of
* sentences — the other half of the skipped-words report.
*/
const isSpeech = rms > threshold;
const isSpeechExit = rms > threshold * 0.65;
/*
* Two facts the deaf-watchdog needs, recorded where they already exist.
* `loudAt` is deliberately an ABSOLUTE level and not a function of the gate:
* the whole point is to notice when the gate is wrong, so it cannot be the
* thing that decides whether the room is loud. -45 dBFS is quiet speech.
*/
state.threshold = threshold;
if (rms > 0.0056) state.loudAt = Date.now();
drawMeter(rms, peak, threshold, state.speaking ? isSpeechExit : isSpeech);
/*
* The room-mic feed is a secondary feature for hearing-aid users. It must
* never be able to take the captions down with it, which is exactly what it
* did: one exception in here escaped through onFrame and every frame after
* it was lost. Contained, and reset so a bad state cannot repeat forever.
*/
if (!state.live) { safeFeedMic(frame); return; }
safeFeedMic(frame);
if (state.speaking ? isSpeechExit : isSpeech) {
if (!state.speaking) {
state.speaking = true;
/* Start with the carried tail of a forced cut (so its boundary word
survives), else with the pre-roll (so the first syllable does). */
if (state.carry.length) {
state.segment = state.carry.slice();
state.carriedCount = state.carry.length;
state.carry = [];
} else {
state.segment = state.preroll.slice();
state.carriedCount = 0;
}
state.preroll = [];
}
state.silence = 0;
state.dip = 0;
state.segment.push(frame);
if (state.segment.length >= HARD_CUT_MAX) {
finishSegment('length');
} else if (state.cfg.partialsEnabled &&
state.segment.length >= PARTIAL_AFTER &&
Date.now() - state.lastPartialAt > PARTIAL_EVERY) {
state.lastPartialAt = Date.now();
queuePartial();
}
} else if (state.speaking) {
/* Keep collecting through short gaps — a pause between clauses is not the
end of a sentence. Below the EXIT threshold counts toward ending the
sentence; below the ENTRY threshold counts as a breath a soft cut may
use (a quiet trailing word sits between the two and counts as neither
an end nor a breath — which is exactly the protection). */
state.segment.push(frame);
state.silence++;
if (!isSpeech) state.dip++; else state.dip = 0;
const len = state.segment.length;
const previewSaysDone = state.previewPunct &&
(Date.now() - state.previewAt) < PREVIEW_FRESH_MS;
if (state.silence >= SILENCE_TO_END) {
finishSegment('silence');
} else if (len >= SOFT_CUT && state.dip >= SOFT_DIP) {
/* Long segment + a breath = good enough. */
finishSegment(len >= HARD_CUT ? 'hard' : 'soft');
} else if (previewSaysDone && len >= EARLY_MIN_SEG && state.silence >= EARLY_CLOSE) {
finishSegment('early');
} else if (previewSaysDone && len >= SOFT_CUT) {
/*
* A finished sentence and a long segment, with NO silence requirement.
* Every other branch here waits for the speaker to stop making noise,
* which is precisely what a fast preacher never does — so they fell
* through to the ceiling every time. Whisper has already told us the
* sentence ended; waiting for a breath to confirm it just adds delay.
* Guarded by SOFT_CUT so a mid-clause period Whisper invented early in a
* segment still cannot chop it.
*/
finishSegment('sentence');
}
} else {
state.preroll.push(frame);
while (state.preroll.length > PREROLL) state.preroll.shift();
}
}
/*
* The English feed: the actual room microphone, passed through.
*
* For hearing-aid and hard-of-hearing listeners, the most faithful "English
* voice" is not a synthesizer reading the transcript — it is the pastor's own
* microphone. When anyone is listening to the source language with speech on,
* the server asks for this feed (micWanted) and every captured frame — speech,
* pause, all of it — is gained, chunked and shipped; the server splices it
* into English's live stream exactly like a voice clip.
*
* The chunk is EXACTLY 11200 samples (0.700s @16k): that length encodes to a
* whole number of AAC frames with zero tail padding, which is what keeps a
* continuous splice from drifting (the residual +64 samples/chunk of encoder
* priming is settled by a debt counter on the server side).
*
* Gain: a slow AGC toward a 0.5 RMS-peak target with a hard clamp — the raw
* console send is far quieter than the synthesized voices, and the audience
* for this feature is precisely the people most bothered by level jumps.
*/
const MIC_CHUNK = 11200;
let micWanted = false;
let micBuf = [];
let micLen = 0;
let micGain = 4;
function micReset() { micBuf = []; micLen = 0; }
function safeFeedMic(frame) {
try { feedMic(frame); }
catch (e) {
micReset();
log('room-mic feed reset after an error (captions unaffected): ' + e.message, 'err');
}
}
function feedMic(frame) {
/*
* Two consumers, gated separately.
*
* Phones only want this when someone is actually listening in the source
* language with speech on. HARDWARE wants it whenever the source language
* has output channels assigned — a Dante feed to an assistive-listening
* transmitter has no way to tell us it is there, and gating it on phone
* listeners would leave those channels silent all service.
*/
const toPhones = micWanted && state.ws && state.ws.readyState === 1;
const toHardware = !!(window.OutRouting && window.OutRouting.routed(state.source));
if (!state.live || (!toPhones && !toHardware)) {
if (micLen) micReset();
return;
}
/*
* HARDWARE GOES OUT PER FRAME, not per chunk.
*
* The 700 ms MIC_CHUNK exists to keep the WebSocket from sending fifteen
* tiny messages a second to phones, and for a phone that latency is
* invisible — the listener is reading captions, not watching lips.
*
* A body-pack receiver is the opposite case: that listener is sitting in the
* room looking straight at the preacher. Waiting for the chunk put the feed
* 780 ms behind, and ITU BT.1359 puts the ACCEPTABILITY threshold for late
* audio at 185 ms and detectability at 125 ms — so the assistive-listening
* feed would have been four times past objectionable.
*
* A frame is 64 ms, so this lands around 144 ms including the scheduler's
* lead: under the threshold, and low enough to watch someone speak.
*/
if (toHardware) {
const out = new Float32Array(frame.length);
for (let i = 0; i < frame.length; i++) {
let v = frame[i] * micGain;
if (v > 0.98) v = 0.98; else if (v < -0.98) v = -0.98;
out[i] = v;
}
window.OutRouting.playPcm(state.source, out, SAMPLE_RATE);
}
/* The phone path still batches; nothing below it changed. */
if (!toPhones) { if (micLen) micReset(); return; }
micBuf.push(frame);
micLen += frame.length;
if (micLen < MIC_CHUNK) return;
const all = flatten(micBuf);
const chunk = all.subarray(0, MIC_CHUNK);
const rest = all.subarray(MIC_CHUNK);
micBuf = rest.length ? [rest.slice()] : [];
micLen = rest.length;
/* AGC: adapt toward the target on the chunk's RMS, rise slowly (0.5 dB per
chunk), fall fast on loud input, clamp the waveform regardless. */
let sum = 0;
for (let i = 0; i < chunk.length; i += 2) sum += chunk[i] * chunk[i];
const rms = Math.sqrt(sum / (chunk.length / 2));
if (rms > 0.0005) {
const ideal = 0.18 / rms;
micGain = ideal < micGain ? Math.max(1, ideal) : Math.min(24, micGain * 1.06, ideal);
}
/* Same gain the hardware feed just used, so the two hear one room rather
than two independently-tuned versions of it. */
const pcm = new Int16Array(MIC_CHUNK);
for (let i = 0; i < MIC_CHUNK; i++) {
let v = chunk[i] * micGain;
if (v > 0.98) v = 0.98; else if (v < -0.98) v = -0.98;
pcm[i] = (v * 32767) | 0;
}
/* btoa over 22KB via one .apply risks the call-stack limit — encode in
slices. */
const bytes = new Uint8Array(pcm.buffer);
let bin = '';
for (let i = 0; i < bytes.length; i += 8192) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
}
send({ t: 'mic', pcm: btoa(bin) });
}
/* ~450ms carried across a forced cut. Whisper reliably loses the word that
straddles a mid-speech cut — on BOTH sides — and a 'length' cut used to lose
even the preroll, because speech never stopped so nothing refilled it. */
const CARRY = Math.round(450 / FRAME_MS);
function finishSegment(reason) {
const seg = state.segment;
const carried = state.carriedCount || 0;
state.speaking = false;
state.segment = [];
state.preroll = [];
state.silence = 0;
state.dip = 0;
state.carriedCount = 0;
if (reason === 'silence' || reason === 'early') {
state.carry = [];
} else {
/* A forced cut mid-speech: the next segment reopens with this tail so the
boundary word exists whole in at least one chunk. The duplicate-word
risk is smaller than the guaranteed loss. */
state.carry = seg.slice(-CARRY);
}
/* Carried audio must not let a sliver masquerade as speech. */
if (seg.length - carried < MIN_SPEECH) return;
queueFinal(seg, reason);
}
/*
* Concatenate frames. The size comes from the frames THEMSELVES, not from
* assuming every one is FRAME samples long.
*
* It used to allocate `frames.length * FRAME`, which is wrong in both
* directions once a list contains anything but full frames — and the room-mic
* path feeds its own remainder back in, so it always does:
*
* • UNDER-allocated, and it threw. feedMic carries `all.subarray(MIC_CHUNK)`
* into the next cycle, and MIC_CHUNK (11200) is not a multiple of FRAME
* (1024), so that remainder grows past one frame by the third cycle:
* 11 frames = 11264 allocated, 11328 actual -> "RangeError: offset is out
* of bounds". That exception came out of onFrame, so it took the CAPTIONS
* down with it — every frame after it was lost and nothing translated,
* while the meter and the hero both looked perfectly healthy. It needed a
* source-language listener with speech ON to fire at all, which is why it
* sat here unnoticed.
*
* • OVER-allocated the rest of the time, which is quieter but not harmless:
* the tail was zero padding, and feedMic counted it as audio and carried
* it forward, injecting ~60 ms of phantom silence into every 700 ms of the
* hearing-aid feed.
*/
function flatten(frames) {
let total = 0;
for (const f of frames) total += f.length;
const out = new Float32Array(total);
let o = 0;
for (const f of frames) { out.set(f, o); o += f.length; }
return out;
}
/* dBFS -> linear amplitude, the inverse of what the meter draws. */
function dbToAmp(db) { return Math.pow(10, Number(db) / 20); }
/*
* The floor the SILENCE heuristics measure against: always the MEASURED room
* tone, never the pinned gate.
*
* This briefly followed the pinned gate, on the reasoning that an operator who
* sets the gate has answered "was there anything there" themselves. That is
* wrong, and the way it is wrong matters: pin the gate BELOW the room and the
* guard's reference sinks with it, so the one check standing between room tone
* and the transcriber stops firing at exactly the moment it is needed. These
* checks ask what the room is actually doing, and the learned floor is the only
* thing that knows.
*/
function gateFloor() {
return state.noiseFloor;
}
function drawMeter(rms, peak, threshold, isSpeech) {
/* A log scale, because a linear one makes speech look like nothing. */
const db = 20 * Math.log10(Math.max(rms, 1e-6));
const pct = Math.max(0, Math.min(100, ((db + 60) / 60) * 100));
const tdb = 20 * Math.log10(Math.max(threshold, 1e-6));
const tpct = Math.max(0, Math.min(100, ((tdb + 60) / 60) * 100));
const fill = $('meterFill');
fill.style.width = pct + '%';
fill.className = 'meter-fill' + (peak > 0.99 ? ' clip' : isSpeech ? ' speech' : peak > 0.7 ? ' hot' : '');
$('meterThresh').style.left = tpct + '%';
$('meterLabel').textContent = rms < 1e-5 ? 'silent' : Math.round(db) + ' dB';
}
// ────────────────────────────────────────────────────────── models
function resolveDtype(value, fallback) {
if (!value || value === 'auto') return fallback;
if (typeof value === 'string' && value.trim().startsWith('{')) {
try { return JSON.parse(value); } catch (_) { return fallback; }
}
return value;
}
/* ---- talking to the model workers ---------------------------------------
A worker answers each 'run' with a matching id. Anything unmatched is
dropped rather than thrown, so a late reply from a worker we have already
given up on cannot resurrect a stale sentence. */
const rpc = { seq: 0, pending: new Map() };
function handleWorkerReply(m) {
if (!m || (m.type !== 'result' && m.type !== 'error')) return;
const waiting = rpc.pending.get(m.id);
if (!waiting) return;
rpc.pending.delete(m.id);
if (m.type === 'result') waiting.resolve(m.out);
else waiting.reject(new Error(m.message));
}
/*
* A transcription that never returns used to wedge the pipeline forever: the
* finals queue waited on state.busy, and a hung PREVIEW wedged partialBusy so
* previews silently died for the rest of the service. 30s is far beyond any
* legitimate run (a 9s chunk transcribes in ~2s), so a timeout means the WASM
* side is gone — throw the worker away and start a fresh one.
*/
const WORKER_TIMEOUT_MS = 30000;
function callWorker(worker, input, options, transfer) {
return new Promise((resolve, reject) => {
const id = ++rpc.seq;
const timer = setTimeout(() => {
if (!rpc.pending.has(id)) return;
rpc.pending.delete(id);
reject(new Error('model timed out'));
respawnWorker(worker);
}, WORKER_TIMEOUT_MS);
rpc.pending.set(id, {
resolve: (v) => { clearTimeout(timer); resolve(v); },
reject: (err) => { clearTimeout(timer); reject(err); }
});
worker.postMessage({ type: 'run', id, input, options }, transfer || []);
});
}
let respawning = false;
async function respawnWorker(worker) {
if (respawning) return;
respawning = true;
const isFast = worker === state.asrFastWorker;
try { worker.terminate(); } catch (_) {}
log((isFast ? 'preview' : 'speech') + ' model hung — restarting it', 'err');
try {
const cfg = state.cfg;
if (isFast) {
state.asrFastWorker = null;
state.asrFastWorker = await loadWorkerWithFallback('preview', 'automatic-speech-recognition',
cfg.asrFastModel, resolveDtype(cfg.asrFastDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' }),
{ encoder_model: 'q4', decoder_model_merged: 'q4' });
log('preview model restarted', 'ok');
} else {
/*
* STEP DOWN, do not restart the same thing.
*
* This used to reload the identical model — which on hardware that
* cannot run it hung again, and again, forever. MEASURED on the
* church's Intel laptop: two hangs in thirty seconds, each followed by
* a restart of the same model. A model that hung once on this machine
* will hang again on the next long sentence.
*/
const lighter = lighterAsr(state.asrModelInUse || cfg.asrModel);
if (lighter) {
state.asrModelInUse = lighter;
log('the speech model is too heavy for this machine — switching to ' +
lighter.split('/').pop(), 'err');
}
/*
* A LIGHTER MODEL GETS THE PREVIEW'S DTYPE, NOT THE BIG ONE'S.
*
* asrDtype is chosen for the accurate model and its weights are cached against
* it. Reusing it when stepping down asks for a variant that was never
* downloaded — and those weights live in the browser cache, with no network at
* a venue — so the step-down failed and the ladder could not move at all.
*
* MEASURED on a campus Mac: the cache held whisper-large-v3-turbo as
* encoder_model_q4f16.onnx (its asrDtype) and whisper-small as
* encoder_model_fp16.onnx (the preview's). Stepping down asked for
* whisper-small at q4f16, a file that is not there; it went to the network,
* failed, and logged "Unsupported model type: whisper" three times a minute
* for ever while the service sat dead.
*
* The preview runs whisper-small on exactly these settings every service, so
* they are the one combination this machine is known to be able to load.
*/
const stepDtype = lighter
? resolveDtype(cfg.asrFastDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' })
: resolveDtype(cfg.asrDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' });
state.asrWorker = await loadWorkerWithFallback('speech', 'automatic-speech-recognition',
state.asrModelInUse || cfg.asrModel,
stepDtype,
{ encoder_model: 'q4', decoder_model_merged: 'q4' });
log('speech model restarted' + (lighter ? ' on ' + lighter.split('/').pop() : ''), 'ok');
state.slowRun = 0;
if (lighter) {
/*
* THROW AWAY THE BACKLOG THAT CAUSED THIS, AND LET THE NEW MODEL SETTLE.
*
* Swapping the model does nothing about the queue that triggered the swap.
* Those chunks are already past the caption-only cutoff, so they can no
* longer be translated in time and are worth nothing to the room — but the
* first of them gets handed to a model that has just loaded, takes the full
* worker timeout, and trips the absolute rule. MEASURED: a campus Mac fell
* large-v3-turbo -> small -> base in 34 seconds exactly that way, landing on
* a model SLOWER than the one it started from.
*
* So drop them, and refuse to step down again for a grace window, so the new
* model is judged on sentences it was actually given.
*/
const dropped = queue.length;
queue.length = 0;
state.steppedDownAt = Date.now();
if (dropped) log('dropped ' + dropped + ' stale sentence' + (dropped === 1 ? '' : 's') +
' the old model never reached', 'warn');
}
}
} catch (err) {
log('model restart failed: ' + err.message, 'err');
} finally {
respawning = false;
}
}
/* One progress bar across what is really dozens of file downloads, because
"shard 37 of 52" means nothing to the person setting this up. */
const progressFiles = new Map();
/*
* Which of the three models is loading, and is it coming off the disk or the
* network. Set per stage from the FILESYSTEM (engineHost.modelsCached), never
* from the library's own progress events — MEASURED: a fully cached NLLB emits
* `download` for every file and 268 `progress` events, so "bytes are moving"
* is true on every single launch. That is why an app with 3.4 GB already on
* disk spent every startup insisting it was downloading.
*/
let stage = { n: 0, of: 3, downloading: false };
/*
* Worse, the files that report a size are not the files that take the time.
* MEASURED on the cached NLLB: tokenizer.json (16.5 MB) sends 265 progress
* events, while encoder_model.onnx and decoder_model_merged.onnx — 3.2 GB
* between them, and essentially all of the wait — send NONE and no total at
* all. A byte bar built from that describes half a percent of the work and
* then sits at "0 MB of about 17 MB" for the rest of the load, which is the
* exact thing that looked stuck.
*/
function onModelProgress(p) {
if (p.status === 'progress' && p.file) {
progressFiles.set(p.file, { loaded: p.loaded || 0, total: p.total || 0 });
} else if (p.status === 'done' && p.file) {
const f = progressFiles.get(p.file);
if (f) f.loaded = f.total;
}
let loaded = 0, total = 0;
progressFiles.forEach((f) => { loaded += f.loaded; total += f.total; });
/* Hugging Face omits content-length on some files, so the total is a floor,
not a promise. Better an approximate bar than a frozen one. */
$('progWrap').hidden = false;
$('progNote').hidden = false;
if (stage.downloading && total > 0) {
/* A real first-run fetch: bytes are the honest unit, even though the
total is a floor (HF omits content-length on the big files). */
$('progFill').style.width = Math.min(100, (loaded / total) * 100) + '%';
$('progNote').textContent = 'Model ' + stage.n + ' of ' + stage.of + ' — ' +
(loaded / 1048576).toFixed(0) + ' MB of at least ' + (total / 1048576).toFixed(0) + ' MB';
} else {
/* Off the disk. There is no meaningful byte total to show, so show the
one thing that is true and moving: which model of how many. */
$('progFill').style.width = Math.round((stage.n - 1) / stage.of * 100) + '%';
$('progNote').textContent = 'Model ' + stage.n + ' of ' + stage.of;
}
}
/* Announce a stage, having first asked the disk whether this is a download. */
async function beginStage(n, model, name, note) {
progressFiles.clear();
let cached = false;
try { cached = await window.engineHost.modelsCached(model); } catch (_) {}
stage = { n: n, of: 3, downloading: !cached };
hero('loading',
(cached ? 'Loading ' : 'Downloading ') + name,
cached ? (note || '') : 'First run only — this one is fetched once and kept.');
log((cached ? 'loading ' : 'DOWNLOADING ') + name + ': ' + model);
}
let preloadedVoices = new Set();
function preloadNewVoices() {
const want = state.targets.filter((l) =>
state.spokenLanguages.indexOf(l) !== -1 && !preloadedVoices.has(l));
if (!want.length) return;
/* One at a time in the child anyway; cap the request so a burst of joins
cannot queue up a re-download marathon.
Only what is SENT counts as warmed. Every wanted language used to be
marked and only the first four sent, so a fifth was never preloaded and
built its engine on its first sentence instead — por_PT, at all five
starts on the Mini on 2026-10-15. Six is the voice process's own cap. */
const batch = want.slice(0, 6);
batch.forEach((l) => preloadedVoices.add(l));
window.engineHost.ttsPreload(batch);
}
/* The translation model reports its download from the main process. */
if (window.engineHost && window.engineHost.onTtsReady) {
window.engineHost.onTtsReady((info) => {
state.ttsReady = true;
state.spokenLanguages = info.spoken || [];
state.spokenDefaults = info.defaults || state.spokenDefaults || {};
log('built-in voices ready — ' + state.spokenLanguages.length + ' languages', 'ok');
/* Warm whatever people are already reading, so the first sentence of a
service is not the one that pays for the download. A voice process
that has just (re)started holds no engines, whatever this page warmed
in the last one — so forget that first. */
preloadedVoices.clear();
preloadNewVoices();
});
window.engineHost.onTtsProgress((p) => log('voice ' + p.bundle + ': ' + p.status));
}
if (window.engineHost && window.engineHost.onMtProgress) {
window.engineHost.onMtProgress((p) => onModelProgress(p));
}
/* The translation process is supervised and restartable, but the operator
should know it happened rather than wondering why one service was quieter. */
if (window.engineHost && window.engineHost.onMtDown) {
window.engineHost.onMtDown((info) => {
state.modelsReady = false;
if (info && info.giveUp) {
log('translation failed repeatedly — giving up', 'err');
hero('error', 'Translation is not running',
'The translation process failed to start. Captions in the original language still work.');
return;
}
log('the translation process stopped — restarting it', 'err');
/* Back off before retrying, so a failing model does not spin. */
setTimeout(function () {
window.engineHost.mtEnsure()
.then(() => { state.modelsReady = true; log('translation restarted', 'ok'); updateIdleHero(); })
.catch((e) => log('restart failed: ' + e.message, 'err'));
}, 2000 * ((info && info.attempt) || 1));
});
}
function loadWorker(label, task, model, dtype) {
return new Promise((resolve, reject) => {
let worker;
try {
worker = new Worker(new URL('./model-worker.js', import.meta.url), { type: 'module' });
} catch (e) { reject(e); return; }
worker.onerror = (e) => reject(new Error(label + ' worker failed: ' + (e.message || 'unknown')));
worker.onmessage = (e) => {
const m = e.data;
if (m.type === 'gpu') {
/* Kept, not just logged: whether this GPU has fp16 decides which
speech model this machine can actually keep up with. */
state.gpuFeatures = String(m.features || '');
log('GPU features (' + m.label + '): ' + m.features);
return;
}
if (m.type === 'progress') { onModelProgress(m.p); return; }
if (m.type === 'loadError') { reject(new Error(m.message)); return; }
if (m.type === 'ready') {
/* WASM means this machine has no usable WebGPU — several times slower,
and the operator should know why before they wonder about the lag. */
if (m.device === 'wasm') log(m.label + ': no GPU acceleration available — running on CPU (slower)', 'err');
worker.onmessage = (ev) => handleWorkerReply(ev.data);
resolve(worker);
return;
}
handleWorkerReply(m);
};
worker.postMessage({ type: 'load', label, task, model, dtype });
});
}
/*
* Try the good weights, fall back to weights that fit this GPU.
*
* A worker whose load failed is thrown away and a fresh one started, which
* matters: a failed ONNX load leaves its WASM instance unusable, so retrying
* inside the same worker would fail identically no matter what we asked for.
*/
/*
* WHAT THIS MACHINE CAN ACTUALLY KEEP UP WITH.
*
* whisper-large-v3-turbo is the right model on Apple Silicon, where fp16 runs
* natively. MEASURED on an Intel laptop with integrated graphics and no fp16
* (the church's own, 2026-09-17): every sentence took 12-25 SECONDS to
* transcribe, then the model hung outright twice and the watchdog restarted
* the SAME model — which hung again. Captions never had a chance.
*
* shader-f16 is the signal. Without it transformers.js drops to integer
* weights, and the big model at integer precision on an iGPU is far slower
* than real time. The small model is what the PREVIEW path already runs
* happily on that same machine, so it is the honest choice for the accurate
* path too: a slightly less accurate caption that arrives beats a perfect one
* that times out.
*/
const ASR_LADDER = ['onnx-community/whisper-large-v3-turbo',
'onnx-community/whisper-small',
'onnx-community/whisper-base'];
function hasF16() {
return /shader-f16|(^|,)f16(,|$)/i.test(state.gpuFeatures || '');
}
/* One step down the ladder, or null at the bottom. */
function lighterAsr(model) {
const i = ASR_LADDER.indexOf(model);
if (i === -1) return ASR_LADDER[1]; // an unknown model: try the small one
return ASR_LADDER[i + 1] || null;
}
async function loadWorkerWithFallback(label, task, model, primary, fallback) {
try {
return await loadWorker(label, task, model, primary);
} catch (e) {
const noF16 = /f16|fp16/i.test(e.message || '');
if (!noF16) throw e;
log(label + ': this GPU has no fp16 — falling back to integer weights', 'err');
progressFiles.clear();
return await loadWorker(label, task, model, fallback);
}
}
async function loadModels() {
const cfg = state.cfg;
/*
"Loading" or "Downloading" is decided per model by asking the disk, not by
watching progress events — see beginStage/onModelProgress. After the first
run everything is local and the wait is building GPU sessions out of 3.4 GB
of weights, which is not a download and must not say it is.
*/
/* The small one first: it is a tenth the size, so the live preview starts
working long before the accurate model is ready. */
await beginStage(1, cfg.asrFastModel, 'the preview speech model', 'The quick one, so captions start early.');
try {
state.asrFastWorker = await loadWorkerWithFallback('preview', 'automatic-speech-recognition', cfg.asrFastModel,
resolveDtype(cfg.asrFastDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' }),
{ encoder_model: 'q4', decoder_model_merged: 'q4' });
log('fast preview model ready', 'ok');
} catch (e) {
/* The preview is a luxury. Losing it must not cost the service its
captions — fall back to previewing with the accurate model. */
log('fast preview model unavailable (' + e.message + ') — previews will use the accurate model', 'err');
state.asrFastWorker = null;
}
/*
* The preview model has loaded by now, so state.gpuFeatures is populated and
* we know what this GPU can do BEFORE committing to the big model.
*/
let accurate = cfg.asrModel;
if (!hasF16() && accurate === ASR_LADDER[0]) {
accurate = ASR_LADDER[1];
log('this GPU has no fp16 — using the smaller speech model, which it can keep up with', 'err');
log('(the big one transcribes slower than people talk on this hardware)', 'err');
}
state.asrModelInUse = accurate;
await beginStage(2, accurate, 'the speech model',
accurate === ASR_LADDER[0] ? 'The accurate one. This is the big one.'
: 'Sized for this machine\u2019s graphics.');
state.asrWorker = await loadWorkerWithFallback('speech', 'automatic-speech-recognition', accurate,
resolveDtype(cfg.asrDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' }),
{ encoder_model: 'q4', decoder_model_merged: 'q4' });
log('speech model ready' + (accurate === cfg.asrModel ? '' : ' (' + accurate.split('/').pop() + ')'), 'ok');
await beginStage(3, cfg.mtModel, 'the translation model', 'Nearly there.');
/* Loaded by the main process, not a worker — see ensureTranslator() in
main.js for why translation cannot live in the browser sandbox. */
await window.engineHost.mtEnsure();
log('translation model ready', 'ok');
$('progWrap').hidden = true;
$('progNote').hidden = true;
state.modelsReady = true;
refreshCacheInfo();
}
/*
* Whisper invents text when handed near-silence — usually a stock phrase it saw
* a million times in training. Anything this short and this generic did not
* come from the pulpit.
*/
const HALLUCINATIONS = new Set([
'thank you', 'thank you.', 'thanks for watching', 'thanks for watching!',
'you', 'you.', '.', 'bye', 'bye.', 'okay', 'ok', 'so', 'oh',
'thank you for watching', 'please subscribe', 'subtitles by the amara.org community',
'the end', 'music', '[music]', '(music)', 'applause', '[applause]'
]);
function cleanTranscript(raw) {
let text = String(raw || '').replace(/\s+/g, ' ').trim();
if (!text) return '';
/*
* No letter or digit at all is not speech. MEASURED: a 444-dot Whisper
* silence hallucination reached NLLB, which invented "…y el hecho de que el
* gobierno de los Estados Unidos haya hecho un…" and it was SPOKEN to the
* Spanish congregation as the first line of the service.
*/
if (!/[\p{L}\p{N}]/u.test(text)) return '';
/*
* Whisper describes SOUNDS when it cannot find words: "(keyboard clicking)",
* "[BLANK_AUDIO]", "(wind blowing)", "* rustling *". Reported from a laptop
* mic. The whole utterance being wrapped in brackets is the signature — a
* real sentence never is — so this is safe and needs no word list.
*/
if (/^[\s]*[\(\[\*][^)\]\*]*[\)\]\*][\s.]*$/u.test(text)) return '';
/* Trailing dots/ellipses too, or "you..." slips past a set that has "you". */
const bare = text.toLowerCase().replace(/[.…]+$/u, '').replace(/[!?,]/g, '').trim();
if (HALLUCINATIONS.has(bare)) return '';
/* Whisper sometimes loops a phrase when the audio degrades. Two shapes: the
whole utterance said twice, and a groove it cannot get out of. */
const words = text.split(' ');
if (words.length > 6) {
const half = Math.floor(words.length / 2);
const a = words.slice(0, half).join(' ');
const b = words.slice(half, half * 2).join(' ');
if (a && a === b) text = a;
}
text = collapseRepeats(text);
return applyFixes(text);
}
/* Same words ignoring case and punctuation, L at a time, from two places. */
function sameRun(keys, a, b, len) {
for (let c = 0; c < len; c++) if (keys[a + c] !== keys[b + c]) return false;
return true;
}
/*
* A PHRASE REPEATED UNTIL THE TOKEN BUDGET RUNS OUT.
*
* Whisper's failure on room tone is not a wrong word, it is a groove: it finds
* a phrase and says it until generation stops — "I'm gonna be a little bit of
* a little bit of a little bit of…". MEASURED here with the gate pinned below
* the room, and it reached the congregation's screens as a live caption,
* because the PREVIEW path has no silence guard of its own and previews are
* sent to listeners the moment they exist.
*
* The halves test above only catches an utterance doubled EXACTLY. A groove
* with a few real words in front of it, or an odd number of turns, walks
* straight past — which is exactly the shape that got through.
*
* Deliberately timid about single words: "no no no" and "holy holy holy" are
* said from a pulpit and are not faults, so one word must repeat six times
* before it counts and two are kept. A phrase of two or more words repeating
* three times back to back is not speech in any sermon.
*/
function collapseRepeats(text) {
const w = String(text || '').split(' ');
if (w.length < 6) return text;
/* Matched on letters and digits only, so "bit of," and "bit of" are the same
phrase. The ORIGINAL words are what survives. */
const keys = w.map((x) => x.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''));
const out = [];
let i = 0;
while (i < w.length) {
let len = 0, reps = 1;
/* Longest run wins, so "a little bit of" collapses as one phrase rather
than four separate single words. */
for (let L = 1; L <= 8 && i + L * 2 <= w.length; L++) {
let n = 1;
while (i + L * (n + 1) <= w.length && sameRun(keys, i, i + L * n, L)) n++;
if ((L === 1 ? n >= 6 : n >= 3) && n * L > reps * len) { len = L; reps = n; }
}
if (len) {
const keep = len === 1 ? 2 : 1;
for (let c = 0; c < len * keep; c++) out.push(w[i + c]);
i += len * reps;
} else {
out.push(w[i]);
i++;
}
}
return out.join(' ');
}
/*
* Building a matcher for one glossary term.
*
* Three things went wrong with the naive version, all verified:
*
* 1. Wrapping every term in \b meant a term ending in punctuation could never
* match at all — "1 Cor." and "(Selah)" silently did nothing, and those are
* exactly the entries an operator types.
* 2. \b is meaningless in Chinese, Japanese, Korean, Thai — scripts with no
* spaces — so adding it there kills the feature outright. Leaving it off
* everywhere instead corrupts words from the inside: an override of 구원
* turned 구원자 ("saviour") into 救い자.
* 3. Case-insensitive matching makes Bible book names collide with ordinary
* English. "job" became "Job" in "I finally got the job I wanted" — in all
* 103 languages at once. So a term written with a capital is matched
* exactly; an all-lowercase term stays forgiving, which is what you want
* for a misheard phrase like "have a back up".
*/
const SCRIPT_NO_SPACES = '\\u3040-\\u30ff\\u4e00-\\u9fff\\uac00-\\ud7af' +
'\\u0e00-\\u0e7f\\u0e80-\\u0eff\\u1000-\\u109f\\u1780-\\u17ff';
const NO_WORD_BOUNDARIES = new RegExp('[' + SCRIPT_NO_SPACES + ']');
/* What counts as "inside a word" for anchoring: any letter, digit or combining
mark. The mark class matters for Devanagari matras and Vietnamese tone marks —
without it a term would be allowed to anchor inside its own vowel signs. */
const WORDY = '\\p{L}\\p{N}\\p{M}';
const WORDY_START = new RegExp('^[' + WORDY + ']', 'u');
const WORDY_END = new RegExp('[' + WORDY + ']$', 'u');
function termMatcher(from) {
const esc = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const spaced = !NO_WORD_BOUNDARIES.test(from);
let pre = '', post = '';
if (spaced) {
/* Only anchor where an anchor can mean something — an edge that is actually
a word character. Anchoring a term that ends in "." or ")" makes it
unmatchable, which is how "1 Cor." and "(Selah)" silently did nothing. */
/*
* Unicode letter classes, NOT \b. JavaScript's \b is defined over
* [A-Za-z0-9_], so every non-Latin term compiled to a bare unanchored
* substring — verified: an entry for Марк rewrote Маркса ("of Marx") and
* الرب rewrote الربا ("usury"). pt-variants.js hit this same trap and
* documented it; this matcher never got the fix.
*/
pre = WORDY_START.test(from) ? '(?<![' + WORDY + '])' : '';
post = WORDY_END.test(from) ? '(?![' + WORDY + '])' : '';
} else if (from.length <= 1) {
/*
* Chinese, Japanese, Korean and Thai have no spaces, so \b is meaningless
* and plain substring matching eats words from the inside — an override of
* 구원 turned 구원자 ("saviour") into 救い자.
*
* But refusing every glued match is worse: in Chinese EVERYTHING is glued,
* so it blocked the very correction it was added for (哈巴古书 → 哈巴谷书
* sits between 向 and 第).
*
* The floor is now ONE character, not two. At two, the names that matter
* most could never fire — 约翰, 摩西, 彼得, 保罗, 마가 are all two
* characters, and in running text their neighbours are always CJK, so the
* lookaround blocked every one of them silently. A single character is
* still guarded, because one Han character is almost always a fragment.
*
* ACCEPTED COST: a two-character COMMON noun can now match inside a longer
* word again — an override of 구원 will touch 구원자 ("saviour"). The
* remedy is longest-match-first, which this table already does: add 구원자
* as its own entry and it wins. For proper nouns, matching inside a
* compound is what you want anyway (马太 inside 马太福音).
*/
pre = '(?<![' + SCRIPT_NO_SPACES + '])';
post = '(?![' + SCRIPT_NO_SPACES + '])';
}
/* A term written with a capital is matched exactly. Without this, "job"
became "Job" in "I finally got the job I wanted" — in every language. */
/* \p{Lu}, not [A-Z]: É is a capital too, and treating "Éxodo" as lowercase
made it match the ordinary Spanish noun "éxodo". Scripts with no case
(Arabic, Hebrew, Devanagari, CJK) have no \p{Lu} and stay forgiving. */
const flags = (/\p{Lu}/u.test(from) ? 'yu' : 'iyu');
try { return new RegExp(pre + esc + post, flags); } catch (_) { return null; }
}
/*
* One left-to-right pass, longest term first, each match consuming its span.
*
* Running the rules one after another over the whole string lets a later rule
* chew up what an earlier one produced: "first Corinthians" became
* "1 Corinthians" and then a "Corinthians" rule turned that into "1 X". Walking
* the string once and skipping past each replacement makes that impossible.
*/
function applyTable(text, pairs) {
const rules = pairs
.filter((p) => p && p.from)
.sort((a, b) => b.from.length - a.from.length)
.map((p) => ({ re: termMatcher(p.from), to: String(p.to == null ? '' : p.to) }))
.filter((r) => r.re);
if (!rules.length) return text;
let out = '';
let i = 0;
while (i < text.length) {
let hit = null;
for (const rule of rules) {
rule.re.lastIndex = i; // sticky: only matches AT i
const m = rule.re.exec(text);
if (m && m[0].length) { hit = { len: m[0].length, to: rule.to }; break; }
}
if (hit) { out += hit.to; i += hit.len; }
else { out += text[i]; i += 1; }
}
return out;
}
function applyFixes(text) {
const fixes = ((state.glossary && state.glossary.fixes) || [])
.filter((f) => f && f.from);
return applyTable(text, fixes);
}
function applyOverrides(text, lang) {
const o = (state.glossary && state.glossary.overrides) || {};
/* A variant inherits the base language's pins. Months of an operator's
corrections live under spa_Latn; a Spain listener must not lose them. A
pin set on the variant itself still wins. */
const table = o[lang] || o[NLLB_ALIAS[lang]] || null;
if (!table) return text;
return applyTable(text, Object.keys(table).map((from) => ({ from, to: table[from] })));
}
function whisperLang(flores) {
const l = window.Languages && window.Languages.get(flores);
return l ? l.tts.split('-')[0] : 'en';
}
async function transcribe(audio, { fast } = {}) {
/* Previews go to the small model, finished sentences to the accurate one.
The buffer is handed to the worker rather than copied — flatten() builds a
fresh array for every job, so detaching it here costs nothing. */
const worker = (fast && state.asrFastWorker) || state.asrWorker;
const opts = {
language: whisperLang(state.source),
task: 'transcribe',
return_timestamps: false
};
/* Cap generation on previews. Handed mid-sentence audio a model can decide to
hallucinate and keep going: one preview ran away to 10 seconds of invented
text, and because the queue runs one job at a time it blocked every other
preview behind it. A finished sentence gets no cap — it needs to say
everything that was said. */
if (fast) opts.max_new_tokens = 96;
const out = await callWorker(worker, audio, opts, [audio.buffer]);
return cleanTranscript(Array.isArray(out) ? out.map((o) => o.text).join(' ') : out.text);
}
/*
* NLLB is a sentence-level model. Handed two sentences it will translate the
* first and quietly stop — which showed up in testing as Haitian and Arabic
* listeners losing half of every long utterance while Spanish looked fine.
* Split first, translate each, rejoin.
*/
/* Titles and initials: a full stop here does not end a sentence. Anchored to
the END of a piece, not the whole of it — the piece is "We heard Dr.", not
"Dr.", except when the title happens to open the segment. */
const ABBREV_TAIL = /(?:^|\s)(?:[A-Z]|Dr|Mr|Mrs|Ms|St|Jr|Sr|Rev|Prof|Fr|Gen|Col|Sgt|Lt|Mt|No|vs|etc)\.$/;
function splitSentences(text) {
const parts = String(text).match(/[^.!?]+[.!?]+["'\)\]]*\s*|[^.!?]+$/g);
if (!parts) return [text];
const out = [];
/*
* A short trailing piece is usually an abbreviation or a stray number that
* belongs to its neighbour. But it can also be a whole short sentence, and
* gluing those was losing them outright: NLLB is sentence-level, so
* "God is good. Amen." arrived as one string and came back translated only
* as far as the first full stop. The Amen simply vanished — verified.
*
* A continuation starts lowercase or with a digit. A sentence does not.
* An abbreviation is held and prepended to what follows, instead of being
* pushed alone ("Dr." used to reach NLLB by itself when it led a segment).
*/
let pending = '';
for (const raw of parts) {
let part = raw.trim();
if (!part) continue;
if (pending) { part = pending + ' ' + part; pending = ''; }
if (ABBREV_TAIL.test(part)) { pending = part; continue; }
const continuation = /^[\p{Ll}\p{N}]/u.test(part);
if (out.length && part.length < 12 && continuation) {
const prev = out[out.length - 1];
/* "It cost 3.5 million." splits at the decimal point. Rejoining with a
space left "It cost 3. 5 million."; close the number back up. */
out[out.length - 1] = (/\d\.$/.test(prev) && /^\d/.test(part))
? prev.slice(0, -1) + '.' + part
: prev + ' ' + part;
} else {
out.push(part);
}
}
if (pending) {
if (out.length) out[out.length - 1] += ' ' + pending;
else out.push(pending);
}
return out.length ? out : [text];
}
/*
* How many languages may be in flight at once. This is no longer a limit on
* parallel inferences — it is how many languages get to join the same batched
* decode (see mtq below). Every language that arrives inside the window rides
* one pass over the model, so a wider number is now cheaper, not dearer.
*/
const MT_CONCURRENCY = 12;
/*
* ──────────────────────────────── mtq ─────────────────────────────────────
*
* Every language asking for the SAME sentence inside one short window is sent
* to the model together and comes back from a single batched decode. NLLB
* chooses its target with one decoder token, so the languages differ by
* exactly one token and share everything else — see mt-service.js for why
* that is true and what had to be built to exploit it.
*
* MEASURED on this M2 Max against the same work one language at a time:
* 1.59x for two languages, 1.93x for three, 3.06x for five, 3.89x for nine,
* with byte-identical output in 9 of 9 languages.
*
* The window is 12 ms. Nothing waits on it in practice: the translation
* process is serial, so the first language to ask was going to sit behind the
* model anyway, and every language of one sentence is dispatched in the same
* turn of the event loop.
*/
const MT_BATCH_MS = 12;
const MT_BATCH_MAX = 16;
let mtBatch = null;
function flushMtBatch() {
const b = mtBatch;
if (!b) return;
mtBatch = null;
clearTimeout(b.timer);
const tgts = Array.from(b.waiters.keys());
const settle = (tgt, text) => {
for (const w of b.waiters.get(tgt)) w.resolve(text);
};
const fail = (tgt, err) => {
for (const w of b.waiters.get(tgt)) w.reject(err);
};
/* One language is not a batch — the ordinary call has less to set up. */
if (tgts.length === 1) {
window.engineHost.mtTranslate({ text: b.text, src: b.src, tgt: tgts[0] })
.then((t) => settle(tgts[0], t), (e) => fail(tgts[0], e));
return;
}
window.engineHost.mtTranslateMulti({ text: b.text, src: b.src, tgts })
.then((texts) => {
for (const t of tgts) {
/* A language the batch somehow skipped still gets its sentence. */
if (typeof texts[t] === 'string') settle(t, texts[t]);
else window.engineHost.mtTranslate({ text: b.text, src: b.src, tgt: t })
.then((x) => settle(t, x), (e) => fail(t, e));
}
})
.catch((e) => {
/* Batching is an optimisation, never a dependency: an older main
process, or a library that moved what the batch needs, falls back to
one pass per language and the service keeps running. */
log('batched translation failed (' + e.message + ') — one at a time', 'warn');
for (const t of tgts) {
window.engineHost.mtTranslate({ text: b.text, src: b.src, tgt: t })
.then((x) => settle(t, x), (err) => fail(t, err));
}
});
}
function mtq(text, src, tgt) {
if (!window.engineHost.mtTranslateMulti) {
return window.engineHost.mtTranslate({ text, src, tgt });
}
return new Promise((resolve, reject) => {
/* A batch is one sentence: different text cannot share an encode. */
if (mtBatch && (mtBatch.text !== text || mtBatch.src !== src)) flushMtBatch();
if (!mtBatch) {
mtBatch = { text, src, waiters: new Map(), timer: null };
mtBatch.timer = setTimeout(flushMtBatch, MT_BATCH_MS);
}
const b = mtBatch;
let arr = b.waiters.get(tgt);
if (!arr) { arr = []; b.waiters.set(tgt, arr); }
arr.push({ resolve, reject });
if (b.waiters.size >= MT_BATCH_MAX) flushMtBatch();
});
}
async function translateOne(sentences, target, tail) {
/* Sentences stay in order within a language — they get rejoined. */
const parts = [];
for (const sentence of sentences) {
/* Same short-cuts as the final path, or a preview shows the invented line
for a second before the final replaces it. Not on a TAIL window: its
first "sentence" is the middle of a longer one, and "What" from "What
shall we say to these things?" is not a question of its own. */
const fixed = !tail && ((window.Liturgy && window.Liturgy.lookup(sentence, target)) ||
(state.source === 'eng_Latn' && window.ShortQuestions && window.ShortQuestions.lookup(sentence, target)));
if (fixed) { parts.push(fixed); continue; }
const pre = window.Preach ? window.Preach.forTranslation(sentence) : sentence;
const sd = window.Preach ? window.Preach.stripShortPeriod(pre) : { text: pre, hadPeriod: false };
let piece = await mtq(sd.text, state.source, NLLB_ALIAS[target] || target);
/* The same guard the final gets — a preview used to show the invented
line for a second before the final replaced it. */
if (window.MtGuard && piece) piece = window.MtGuard.clean(sd.text, piece).text;
if (window.Preach) piece = window.Preach.restorePeriod(piece, sd.hadPeriod);
if (window.PtVariants && piece) {
if (target === 'por_Latn') piece = window.PtVariants.toBrazilian(piece);
else if (target === 'por_PT') piece = window.PtVariants.toEuropean(piece);
}
/* Previews applied the Portuguese passes but never the Spanish ones, so a
preview read "bendecemos" and the final swapped to "bendecimos". */
if (window.SpaVariants && piece && (target === 'spa_Latn' || target === 'spa_ES' || target === 'spa_MX')) {
piece = window.SpaVariants.toChurchRegister(piece);
piece = target === 'spa_ES' ? window.SpaVariants.toPeninsular(piece) : window.SpaVariants.toLatinAmerican(piece);
}
parts.push(piece);
}
return parts.filter(Boolean).join(' ').trim();
}
async function translateAll(text, targets, tail) {
const sentences = splitSentences(text);
const tr = {};
const queue = targets.filter((t) => {
if (t === state.source) { tr[t] = text; return false; }
return true;
});
/* Languages run in parallel. Sequentially, a congregation reading four
languages waited for all four to be produced one after another — the last
one was seconds behind the preacher. */
let next = 0;
async function worker() {
while (next < queue.length) {
const target = queue[next++];
try {
const joined = await translateOne(sentences, target, tail);
if (joined) tr[target] = applyOverrides(joined, target);
} catch (e) {
/* One bad language must not cost the others their sentence. */
log('translation failed for ' + target + ': ' + e.message, 'err');
}
}
}
await Promise.all(
Array.from({ length: Math.min(MT_CONCURRENCY, queue.length) }, worker)
);
return tr;
}
// ────────────────────────────────────────────────────────── work queue
const queue = [];
function queueFinal(frames, reason) {
queue.push({ frames, at: Date.now(), reason });
/* The sentence is over; anything still queued as a preview describes audio
the congregation is about to read properly. */
partialNext = null;
pump();
}
/*
* Previews run on their own track, not in the queue with finished sentences.
*
* They share nothing but the GPU: the preview uses whisper-small in one worker,
* the finished line uses the accurate model in another. Putting both through
* one queue meant every finished sentence waited behind whatever preview
* happened to be running — measured, that pushed the finished line from 1.3 s
* to 3.3 s behind the speaker. The whole point of a preview is to cost the real
* captions nothing.
*/
let partialBusy = false;
let partialNext = null;
function queuePartial() {
const long = state.segment.length > PARTIAL_WINDOW;
const frames = long ? state.segment.slice(-PARTIAL_WINDOW) : state.segment.slice();
/* Only the newest guess is worth anything — replace, never queue up. */
partialNext = { frames, at: Date.now(), tail: long };
pumpPartial();
}
async function pumpPartial() {
if (partialBusy || !partialNext || !state.modelsReady || !state.live) return;
const job = partialNext;
partialNext = null;
partialBusy = true;
try {
const text = await transcribe(flatten(job.frames), { fast: true });
/* Speech may have ended while this was in flight; a preview after the real
line has landed would overwrite it with something less accurate. */
if (!state.live || !state.speaking) return;
/* A one-word fragment left over from the rolling window is noise, not a
preview. */
if (!text || (job.tail && text.length < 4)) return;
/* A preview of a filler is the invented line, one second earlier. */
if (classifyFragment(text)) return;
/* Segmentation hint: a preview that ends in terminal punctuation means the
sentence is probably finished. Tail windows lie about this (they start
mid-sentence), so they never set it. */
state.previewPunct = !job.tail && /[.!?\u3002\uff01\uff1f]["'\u201d\u2019)\]]*\s*$/.test(text);
state.previewAt = Date.now();
/*
* Preview the languages with live listeners, most-listened first, up to
* the budget. Falls back to the active set only when nobody has reported
* a language yet (the first seconds of a service), so an early listener
* still gets a preview.
*/
const heard = (state.byLanguage || [])
.filter((r) => r && rowLang(r) && rowLang(r) !== state.source && rowCount(r) > 0)
.sort((a, b) => (b.n || 0) - (a.n || 0))
.map(rowLang)
.filter((l) => state.targets.indexOf(l) !== -1);
const want = (heard.length ? heard : orderedTargets()).slice(0, PARTIAL_MAX_LANGS);
/* Never queue behind a finished sentence — a final in flight is worth more
than a preview, and the work queue is serial. */
const cheap = want.length && !queue.length && !state.busy;
const tr = cheap ? await translateAll(text, want, !!job.tail) : {};
if (!state.live || !state.speaking) return;
send({ t: 'partial', text, tr, tail: !!job.tail });
showMonitor(text, true, Object.keys(tr).length);
} catch (e) {
log('preview failed: ' + e.message, 'err');
} finally {
partialBusy = false;
if (partialNext) pumpPartial();
}
}
/*
* Targets in the order the room cares about: languages someone is LISTENING to
* first (most listeners first), then the rest of the active set. With a serial
* translation worker this is the difference between the Spanish caption landing
* in 0.4s and it landing behind six idle pinned languages.
*/
function orderedTargets() {
const counts = {};
for (const row of state.byLanguage || []) counts[rowLang(row)] = rowCount(row) || 1;
return state.targets
.filter((t) => t !== state.source)
.sort((a, b) => (counts[b] || 0) - (counts[a] || 0));
}
/*
* Whisper's silence-hallucination signature is the SAME sentence repeated many
* times. Collapse runs of four or more; genuine liturgy ("holy, holy, holy")
* never repeats a full sentence that often.
*/
/*
* Whisper's silence-hallucination signature is the SAME sentence repeated many
* times. Collapse runs of four or more; genuine liturgy ("holy, holy, holy")
* never repeats a full sentence that often.
*
* Doubles and triples are NOT folded here. Whisper does double a sentence
* inside one segment ("We need you. We need you." — 24 same-instant pairs in
* 9,405 archived rows), but the archive also holds "Jesus! Jesus!", "Listen to
* me here. Listen to me here." and "The wrath of God is revealed. The wrath of
* God is revealed." — a preacher repeating himself on purpose, indistinguishable
* from the artifact by any test available here. The caption and the transcript
* are the record of what was said, so they keep every copy; what the user
* actually complained about — HEARING the line twice — is fixed one layer down:
* pump() marks an exact consecutive copy `silent`, and it is captioned and
* translated but not spoken. See translateSentence.
*/
function collapseLoops(sentences) {
const out = [];
let run = 0;
for (const s of sentences) {
if (out.length && s === out[out.length - 1]) {
run++;
if (run >= 3) continue; // 4th identical copy and beyond
} else run = 0;
out.push(s);
}
return out;
}
/*
* FRAGMENTS THAT MUST NOT REACH THE MODEL.
*
* MEASURED in a live service: the ASR emitted "Mmm." and "them." as sentences
* of their own and NLLB, handed no context, answered each with "¿Qué es eso?"
* — spoken aloud to the Spanish congregation; Portuguese listeners got "Não."
* for the same fragments. Two kinds, treated differently:
*
* filler — mm, hmm, uh, um… Not speech. Dropped entirely: no caption, no
* translation, and the preview it may have produced is cleared.
* The atoms must be SEPARATED (space/punctuation) or real words
* match: "mum", "ohm", "Mah" all decompose into filler atoms.
* fragment — a lone function word ("them.", "that.", "you.", "of.") the
* segmenter cut off the sentence it belongs to. It WAS said, so
* the caption keeps it; it is not translated or spoken, because
* one pronoun is exactly the input this model answers with an
* invented question. A closed list on purpose — a broader
* "any lowercase short line" rule would have silenced 276
* correct lines like "of God." and "by faith." in the archive.
*/
const FILLER_ATOM = '(?:mm+|h+m+|mhm+|u+h+|u+m+|a+h+|e+r+|e+h+|h+u+h+|o+h+|heh+|ugh+|uh[- ]huh|mm[- ]hmm|um[- ]hum)';
const FILLER_RE = new RegExp('^' + FILLER_ATOM + '(?:[\\s.!?,;:…\\-]+' + FILLER_ATOM + ')*[\\s.!?,;:…\\-]*$', 'i');
const FUNCTION_WORDS = new Set(('them you him her me it that this these those there to and of the is are was were be been ' +
'i we they us a an but or so if in on at for with from by as').split(' '));
function classifyFragment(sentence) {
const t = String(sentence || '').trim();
if (!t) return '';
/* A question mark makes it a question, filler-shaped or not: "Huh?" and
"Eh?" have deterministic renderings in short-questions.js. */
if (/[?¿]/.test(t)) return '';
if (FILLER_RE.test(t)) return 'filler';
const bare = t.toLowerCase().replace(/[\s.!?,;:…"'“”‘’\-]+$/u, '').replace(/^[\s"'“‘]+/u, '');
if (/[!¡]/.test(t)) return '';
if (bare.indexOf(' ') === -1 && FUNCTION_WORDS.has(bare)) return 'fragment';
return '';
}
async function pump() {
if (state.busy || !state.modelsReady || !state.live) return;
const job = queue.shift();
if (!job) return;
state.busy = true;
const started = Date.now();
let jobAudioMs = 0;
try {
/* job.at is when the audio ENDED. Live translation of stale audio is
worthless, but the WORDS are not: however old the job, it still gets
transcribed and captioned (the transcript archive stays complete) —
what shrinks under pressure is the translation work, which is the
actual bottleneck. */
const ageSec = (Date.now() - job.at) / 1000;
/* Near-silence telemetry (log-only this release): a chunk whose average
level barely clears the noise floor is hallucination fuel. */
const audio = flatten(job.frames);
/* How much speech this chunk actually holds. The step-down below compares
against it, because "slow" only means anything relative to the audio. */
jobAudioMs = (audio.length / SAMPLE_RATE) * 1000;
let sum = 0;
for (let i = 0; i < audio.length; i += 4) sum += audio[i] * audio[i];
const meanRms = Math.sqrt(sum / Math.ceil(audio.length / 4));
/*
* ROOM TONE IS NOT SPEECH — do not hand it to the model.
*
* This used to log the condition and transcribe anyway, and the model
* obliges by inventing something: a laptop mic in a quiet room produced
* "keyboard clicking", and near-silence is where "Thank you." and
* "Subtitles by the Amara.org community" come from too. The VAD already
* decided this chunk had speech in it; if its average level is still down
* at the learned noise floor, the VAD was wrong.
*
* It also saves the work — on a machine that takes seconds per sentence,
* not transcribing silence is the cheapest speed-up available.
*/
/*
* PEAK as well as average, deliberately. A short sentence inside a longer
* chunk averages down close to the noise floor while its peak is obviously
* speech — gating on the average alone would throw away exactly the quiet
* talker this app exists to carry. Both must look like room tone.
*/
let pk = 0;
for (let i = 0; i < audio.length; i += 4) { const v = audio[i] < 0 ? -audio[i] : audio[i]; if (v > pk) pk = v; }
const ref = gateFloor();
if (meanRms < ref * 1.15 && pk < Math.max(ref * 6, 0.02)) {
state.silentChunks = (state.silentChunks || 0) + 1;
if (state.silentChunks === 1 || state.silentChunks % 20 === 0) {
log('skipped ' + state.silentChunks + ' near-silent chunk(s) — nothing there to transcribe ' +
'(rms ' + meanRms.toFixed(5) + ', peak ' + pk.toFixed(4) +
', floor ' + ref.toFixed(5) + ')');
}
return;
}
const text = await transcribe(audio);
if (text) {
const sentences = collapseLoops(splitSentences(text));
let targets = orderedTargets();
/*
* SKIPPING A TRANSLATION ONLY BUYS SOMETHING IF THERE IS A QUEUE TO DRAIN.
*
* Dropping the translations of stale audio is how the app catches up: the
* listener has moved on, so the work is wasted and the time is better spent on
* the sentences behind it. That reasoning needs a backlog. With nothing waiting
* behind it, skipping costs the room a sentence and saves nothing at all.
*
* MEASURED: a campus Mac took one 16 s stall — two Screen Sharing sessions
* competing for the machine — and skipped that sentence's translations with an
* EMPTY queue. The room lost a line of the sermon to a catch-up that had
* nothing to catch up on.
*/
if (ageSec > 12 && queue.length > 0) {
targets = [];
state.captionOnlyFinals++;
state.captionOnlyAt = Date.now();
log('audio ' + ageSec.toFixed(0) + 's stale with ' + queue.length + ' waiting — ' +
'caption only, translations skipped', 'err');
} else if (ageSec > 12) {
/* Late, but nothing is behind it: say so, and translate it anyway. */
log('audio ' + ageSec.toFixed(0) + 's late, but nothing is queued — translating it anyway', 'warn');
} else if (ageSec > 6) {
const listening = new Set((state.byLanguage || []).map(rowLang));
const before = targets.length;
targets = targets.filter((t) => listening.has(t));
if (targets.length < before) { state.degradedFinals++; state.degradedAt = Date.now(); }
}
/* The segment's own loudness, measured once and shared by every sentence
it split into — they were all said at the same level. */
const segLevel = speechLevel(audio, Math.max(gateFloor() * 1.5, 0.002));
const gainDb = dynamicsGainDb(segLevel);
let survived = 0;
let prevSentence = null;
for (const sentence of sentences) {
/* An exact consecutive copy is what Whisper produces when it doubles a
line — and also what a preacher produces on purpose. Both are kept
in the caption and the record; neither is SPOKEN twice. */
const silent = prevSentence !== null && sentence.trim() === prevSentence.trim();
prevSentence = sentence;
const kind = classifyFragment(sentence);
if (kind === 'filler') { log('dropped filler "' + sentence + '"'); continue; }
/* The caption goes out the moment the words exist — before any
translation. Every listener sees SOMETHING immediately (their own
translation replaces it seconds later; source-language readers are
already done), and the operator's monitor tracks the preacher in
real time. */
const cid = ++state.cidSeq;
send({ t: 'final', cid, text: sentence, tr: {} });
survived++;
if (kind === 'fragment') {
/* Captioned, not translated or spoken. Counted in its own field —
NOT captionOnlyFinals, which turns the operator's health light red. */
state.fragmentFinals = (state.fragmentFinals || 0) + 1;
continue;
}
if (silent) log('repeated line kept in the caption, spoken once');
translateSentence(cid, sentence, targets, gainDb, silent);
}
/* Everything in this segment was filler: whatever the preview showed
for it (possibly an invented line) must come off the phones. */
if (!survived && sentences.length) send({ t: 'partial', text: '', tr: {}, clear: true });
/* No segment-level line any more — monitorFinal() has already drawn one
row per SENTENCE, each carrying the cid its translations arrive on. */
state.lagMs = Date.now() - job.at;
state.lastFinalAt = Date.now();
}
} catch (e) {
log('sentence failed: ' + e.message, 'err');
} finally {
state.busy = false;
const took = Date.now() - started;
if (took > 4000) log('slow sentence: ' + took + ' ms');
/*
* A model can be unusable without ever hanging. Three sentences in a row
* each taking longer than 8 s means transcription is slower than people
* talk, and the backlog only grows — step down before the service is lost
* rather than after. Measured on the Intel laptop: 12-25 s, every sentence.
*/
/*
* SLOWER THAN THE AUDIO IT COVERS — not slower than some constant.
*
* This used to fire only past 8 s, on the reasoning that 8 s meant slower
* than people talk. That was true when a chunk could be 8 s long. Chunks
* are capped at HARD_CUT_MAX, 5.5 s, so a 4.5 s chunk taking 4.3 s is
* already losing ground and never came near the old threshold.
*
* MEASURED on a campus M4 Pro: every sentence 4.2-4.7 s against roughly
* 4-5 s of audio, a queue 46 deep and a lag of 152 s still climbing — with
* nobody listening and no language being translated, so this was
* transcription alone. The one mechanism built to catch exactly that could
* not fire, because 4.4 s is not 8 s.
*
* The queue test is what separates "this machine cannot keep up" from one
* slow sentence: a backlog that is three deep and being fed faster than it
* drains does not recover on its own.
*/
/*
* SLOWER THAN THE AUDIO IT COVERS, AND LANDING LATE.
*
* This used to fire only past 8 s, on the reasoning that 8 s meant slower
* than people talk. That held when a chunk could be 8 s long; chunks are
* capped at HARD_CUT_MAX, 5.5 s, so a 4.4 s sentence is already losing and
* never came near the old threshold.
*
* Both numbers below were measured on a campus M4 Pro, and the second one
* only after a first attempt got it wrong. 0.85, because that Mac ran 4.4 s
* against 4.5 s of audio — nominally FASTER than real time while losing
* badly, since the work outside this timer (segmenting, the preview model
* sharing the GPU, the scheduler) loses on its own.
*
* The second test was a queue depth, and watching that Mac go red disproved
* it: the queue sat at 2-3 while the lag held at 15 s and the verdict was
* already red. A machine can be persistently late without ever stacking work
* up — it finishes each sentence just slightly too slowly, for ever. So the
* test is how far behind the speaker the sentence actually landed, which is
* what the operator is complaining about in the first place.
*/
/* A model that loaded seconds ago has not had a fair sentence yet. */
const settling = state.steppedDownAt && (Date.now() - state.steppedDownAt) < STEP_SETTLE_MS;
const behindMs = Date.now() - job.at;
const losingGround = jobAudioMs > 0 && took > jobAudioMs * 0.85 && behindMs > 8000;
if (!settling && (took > 8000 || losingGround)) {
state.slowRun = (state.slowRun || 0) + 1;
if (state.slowRun >= 3 && !respawning && lighterAsr(state.asrModelInUse || state.cfg.asrModel)) {
state.slowRun = 0;
log('transcription is slower than people are speaking (' + took + ' ms for ' +
Math.round(jobAudioMs) + ' ms of audio, ' + Math.round(behindMs / 1000) + 's behind) — ' +
'stepping down to a lighter model', 'err');
respawnWorker(state.asrWorker);
}
} else state.slowRun = 0;
pump();
}
}
/*
* ───────────────────────── compressed dynamics ────────────────────────────
*
* Make the translated voice follow the preacher: quieter when he drops to a
* whisper, louder when he leans on a line.
*
* COMPRESSED, deliberately, and this is the whole design. A real whisper-to-
* shout span is around 40 dB. Reproduced faithfully into headphones or a
* hearing-aid pack the whisper is inaudible and the shout hurts — and the
* people on those packs are the least able to tolerate either. Broadcast and
* assistive-listening systems compress for exactly this reason. So the SHAPE
* of the dynamics survives and the range does not: about 14 dB end to end.
*
* This is level-following, not style. Piper has no expressive axis — it cannot
* whisper, only be quieter. Anything more would mean a different synthesizer,
* per sentence, mid-sermon.
*/
const DYN_RATIO = 0.35; // how much of the source's deviation survives
const DYN_MAX_DB = 7; // +/- this, so ~14 dB total range
/*
* How loud this segment's SPEECH was — the 60th percentile of frames above the
* noise floor, not the mean of the whole segment. A mean counts the pauses and
* reads a firm sentence with a long tail as quiet; a peak counts one cough and
* reads everything as a shout.
*/
function speechLevel(audio, floor) {
const N = 1024;
const levels = [];
for (let i = 0; i + N <= audio.length; i += N) {
let sum = 0;
for (let j = 0; j < N; j++) { const v = audio[i + j]; sum += v * v; }
const r = Math.sqrt(sum / N);
if (r > floor) levels.push(r);
}
if (!levels.length) return 0;
levels.sort((a, b) => a - b);
return levels[Math.min(levels.length - 1, Math.floor(levels.length * 0.6))];
}
function dynamicsGainDb(rms) {
if (!state.cfg || !state.cfg.dynamics) return 0;
if (!(rms > 0)) return 0;
/* Relative to what this room has been doing, not to an absolute number —
mic gain, preacher and building all differ, and an absolute reference
would make one campus permanently loud. */
if (!state.normalRms) { state.normalRms = rms; return 0; }
const dev = 20 * Math.log10(rms / state.normalRms);
/* Slow, so a long loud passage does not quietly become the new normal and
flatten the very contrast this exists to keep. */
state.normalRms = state.normalRms * 0.94 + rms * 0.06;
const g = dev * DYN_RATIO;
return Math.max(-DYN_MAX_DB, Math.min(DYN_MAX_DB, Math.round(g * 10) / 10));
}
/*
* One promise chain per language (the translation worker is serial anyway, so
* this costs nothing) — sentences reach each language in order, and each
* language's caption and voice fire the moment ITS translation lands, never
* waiting for the others. mtDepth keeps a wedged or slow stretch from growing
* the chains without bound: languages nobody is listening to get skipped first.
*/
const mtChain = {};
/* cid:nllbTarget -> in-flight translation, so variants of one language never
pay for the same sentence twice. */
const mtInflight = {};
const mtDepth = {};
/*
* Variant languages share one NLLB translation. por_PT exists so Portugal
* hears a European voice, but NLLB knows only por_Latn — the alias applies at
* the translate call, and when BOTH variants are active the second copies the
* first's finished result instead of paying for the same sentence twice.
*/
const NLLB_ALIAS = { por_PT: 'por_Latn', spa_ES: 'spa_Latn', spa_MX: 'spa_Latn' };
/* Finished translations of in-flight sentences, so a variant can copy its
base's text. Trimmed continuously — only the newest sentences matter. */
const sentenceCache = {};
setInterval(() => {
const keys = Object.keys(sentenceCache);
if (keys.length > 400) for (const k of keys.slice(0, keys.length - 200)) delete sentenceCache[k];
}, 30000);
function translateSentence(cid, sentence, targets, gainDb, silent) {
const listening = new Set((state.byLanguage || []).map(rowLang));
for (const lang of targets) {
if ((mtDepth[lang] || 0) > 6 && !listening.has(lang)) {
state.degradedFinals++;
continue;
}
mtDepth[lang] = (mtDepth[lang] || 0) + 1;
mtChain[lang] = (mtChain[lang] || Promise.resolve()).then(async () => {
try {
if (!state.live) return;
/*
* Liturgical constants never reach the model. "Amen." is one word,
* a context model has no context to work from, and it invents:
* measured, it became "Sí, estoy bien." in Spanish and
* "Je vous en prie." in French. Whole-utterance match only, so
* "can I get an amen?" still translates normally.
* applyOverrides still runs, so an operator's pin outranks this.
*/
const set = window.Liturgy && window.Liturgy.lookup(sentence, lang);
if (set) {
const said = applyOverrides(set, lang);
sentenceCache[cid + ':' + (NLLB_ALIAS[lang] || lang)] = said;
send({ t: 'tr', cid, lang, text: said });
if (!silent) speakOne(cid, lang, said, gainDb);
return;
}
/*
* A whole utterance that is nothing but a Bible name never reaches the
* model. MEASURED: "Peter." came back as "¿Qué es eso?" and "Jesus."
* as "¿Qué quieres?" — the name gone, replaced by invented dialogue;
* in French "Habakkuk." became "Habakuk est mort". A preacher naming
* people one at a time got a run of questions with no names in them.
* Deterministic lookup, so this cannot come back.
*/
const named = window.BibleLexicon &&
window.BibleLexicon.wholeUtterance(sentence, lang);
if (named) {
const said = applyOverrides(named, lang);
sentenceCache[cid + ':' + (NLLB_ALIAS[lang] || lang)] = said;
send({ t: 'tr', cid, lang, text: said });
if (!silent) speakOne(cid, lang, said, gainDb);
return;
}
/*
* A one-word QUESTION never reaches the model either. MEASURED: "What?"
* -> "¿Qué es eso?" fifteen times in the archive, "Yeah?" -> the same,
* "Hello?" -> "¿Qué pasa?" — and because the source IS a question the
* guard rightly leaves a question-shaped answer alone. Deterministic
* table, so it cannot come back.
*/
const asked = state.source === 'eng_Latn' && window.ShortQuestions &&
window.ShortQuestions.lookup(sentence, lang);
if (asked) {
const said = applyOverrides(asked, lang);
sentenceCache[cid + ':' + (NLLB_ALIAS[lang] || lang)] = said;
send({ t: 'tr', cid, lang, text: said });
if (!silent) speakOne(cid, lang, said, gainDb);
return;
}
/* Variants of one language share a single translation, keyed by the
NLLB target rather than by the listener's language — see mtInflight. */
const nllbTgt = NLLB_ALIAS[lang] || lang;
const ckey = cid + ':' + nllbTgt;
/*
* Preaching idioms are rewritten to plain English for the MODEL ONLY.
* The caption, the monitor and the archive keep what was actually
* said — normalising `sentence` itself would put words in the
* preacher's mouth and write them to the permanent record.
*/
const forMt = window.Preach ? window.Preach.forTranslation(sentence) : sentence;
/*
* Strip the terminal period on a short utterance. One character, and
* it decides whether the model translates a term or answers it:
* "Peter." -> "¿Qué es eso?" but "Peter" -> "Pedro". Restored after
* the guard so the caption and the voice both get a finished line.
*/
const shortDot = window.Preach ? window.Preach.stripShortPeriod(forMt)
: { text: forMt, hadPeriod: false };
const mtIn = shortDot.text;
let out = sentenceCache[ckey];
if (out === undefined) {
/*
* One inference per (sentence, NLLB target), however many variants
* ask for it and whichever asks first. An existence check, not a
* truthiness check: the guard below can legitimately return '' and a
* falsy cache hit would silently re-translate.
*/
if (!mtInflight[ckey]) {
mtInflight[ckey] = (async () => {
let raw = await mtq(mtIn, state.source, nllbTgt);
/*
* On the raw output, before the cache write, once per translation.
* Handed two or three words with no context NLLB invents fluent
* continuation — measured: "Donald Trump." came back as "Donald
* Trump. ¿Qué es eso?" and no generation parameter suppresses it.
* Inside the shared promise, so every variant of a language
* inherits the SAME trim decision — otherwise two Portuguese
* congregations could be told different things.
*/
if (window.MtGuard) {
const g = window.MtGuard.clean(mtIn, raw);
if (g.trimmed && g.reason) log(nllbTgt + ': ' + g.reason, 'warn');
raw = g.text;
}
/* Inside the shared promise, so every variant of this language
inherits the same finished line. */
if (window.Preach) raw = window.Preach.restorePeriod(raw, shortDot.hadPeriod);
sentenceCache[ckey] = raw; // RAW — variants derive from it
return raw;
})();
mtInflight[ckey].catch(() => {}).then(() => { delete mtInflight[ckey]; });
}
out = await mtInflight[ckey];
}
/*
* One translation, two Portugueses. The raw NLLB output is European-
* leaning but inconsistent; each variant gets its deterministic pass
* (measured on the church's own corpus, ~35µs, zero-break reviewed).
*/
if (window.PtVariants) {
if (lang === 'por_Latn') out = window.PtVariants.toBrazilian(out);
else if (lang === 'por_PT') out = window.PtVariants.toEuropean(out);
}
/*
* Spanish, and note which direction runs. NLLB leaks Peninsular forms
* into Latin American output (27 rows in this church's archive:
* "lavará vuestros pecados", "Hacéis esto", "coche"), and stripping
* them is unambiguous — vosotros/vuestro/os/-áis can only be second
* person plural. The reverse direction is impossible and is not
* attempted; spa-variants.js records the measurements.
*/
if (window.SpaVariants) {
/* Denominational register first, for EVERY Spanish — rezar vs orar is
not a regional question. Then the regional pass. */
if (lang === 'spa_Latn' || lang === 'spa_ES' || lang === 'spa_MX') {
out = window.SpaVariants.toChurchRegister(out);
}
/* Mexico shares Latin America's grammar exactly, so it takes the same
Peninsular-leakage strip. Spain keeps the words and differs by voice. */
if (lang === 'spa_Latn' || lang === 'spa_MX') out = window.SpaVariants.toLatinAmerican(out);
else if (lang === 'spa_ES') out = window.SpaVariants.toPeninsular(out);
}
/*
* Bible proper nouns NLLB left in English. Measured: it renders
* "Turn to John chapter 3" as "...a Juan..." but "His name was John"
* as "Se llamaba John" — the same word, inconsistently. Only English
* forms are matched, which is what keeps this safe; a target-side
* entry could turn "la marca de la bestia" into "la Marcos de la
* bestia". Runs before applyOverrides so an operator pin still wins.
*/
if (window.BibleLexicon) {
const bl = window.BibleLexicon.forLanguage(lang);
if (bl) out = applyTable(String(out || ''), Object.keys(bl).map((from) => ({ from, to: bl[from] })));
/*
* Target-side corrections, deliberately separate and deliberately
* tiny: forms MEASURED wrong against a named edition. French is the
* only language with any — NLLB renders Stephen as "Stéphanas", who
* is a different person in scripture entirely (1 Cor 1:16).
*/
const fx = window.BibleLexicon.fixesFor(lang);
if (fx) out = applyTable(String(out || ''), Object.keys(fx).map((from) => ({ from, to: fx[from] })));
}
const final = applyOverrides(String(out || '').trim(), lang);
if (!state.live) return;
/*
* AN EMPTY TRANSLATION IS STILL AN ANSWER, and it has to be sent.
*
* The guard blanking an invented line used to return here, so no 'tr'
* ever went out: the listener's row stayed on the English caption for
* the rest of the service, indistinguishable from a translation that
* was merely slow, and the operator's monitor sat on 'translating…'
* forever for a sentence that had already been decided.
*
* Sending the empty string says "this one is deliberately not spoken"
* — the listener page and the monitor both already render that case.
* Nothing is SPOKEN either way; only the reporting changes.
*/
send({ t: 'tr', cid, lang, text: final });
if (!final) return;
if (!silent) speakOne(cid, lang, final, gainDb);
} catch (err) {
/* One bad language must not cost the others their sentence. */
log('translation failed for ' + lang + ': ' + err.message, 'err');
} finally {
mtDepth[lang]--;
}
});
}
}
/*
* Built-in voices.
*
* Speech is generated on this Mac and handed to the phones, so every listener
* on a language hears the same voice with nothing installed. Device voices were
* a lottery — an iPad ships roughly one per language, and Apple's novelty voices
* sort first and sound like cartoons.
*
* Text reaches ~200 languages and speech reaches 31, so this deliberately does
* nothing for the rest. Those listeners fall back to their own device voice and
* the page tells them so.
*/
/*
* One promise chain PER LANGUAGE — each language speaks its sentences in
* caption order (out-of-order splices sound like the voice skipping text), and
* each fires the moment its own translation lands, never waiting for another
* language. The voice process serialises actual synthesis, so chains cost no
* throughput.
*/
const ttsChain = {};
/*
* Tell the server which alternate voices listeners may pick.
*
* The renderer owns this list (it lives in the app's config beside
* voiceChoices); the server only caches it to answer phones. Sent on every
* connect and on every edit, so the two cannot drift.
*/
function sendVoiceOptions() {
/*
* Sent as {key, label} pairs, because the LISTENER page has no catalog — it
* would otherwise have to render a chooser reading "em_santa". The key is
* what comes back and what the engine synthesises; the label is only ever
* shown.
*/
const src = listenerVoices();
const map = {};
for (const lang of Object.keys(src)) {
const cat = (window.VoiceCatalog && window.VoiceCatalog.forLanguage(lang)) || [];
const rows = [];
for (const key of src[lang] || []) {
const v = cat.find((c) => voiceKeyOf(c) === key);
if (v) rows.push({ key: key, label: v.label });
}
if (!rows.length) continue;
/*
* The operator's own voice leads the list, under the empty key.
*
* Naming it beats a generic "Default": the listener is choosing between
* two voices by ear, and "Alex · male / Santa · male" is a choice while
* "Default / Santa · male" is a puzzle. The empty key is what the server
* already means by "on the default", so nothing downstream changes.
*/
const cur = currentVoiceFor(lang);
rows.unshift({ key: '', label: (cur && cur.label) || 'Default' });
map[lang] = rows;
}
send({ t: 'voiceOptions', map: map });
}
function speakOne(cid, lang, text, gainDb) {
if (!state.ttsReady || !window.engineHost.ttsSpeak) return;
if (lang === state.source || state.spokenLanguages.indexOf(lang) === -1) return;
/*
* The default voice always, plus any alternate somebody is ACTUALLY
* listening to right now (the server tracks that in voiceDemand).
*
* The default is never skipped even when every listener has switched: it is
* what feeds the hardware outputs and the scrollback a late joiner gets.
*/
const extra = (state.voiceDemand && state.voiceDemand[lang]) || [];
for (const v of extra) speakAs(cid, lang, text, gainDb, v);
speakAs(cid, lang, text, gainDb, '');
}
/*
* One voice of one sentence.
*
* Chained PER VOICE, not per language: the alternates must not queue behind
* the default, or a language with two voices in play would speak at half
* speed. They are independent streams to independent listeners.
*/
function speakAs(cid, lang, text, gainDb, voice) {
const key = lang + '\u0000' + (voice || '');
ttsChain[key] = (ttsChain[key] || Promise.resolve()).then(async () => {
try {
if (!state.live) return;
const clip = await window.engineHost.ttsSpeak({
lang, text, voice: voice || '',
/* How much louder or quieter this sentence was than the room's normal,
already compressed — see dynamicsGainDb. 0 when the setting is off. */
gainDb: Number(gainDb) || 0,
/* How far this language's spoken audio is behind its captions, from
the server. The voice quickens a little when it is falling behind
— the interpreter's trick — so the stream almost never has to drop
a sentence to catch up. */
lagSec: (state.audioLag && state.audioLag[lang]) || 0
});
/* Nothing sayable in this sentence — punctuation, a stripped URL, an
override that mapped to nothing. The caption still stands. */
if (clip.empty) return;
send({ t: 'audio', cid, lang, name: clip.name, seconds: clip.seconds,
sampleRate: clip.sampleRate, terminal: clip.terminal,
/* '' is the default voice; the server routes each clip only to
the listeners who are on that voice. */
voice: voice || '' });
/*
* Same clip, straight out of the hardware outputs. Not awaited: the
* phone stream must not wait on a sound card, and OutRouting swallows
* its own failures for the same reason.
*
* DEFAULT VOICE ONLY. The Dante/Waves outputs and the T27 transmitter
* are one shared feed with one listener each — sending an alternate
* there would layer two voices on the same channel.
*/
if (!voice && window.OutRouting) {
window.OutRouting.play(lang, 'http://127.0.0.1:' + state.local.port + '/audio/' + clip.name);
}
} catch (e) {
/* A missing voice must never cost a caption. */
log('voice failed for ' + lang + (voice ? ' (' + voice + ')' : '') + ': ' + e.message, 'err');
}
});
}
/*
* cid -> that sentence's row, so a translation arriving seconds after the
* English lands UNDER the English it belongs to rather than at the bottom.
*/
const monRows = new Map();
/* One English sentence, as it went to the congregation. */
function monitorFinal(cid, text) {
const box = $('monitor');
if (!box || !text) return;
const empty = box.querySelector('.mon-empty');
if (empty) empty.remove();
/* The preview this sentence replaces. */
const partial = box.querySelector('.mon-line.partial');
if (partial) partial.remove();
const el = document.createElement('div');
el.className = 'mon-line';
const en = document.createElement('div');
en.className = 'mon-en';
en.textContent = text;
el.appendChild(en);
/* Until a translation lands, say so — a line with nothing under it is
ambiguous between "still working" and "never translated". */
const wait = document.createElement('div');
wait.className = 'mon-wait';
wait.textContent = 'translating…';
el.appendChild(wait);
box.appendChild(el);
monRows.set(cid, el);
/* Neither the map nor the box may grow for the length of a service. */
while (monRows.size > 60) monRows.delete(monRows.keys().next().value);
trimTop(box, 24);
followBottom(box);
}
/*
* One language's finished translation, under its English.
*
* This is the line the congregation actually hears spoken, after the guard and
* every override — so an invented question or a stale line is visible here the
* moment it happens, next to what was really said.
*/
function monitorTranslation(cid, lang, text) {
const el = monRows.get(cid);
if (!el) return;
const wait = el.querySelector('.mon-wait');
if (wait) wait.remove();
let row = el.querySelector('.mon-tr[data-lang="' + lang + '"]');
if (!row) {
row = document.createElement('div');
row.className = 'mon-tr';
row.setAttribute('data-lang', lang);
const tag = document.createElement('b');
const l = window.Languages && window.Languages.get(lang);
tag.textContent = l ? l.name : lang;
row.appendChild(tag);
const t = document.createElement('span');
t.className = 'mon-tr-text';
row.appendChild(t);
el.appendChild(row);
}
const body = row.querySelector('.mon-tr-text');
/* An empty translation is the guard having suppressed it. Saying so is the
whole point — silence with no explanation is what sent the operator
hunting through the audio chain for a fault that was not there. */
if (text && String(text).trim()) {
body.textContent = String(text);
row.classList.remove('dropped');
} else {
body.textContent = 'not spoken — suppressed as invented';
row.classList.add('dropped');
}
const box = $('monitor');
followBottom(box);
}
/*
* THE DEAF WATCHDOG.
*
* MEASURED here: the app sat LIVE for four minutes capturing nothing at all,
* with the meter visibly moving, because the gate had been left above the
* speaker's level. The UI said "Translating", the health light stayed green,
* and nothing anywhere said otherwise — so the fault was hunted through the
* audio chain, the output routing and the translator before anyone suspected
* the notch. Every ingredient needed to catch it in ten seconds was already
* being computed and thrown away.
*
* Deliberately conservative: it takes ninety seconds of nothing WHILE the room
* is audibly loud. A preacher pausing, a silent prayer, a song with the mic
* closed — none of those trip it, because none of them are loud at the input.
* It says it once per episode, and names both numbers so the operator can act
* on it rather than go looking.
*/
/* How long a freshly swapped speech model is left alone. Long enough to cover a
cold first inference, short enough that a genuinely hopeless model still falls
through the ladder within a sentence or two. */
const STEP_SETTLE_MS = 20000;
const DEAF_AFTER_MS = 90000;
setInterval(() => {
try {
if (!state.live) { state.deafWarned = false; return; }
const now = Date.now();
const quietFor = now - (state.lastFinalAt || now);
/* Loud within the last half minute — this is a live room, not a lull. */
const loudNow = state.loudAt && (now - state.loudAt) < 30000;
if (quietFor > DEAF_AFTER_MS && loudNow) {
if (!state.deafWarned) {
state.deafWarned = true;
const tdb = Math.round(20 * Math.log10(Math.max(state.threshold || 0.0012, 1e-6)));
const pdb = Math.round(20 * Math.log10(Math.max(state.rms || 1e-6, 1e-6)));
log('nothing transcribed for ' + Math.round(quietFor / 1000) + 's while the input is still ' +
'hearing speech (input ' + pdb + ' dB, notch ' + tdb + ' dB). The notch is probably above ' +
'the voice — lower it under Input, or raise the gain on the interface.', 'err');
}
} else if (quietFor < DEAF_AFTER_MS) {
state.deafWarned = false;
}
} catch (_) { /* a watchdog must never be the thing that breaks the service */ }
}, 15000);
function showMonitor(text, isPartial, langCount) {
const box = $('monitor');
const empty = box.querySelector('.mon-empty');
if (empty) empty.remove();
if (isPartial) {
let el = box.querySelector('.mon-line.partial');
if (!el) { el = document.createElement('div'); el.className = 'mon-line partial'; box.appendChild(el); }
el.textContent = text;
} else {
const old = box.querySelector('.mon-line.partial');
if (old) old.remove();
const el = document.createElement('div');
el.className = 'mon-line';
el.textContent = text;
const sub = document.createElement('small');
sub.textContent = langCount + ' language' + (langCount === 1 ? '' : 's') + ' · ' + state.lagMs + ' ms';
el.appendChild(sub);
box.appendChild(el);
trimTop(box, 30);
}
followBottom(box);
}
// ────────────────────────────────────────────────────────── church server
function send(msg) {
/*
* The operator's monitor is fed from the SAME messages the congregation
* gets, not from a separate reconstruction. Anything that drifts between
* what is shown here and what went out would defeat the point of showing it:
* this panel exists to verify the service, so it has to read the wire.
*/
try {
if (msg && msg.cid) {
if (msg.t === 'final') monitorFinal(msg.cid, msg.text);
else if (msg.t === 'tr') monitorTranslation(msg.cid, msg.lang, msg.text);
}
} catch (_) { /* a display fault must never cost a listener their caption */ }
if (state.ws && state.ws.readyState === 1) {
try { state.ws.send(JSON.stringify(msg)); } catch (_) {}
}
}
function setServerPip(ok, text) {
$('srvDot').className = 'pip' + (ok === true ? ' ok' : ok === false ? ' bad' : '');
$('srvText').textContent = text;
}
function wsUrl() {
/* Always our own server, on this machine. Nothing to configure and nothing
to type — every campus runs a self-contained copy. */
return 'ws://127.0.0.1:' + state.local.port +
'/ws?role=engine&key=' + encodeURIComponent(state.local.engineKey);
}
function connectServer() {
clearTimeout(state.wsTimer);
if (!state.local || !state.local.ready) {
setServerPip(null, 'Starting…');
state.wsTimer = setTimeout(connectServer, 1000);
return;
}
if (state.ws) { try { state.ws.onclose = null; state.ws.close(); } catch (_) {} state.ws = null; }
let ws;
try { ws = new WebSocket(wsUrl()); }
catch (e) { log('bad server address: ' + e.message, 'err'); return; }
state.ws = ws;
setServerPip(null, 'Connecting…');
ws.onopen = () => {
state.wsRetry = 0;
setServerPip(true, 'Serving');
log('congregation server connected on port ' + state.local.port, 'ok');
reportStatus();
/* The server holds the listener-voice allow-list in memory only, so it has
to be told again every time this socket comes back — including after a
server restart, when it would otherwise offer listeners nothing. */
sendVoiceOptions();
};
ws.onmessage = (ev) => {
let m;
try { m = JSON.parse(ev.data); } catch (_) { return; }
if (m.t === 'lag') { state.audioLag = m.backlog || {}; return; }
if (m.t === 'welcome' || m.t === 'start' || m.t === 'stop' || m.t === 'langs') {
if (m.source) state.source = m.source;
const before = state.targets.join(',');
if (Array.isArray(m.langs)) state.targets = m.langs;
if (Array.isArray(m.list)) state.targets = m.list;
/* Warm any newly active voice NOW, not on its first sentence — the first
listener on a new language used to pay the whole bundle download plus
model load before hearing a word. */
if (state.ttsReady && state.targets.join(',') !== before) preloadNewVoices();
/* The routing matrix lists the languages in play, so it follows them. */
if (state.targets.join(',') !== before) refreshOutMapIfShown();
if (m.glossary) state.glossary = normalizeGlossary(m.glossary);
if (typeof m.micWanted === 'boolean') micWanted = m.micWanted;
if (typeof m.listeners === 'number') state.listeners = m.listeners;
if (Array.isArray(m.detail)) state.listenerDetail = m.detail;
if (Array.isArray(m.byLanguage)) state.byLanguage = m.byLanguage;
/* Which alternate voices are actually being listened to. Absent means
nobody switched, which is the normal case and costs nothing. */
if (m.voiceDemand && typeof m.voiceDemand === 'object') state.voiceDemand = m.voiceDemand;
}
if (m.t === 'welcome') {
log('server says: ' + (m.live ? 'already live' : 'standing by'));
/*
* Adopt the server's state, in BOTH directions.
*
* This used to only act on live=true. If the socket dropped while
* translating and came back to a server that was no longer live, the
* engine carried on transcribing and translating into a server that
* discards every sentence it is sent while stopped. The app showed
* "Translating", the monitor filled with correctly translated text, and
* the congregation's page sat on "the message hasn't started yet" — a
* total failure with no symptom anywhere an operator would look.
*/
goLive(!!m.live);
} else if (m.t === 'start') {
goLive(true);
} else if (m.t === 'stop') {
goLive(false);
} else if (m.t === 'status') {
/* The server pushes config + status to every operator surface whenever
anything changes. Refreshing the cache here is what stops this pane
from saving a stale array over a change someone just made from the
browser operator page. */
if (m.config && opConfig) {
/*
* The server pushes a fresh operator snapshot every 3 seconds
* (consoleTick in server.js) so elapsed time and staleness stay honest.
* Rebuilding 105 language rows twenty times a minute for a config that
* has not changed destroys keyboard focus, and would tear down a
* in-flight row animation. Re-render only on an actual change.
*/
const sig = JSON.stringify(m.config);
opConfig = m.config;
if (sig !== lastConfigSig) { lastConfigSig = sig; renderOpLanguages(); }
}
if (m.status) {
state.health = m.status.health || null;
if (typeof m.status.hold === 'boolean') state.hold = m.status.hold;
if (typeof m.status.listeners === 'number') state.listeners = m.status.listeners;
if (Array.isArray(m.status.listenersByLanguage)) state.byLanguage = m.status.listenersByLanguage;
if (Array.isArray(m.status.listenerDetail)) state.listenerDetail = m.status.listenerDetail;
if (m.status.startedAt !== undefined) state.startedAt = m.status.startedAt;
renderOperatorLive();
}
} else if (m.t === 'langs') {
log('translating into: ' + (state.targets.join(', ') || '(nobody listening yet)'));
updateIdleHero();
}
};
ws.onclose = (ev) => {
if (state.ws !== ws) return;
state.ws = null;
goLive(false);
/*
* "replaced" means another engine connected and the server handed it the
* job. Reconnecting straight away would displace *it*, and the two would
* trade places forever — every swap dropping captions. Back right off, and
* say plainly what is happening, so whoever is watching can go and quit the
* other copy.
*/
if (ev && ev.reason === 'replaced') {
setServerPip(false, 'Another engine took over');
hero('error', 'Another engine is connected',
'A second copy of this app — probably on another Mac — is doing the translating. ' +
'Quit that one, or leave this one closed. This machine will retry in 30 seconds.');
log('another engine took over — standing down', 'err');
state.wsTimer = setTimeout(connectServer, 30000);
return;
}
setServerPip(false, 'Disconnected');
state.wsRetry++;
const wait = Math.min(15000, 700 * Math.pow(1.6, Math.min(state.wsRetry, 8)));
state.wsTimer = setTimeout(connectServer, wait);
if (state.wsRetry === 1) log('lost the church server — retrying', 'err');
};
ws.onerror = () => { try { ws.close(); } catch (_) {} };
}
/* The operator page edits a plain list of "heard => correct" lines; the engine
wants something it can apply. Accept either shape. */
function normalizeGlossary(g) {
const out = { fixes: [], overrides: (g && g.overrides) || {} };
const raw = (g && (g.fixes || g.bias)) || [];
for (const item of raw) {
if (!item) continue;
if (typeof item === 'string') {
const parts = item.split('=>');
if (parts.length === 2 && parts[0].trim() && parts[1].trim()) {
out.fixes.push({ from: parts[0].trim(), to: parts[1].trim() });
}
} else if (item.from && item.to) {
out.fixes.push({ from: String(item.from), to: String(item.to) });
}
}
return out;
}
function goLive(on) {
if (state.live === on) return;
state.live = on;
if (on) {
state.degradedFinals = 0; state.captionOnlyFinals = 0; state.fragmentFinals = 0;
state.captionOnlyAt = 0; state.degradedAt = 0;
/* The watchdog measures from the start of the service, not from the last
sentence of the previous one. */
state.lastFinalAt = Date.now();
state.deafWarned = false;
}
try { window.engineHost.setLive(on); } catch (_) {}
queue.length = 0;
state.segment = [];
state.preroll = [];
state.carry = [];
state.carriedCount = 0;
state.dip = 0;
state.speaking = false;
micReset();
partialNext = null;
updateIdleHero();
log(on ? ('LIVE — translating into ' + (state.targets.length || 0) + ' language(s)') : 'stopped',
on ? 'ok' : undefined);
}
/*
* Say which of the several "nothing is appearing" states we are actually in.
*
* The level meter moves whenever audio arrives, whether or not translation is
* running — so a stopped engine and a working one look identical at a glance,
* and "it's picking up my voice but not translating" is the natural conclusion.
* Likewise, translating with nobody listening produces no visible output at all,
* because languages are only translated when somebody has chosen them.
*/
function updateIdleHero() {
if (!state.modelsReady) return;
if (!state.audioCtx) {
hero('error', 'No audio input', 'Choose an input below.');
return;
}
if (!state.ws || state.ws.readyState !== 1) {
hero('error', 'Server not running', 'Restarting.');
return;
}
const hearing = state.peak > 0.002;
if (state.live) {
if (!state.targets.length) {
hero('live', 'Translating — nobody listening yet',
'A language is only translated once someone picks it.');
} else {
hero('live', 'Translating',
'Into ' + state.targets.length + ' language' + (state.targets.length === 1 ? '' : 's') + '.');
}
return;
}
hero('ready', hearing ? 'Ready — hearing audio' : 'Ready',
hearing ? 'Press START to begin.' : 'Waiting for START.');
}
function reportStatus() {
send({
t: 'status',
status: {
device: state.cfg.deviceLabel || 'unknown',
model: (state.cfg.asrModel || '').split('/').pop(),
fastModel: state.asrFastWorker ? (state.cfg.asrFastModel || '').split('/').pop() : null,
rms: Number(state.rms.toFixed(5)),
peak: Number(state.peak.toFixed(5)),
peakDb: state.peak > 0 ? Math.round(20 * Math.log10(state.peak)) : -99,
lagMs: state.lagMs,
modelsReady: state.modelsReady,
queue: queue.length,
degradedFinals: state.degradedFinals,
captionOnlyFinals: state.captionOnlyFinals,
/* WHEN, not just how many — the verdict needs to know whether this is
happening now or happened once an hour ago. */
captionOnlyAt: state.captionOnlyAt || 0,
degradedAt: state.degradedAt || 0,
fragmentFinals: state.fragmentFinals || 0,
cloud: state.cloudStats || null
}
});
}
setInterval(() => {
if (state.ws && state.ws.readyState === 1) reportStatus();
/* Refresh before clearing the hold, so "hearing audio" reflects the window
just gone rather than this instant. */
const st = $('hero').dataset.state;
if (st === 'ready' || st === 'live') updateIdleHero();
state.peak = 0;
}, 3000);
// ────────────────────────────────────────────────────────── cache + wiring
async function refreshCacheInfo() {
try {
const info = await window.engineHost.cacheInfo();
$('cacheSize').textContent = (info.bytes / 1073741824).toFixed(2) + ' GB on disk';
} catch (_) {}
}
$('device').addEventListener('change', async (e) => {
try {
await startCapture(e.target.value);
updateIdleHero();
} catch (err) {
log('could not open that input: ' + err.message, 'err');
hero('error', 'Could not open that input', err.message);
}
});
/*
* Audio devices come and go while the app is open — Dante Virtual Soundcard
* registers its driver when someone starts DVS, which is very often *after*
* this app launched. Without listening for that, the picker keeps showing the
* list from startup and it looks like Dante simply is not supported.
*/
async function rescanDevices({ auto } = {}) {
const before = $('device').value;
await listDevices(state.cfg.deviceId);
const after = $('device').value;
if (after === before) { if (!auto) log('rescanned — no change'); return; }
const label = ($('device').selectedOptions[0] || {}).textContent || after;
if (state.live) {
/* Never yank the input out from under a live service. */
log('"' + label + '" appeared, but translation is running — not switching now', 'err');
$('device').value = before;
return;
}
try {
await startCapture(after);
log('switched to "' + label + '"', 'ok');
updateIdleHero();
} catch (e) {
log('could not open "' + label + '": ' + e.message, 'err');
}
}
$('refreshDevices').addEventListener('click', () => rescanDevices());
$('chanMixAll').addEventListener('click', () => setChannels([]));
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener) {
navigator.mediaDevices.addEventListener('devicechange', () => {
/* Chromium fires this more than once per actual change; settle first. */
clearTimeout(state.deviceChangeTimer);
state.deviceChangeTimer = setTimeout(() => {
rescanDevices({ auto: true });
/* A Dante or SoundGrid driver restarting takes its OUTPUT with it too,
and a stale sink id is an output that silently plays nowhere. */
/* A driver that restarts with a new channel count keeps its id, so the
count has to be re-read rather than assumed unchanged. */
rescanOutputs(true);
}, 600);
});
}
/*
* The sensitivity SLIDER is gone; the value is not.
*
* Why it looked like it did nothing, which is the reason it was removed:
* the gate is `max(noiseFloor * sensitivity, 0.0012)`. On a clean digital feed
* — Dante, a console send — the noise floor decays to almost zero, so
* `noiseFloor * sensitivity` sits well under the 0.0012 absolute floor and the
* floor wins no matter where the slider is. It only ever bit on a genuinely
* noisy analogue input. Silently deleting the value would have changed
* behaviour on those inputs, so it stays in config and is still read below.
*
* WHAT REPLACED IT is below: an absolute gate in dB. A multiplier on a learned
* floor cannot be set to a number, cannot be seen on the meter, and on the feed
* where it matters is multiplying a value that is already wrong. A dB figure
* the operator reads off the same meter the voice moves has none of those
* problems, and it is the same number whichever input is plugged in.
*/
/*
* THE GATE, BY HAND.
*
* Automatic stays the default and stays untouched — a console or Dante feed
* needs nothing else. This exists for the analogue case: a preamp with audible
* hiss teaches the floor a high value, the gate rides up with it, and a quiet
* talker never crosses. The meter shows the voice moving and nothing is
* transcribed, which reads as a broken mic.
*
* -60 to -20 dBFS because that is the range the meter draws; outside it the
* notch would sit off the end of the bar the operator is aiming with.
*/
const THRESH_DEFAULT_DB = -45;
function applyThreshFromConfig() {
const saved = state.cfg && state.cfg.threshDb;
const db = Number(saved);
const on = saved !== null && saved !== undefined && Number.isFinite(db);
state.threshDb = on ? Math.max(-60, Math.min(-20, db)) : null;
$('threshManual').checked = on;
$('threshSlider').disabled = !on;
$('threshSlider').value = String(state.threshDb === null ? THRESH_DEFAULT_DB : state.threshDb);
renderThresh();
}
function renderThresh() {
const on = state.threshDb !== null;
$('threshDb').textContent = on ? state.threshDb + ' dB' : 'auto';
$('threshHint').textContent = on
? 'Pinned at ' + state.threshDb + ' dB. The notch no longer follows the room — speech is ' +
'anything above this line and nothing below it. Watch the bar while the room is empty: ' +
'the notch wants to sit just above where it rests, and under where the voice reaches.'
: 'Following the room: the notch sits above whatever the input is hearing when nobody is ' +
'talking. Right on a console or Dante feed. On a preamp with real hiss it can settle above ' +
'a quiet talker, and the mic goes dead while the bar still moves — set it by hand and put ' +
'the notch just above the room, under the voice.';
}
let threshSaveTimer = null;
function saveThresh() {
clearTimeout(threshSaveTimer);
/* Heard on the very next frame; written once the operator stops dragging. */
threshSaveTimer = setTimeout(() => {
window.engineHost.setConfig({ threshDb: state.threshDb })
.then((c) => { state.cfg = c; }).catch(() => {});
}, 400);
}
$('threshManual').addEventListener('change', (e) => {
const on = !!e.target.checked;
/* Taking it over starts from where the automatic gate is RIGHT NOW, not from
a default — the operator is correcting what they can see, and a jump to
some other number would throw away the thing they were looking at. */
if (on) {
const live = Math.max(state.noiseFloor * state.sensitivity, 0.0012);
const db = Math.round(20 * Math.log10(Math.max(live, 1e-6)));
state.threshDb = Math.max(-60, Math.min(-20, db));
$('threshSlider').value = String(state.threshDb);
} else {
state.threshDb = null;
}
$('threshSlider').disabled = !on;
renderThresh();
saveThresh();
log(on ? 'speech gate pinned at ' + state.threshDb + ' dB'
: 'speech gate following the room again', 'ok');
});
$('threshSlider').addEventListener('input', (e) => {
if (state.threshDb === null) return;
state.threshDb = Number(e.target.value);
renderThresh();
saveThresh();
});
let instructRelabel = null;
function onInstructTyped() {
clearTimeout(instructRelabel);
instructRelabel = setTimeout(() => { if (typeof renderVoiceList === 'function') renderVoiceList(); }, 250);
}
$('voiceInstruct').addEventListener('input', onInstructTyped);
$('voiceInstructClear').addEventListener('click', () => {
$('voiceInstruct').value = '';
onInstructTyped();
});
$('optDynamics').addEventListener('change', async (e) => {
const on = !!e.target.checked;
state.cfg = await window.engineHost.setConfig({ dynamics: on });
/* Forget the running reference, so turning it on mid-service starts from
what the room is doing now rather than an hour-old average. */
state.normalRms = 0;
log(on ? 'voice dynamics on — following the speaker, compressed to ~14 dB'
: 'voice dynamics off — every sentence at the voice\u2019s own level', 'ok');
});
/*
* ─────────────────────────── updates from our own forge ───────────────────
*
* Asked for, never volunteered. A background check that pops "an update is
* available" three minutes into a sermon is a hazard, not a feature, so this
* runs only when the operator presses the button and downloads only when they
* press the second one.
*/
function fmtMB(n) { return (Number(n || 0) / 1048576).toFixed(0) + ' MB'; }
let updFound = null;
async function checkForUpdate() {
const btn = $('updCheck');
btn.disabled = true;
$('updState').textContent = 'Checking…';
$('updFound').hidden = true;
try {
const r = await window.engineHost.updateCheck();
const here = r.current ? (r.current.version + ' \u00b7 ' + r.current.build) : 'this build';
if (!r.ok) { $('updState').textContent = r.error || 'Could not reach the update server.'; return; }
if (!r.latest) { $('updState').textContent = r.note || 'Nothing published yet. Running ' + here + '.'; return; }
const there = r.latest.tag;
if (!r.newer) { $('updState').textContent = 'Up to date \u2014 running ' + here + ', newest is ' + there + '.'; return; }
if (!r.latest.asset) {
$('updState').textContent = there + ' is newer, but it has no installer attached.';
return;
}
updFound = r.latest;
$('updState').textContent = 'Update available.';
$('updWhat').textContent = r.latest.title + ' \u2014 ' + fmtMB(r.latest.asset.size) +
'. You are on ' + here + '.';
$('updNotes').textContent = (r.latest.notes || '').trim() || 'No notes.';
$('updNotesBox').hidden = !r.latest.notes;
$('updFound').hidden = false;
$('updProgress').textContent = '';
$('updGet').disabled = false;
} catch (e) {
$('updState').textContent = 'Could not check: ' + e.message;
} finally {
btn.disabled = false;
}
}
async function downloadUpdate() {
if (!updFound || !updFound.asset) return;
const btn = $('updGet');
btn.disabled = true;
$('updProgress').textContent = 'Starting…';
try {
const r = await window.engineHost.updateDownload(updFound.asset.url, updFound.asset.name);
if (!r.ok) { $('updProgress').textContent = r.error || 'Download failed.'; btn.disabled = false; return; }
$('updProgress').textContent = 'Downloaded. Installing\u2026';
log('update ' + updFound.tag + ' downloaded to ' + r.path, 'ok');
/*
* Straight into the install. The app quits, a helper swaps the bundle and
* reopens it — so from here the window closing IS the update working, and
* saying so before it happens stops that looking like a crash.
*/
$('updWhat').textContent = 'Installing ' + updFound.tag + '. Church Translate will close and ' +
'reopen by itself in a few seconds. Your settings, models and voices are untouched.';
const ins = await window.engineHost.updateInstall(r.path);
if (!ins || !ins.ok) {
/* Still useful: the file is downloaded, so fall back to the manual route
rather than leaving them with nothing. */
$('updProgress').textContent = (ins && ins.error) || 'Could not install.';
$('updWhat').textContent = 'Downloaded, but it could not install itself. Quit Church ' +
'Translate, open the disk image in Downloads, and drag the app over the one in Applications.';
try { await window.engineHost.updateReveal(r.path); } catch (_) {}
btn.disabled = false;
}
} catch (e) {
$('updProgress').textContent = 'Download failed: ' + e.message;
btn.disabled = false;
}
}
$('updCheck').addEventListener('click', checkForUpdate);
$('updGet').addEventListener('click', downloadUpdate);
if (window.engineHost.onUpdateProgress) {
window.engineHost.onUpdateProgress((p) => {
if (!p || !p.total) { $('updProgress').textContent = fmtMB(p && p.got); return; }
$('updProgress').textContent = Math.round((p.got / p.total) * 100) + '% (' +
fmtMB(p.got) + ' of ' + fmtMB(p.total) + ')';
});
}
/* The token is a credential, so it is saved on blur rather than per keystroke
and never logged. */
$('updToken').addEventListener('change', async () => {
const v = $('updToken').value.trim();
state.cfg = await window.engineHost.setConfig({ updateToken: v });
$('updState').textContent = v ? 'Token saved.' : 'Token cleared.';
});
$('revealCache').addEventListener('click', () => window.engineHost.revealCache());
/* ---- carrying a setup to another campus ------------------------------- */
$('setExport').addEventListener('click', async () => {
const h = $('setHint');
h.style.color = '';
h.textContent = 'Saving…';
try {
const r = await window.engineHost.settingsExport();
if (r.canceled) { h.textContent = ''; return; }
if (!r.ok) throw new Error(r.error || 'could not save');
h.textContent = 'Saved — ' + r.fixes + ' corrections and ' + r.languages +
' languages. Open this file on the other campus\u2019s Mac.';
} catch (e) {
h.style.color = 'var(--stop)';
h.textContent = 'Could not save: ' + e.message;
}
});
$('setImport').addEventListener('click', async () => {
const h = $('setHint');
h.style.color = '';
h.textContent = 'Loading…';
try {
const r = await window.engineHost.settingsImport();
if (r.canceled) { h.textContent = ''; return; }
if (!r.ok) throw new Error(r.error || 'could not load');
h.textContent = 'Loaded ' + r.fixes + ' corrections and ' + r.languages + ' languages' +
(r.from ? ' from ' + r.from : '') + '. This Mac kept its own PIN and device settings.';
/* Everything downstream reads from config, so pull it all again rather
than guessing which panels the file happened to touch. */
state.cfg = await window.engineHost.getConfig();
await loadOperator();
await rescanOutputs(true);
renderVoiceLangs && renderVoiceLangs();
} catch (e) {
h.style.color = 'var(--stop)';
h.textContent = 'Could not load: ' + e.message;
}
});
/*
* ───────────────────────── output routing ─────────────────────────────────
*
* Each language's voice to its own physical channels. See out-routing.js for
* why this is possible on the output side and impossible on the input side.
*/
let outDevices = [];
async function rescanOutputs(force) {
try {
outDevices = (await navigator.mediaDevices.enumerateDevices())
.filter((d) => d.kind === 'audiooutput' && d.deviceId && d.deviceId !== 'communications');
} catch (e) {
outDevices = [];
log('could not list outputs: ' + e.message, 'err');
}
const sel = $('outDevice');
const want = (state.cfg && state.cfg.outputDeviceId) || '';
sel.textContent = '';
const off = document.createElement('option');
off.value = ''; off.textContent = 'Off — phones only';
sel.appendChild(off);
for (const d of outDevices) {
const o = document.createElement('option');
o.value = d.deviceId;
/* A 'default' entry follows the system output around, which is the last
thing you want a service routed through. */
o.textContent = (d.label || 'Output') + (d.deviceId === 'default' ? ' (system default)' : '');
sel.appendChild(o);
}
/*
* Find the saved output again by NAME when the id no longer matches.
*
* enumerateDevices ids are not portable and not even stable: Core Audio
* reissues them across reboots and re-plugs, and they differ entirely on a
* different Mac. The input picker has always done this; the output picker did
* not, so moving this app to another campus — or just restarting — would
* silently drop routing back to "Off" with every language still mapped and
* nothing coming out of the hardware.
*
* The same "Default - X" decoration Chromium adds to inputs applies here.
*/
const strip = (l) => String(l || '').toLowerCase().replace(/^default\s*-\s*/, '').trim();
const wantLabel = strip(state.cfg && state.cfg.outputDeviceLabel);
const real = outDevices.filter((d) => d.deviceId !== 'default' && d.deviceId !== 'communications');
const pool = real.length ? real : outDevices;
const byId = want && pool.find((d) => d.deviceId === want);
const byExact = wantLabel && pool.find((d) => strip(d.label) === wantLabel);
const byPartial = wantLabel && pool.find((d) => strip(d.label).includes(wantLabel));
const chosen = byId || byExact || byPartial || null;
sel.value = chosen ? chosen.deviceId : '';
if (chosen && !byId && wantLabel) {
log('output "' + state.cfg.outputDeviceLabel + '" found again under a new id', 'ok');
/* Write the new id back so the next start is a plain id match. */
window.engineHost.setConfig({ outputDeviceId: chosen.deviceId })
.then((c) => { state.cfg = c; }).catch(() => {});
} else if (!chosen && wantLabel) {
log('saved output "' + state.cfg.outputDeviceLabel + '" is not connected — hardware output is off', 'err');
}
await applyOutput(force);
await renderInputTruth();
}
async function applyOutput(force) {
const deviceId = $('outDevice').value;
const map = (state.cfg && state.cfg.outputMap) || {};
/* `force` re-reads the channel count for a device that has not changed
identity. Raising a Dante Virtual Soundcard from 8x8 to 64x64 keeps the
same device id, so without this the app would go on offering 8. */
const gain = (state.cfg && state.cfg.outputGain) || {};
const r = await window.OutRouting.configure({ deviceId, map, gain, onLog: log, force: !!force });
$('outMapBox').hidden = !(r.ok && r.channels > 0);
if (!deviceId) {
const missing = state.cfg && state.cfg.outputDeviceLabel;
$('outState').textContent = missing
? 'Off — "' + missing + '" is not connected on this Mac. Pick a device and the ' +
'channel assignments below come back as they were.'
: 'Off. Listeners on phones are unaffected either way.';
} else if (!r.ok) {
$('outState').textContent = 'Could not open that device: ' + (r.error || 'unknown');
} else {
$('outState').textContent = r.channels + ' channel' + (r.channels === 1 ? '' : 's') +
' available' + (r.max && r.max !== r.channels ? ' (device offers ' + r.max + ')' : '') + '.';
}
state.outChannels = r.channels || 0;
/*
* SAY WHEN A LANGUAGE IS ASSIGNED TO CHANNELS THIS DEVICE DOES NOT HAVE.
*
* channelsFor() filters indexes at or past the real channel count, so a map
* written for an 8-channel Dante card and then reopened on a 2-output
* interface routes four of five languages to nothing — silently. Every clip
* for them is discarded inside OutRouting with no error, because that path
* swallows its own failures by design.
*
* MEASURED on this Mac: por_Latn, spa_ES, spa_MX and por_PT were all mapped
* to channels 4-7 on a 2-output Scarlett. A listener on any of those packs
* hears silence for the whole service while their phone works perfectly and
* this panel still shows the language as routed. The map is deliberately NOT
* rewritten — it is correct for the Dante rig and must survive the trip — so
* the fix is to say so out loud.
*/
if (r.ok && r.channels > 0) {
const lost = [];
for (const lang of Object.keys(map)) {
const want = (map[lang] || []).map(Number).filter((n) => Number.isInteger(n) && n >= 0);
if (want.length && !want.some((n) => n < r.channels)) {
const l = window.Languages && window.Languages.get(lang);
lost.push((l ? l.name : lang) + ' (channel' + (want.length === 1 ? ' ' : 's ') +
want.map((n) => n + 1).join(', ') + ')');
}
}
if (lost.length) {
log('these languages are routed to channels this device does not have and will be ' +
'SILENT on hardware: ' + lost.join('; ') + ' — this device has ' + r.channels +
'. Phones are unaffected.', 'err');
}
}
renderOutMap();
renderSummaries();
}
/*
* The input channel count the app can reach, next to the one the device
* actually has.
*
* MEASURED: Chromium's macOS input path delivers 2 channels whatever the
* device presents, and refuses `channelCount: {exact: 3+}` outright. So a card
* configured 64x64 arrives here as 2, and without saying so the app looks
* broken to the person who just configured it.
*/
async function renderInputTruth() {
const note = $('chanNote');
if (!note) return;
let real = 0;
try {
const label = (state.cfg && state.cfg.deviceLabel) || '';
const devs = await window.engineHost.audioDevices();
/* Core Audio's name is the label without Chromium's " (Virtual)" suffix. */
const hit = (devs || []).find((d) => d.inputs > 0 && label.indexOf(d.name) === 0);
real = hit ? hit.inputs : 0;
} catch (_) {}
state.deviceInputs = real;
const el = $('chanTruth');
if (!el) return;
const got = state.channels || 0;
if (real > got && got > 0) {
el.hidden = false;
el.textContent = 'This device has ' + real + ' input channels, but macOS hands this app ' +
got + '. That is a limit of the audio system, not a setting — subscribe the channel you ' +
'want to channels 1-' + got + ' in Dante Controller or SoundGrid Studio. Outputs are not ' +
'limited this way.';
} else {
el.hidden = true;
}
}
/* Every language the service can actually speak, so the matrix lists what is
really available rather than all 106. */
function outLanguages() {
const langs = (state.targets || []).slice();
const src = state.source;
if (src && langs.indexOf(src) === -1) langs.unshift(src);
return langs;
}
function renderOutMap() {
const box = $('outMap');
const n = state.outChannels || 0;
box.textContent = '';
/* Past sixteen the buttons step down a size; at 64 a full-size row would not
fit anything sensible. */
box.classList.toggle('many', n > 16);
if (!n) return;
const map = (state.cfg && state.cfg.outputMap) || {};
for (const lang of outLanguages()) {
const row = document.createElement('div');
const on = (map[lang] || []).filter((c) => c < n);
row.className = 'outrow' + (on.length ? '' : ' none');
const name = document.createElement('b');
const l = window.Languages && window.Languages.get(lang);
name.textContent = l ? l.name : lang;
row.appendChild(name);
const chans = document.createElement('div');
chans.className = 'outchans';
for (let c = 0; c < n; c++) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'outch' + (on.indexOf(c) !== -1 ? ' on' : '');
b.textContent = String(c + 1);
b.addEventListener('click', () => toggleOutChannel(lang, c));
chans.appendChild(b);
}
row.appendChild(chans);
/*
* What this row is actually doing, in words. Two selected channels look
* identical to two unrelated ones until you say "stereo pair", and the
* source-language row needs saying loudest of all — it carries the live
* room microphone, not a translation, because there is nothing to
* translate English into during an English service.
*/
const tag = document.createElement('span');
tag.className = 'outtag';
const sorted = on.slice().sort((a, b) => a - b);
const adjacent = sorted.length === 2 && sorted[1] === sorted[0] + 1;
if (!sorted.length) tag.textContent = 'not sent';
else if (adjacent) tag.textContent = 'stereo ' + (sorted[0] + 1) + '+' + (sorted[1] + 1);
else if (sorted.length === 1) tag.textContent = 'mono ' + (sorted[0] + 1);
else tag.textContent = sorted.map((c) => c + 1).join(', ');
if (lang === state.source && sorted.length) tag.textContent += ' · room mic';
row.appendChild(tag);
/*
* Level trim. A transmitter's line input wants a level nothing upstream
* knows about, and every voice is a different loudness — without this the
* only way to match a hardware input is a screwdriver on the transmitter,
* per language, which is not a thing anyone can do once packs are handed
* out.
*/
const trim = document.createElement('input');
trim.type = 'range';
trim.className = 'outtrim';
trim.min = '-20'; trim.max = '12'; trim.step = '1';
trim.value = String(Number((state.cfg && state.cfg.outputGain || {})[lang]) || 0);
trim.title = 'Output level for this language';
trim.disabled = !sorted.length;
const db = document.createElement('span');
db.className = 'outdb';
const showDb = () => { db.textContent = (trim.value > 0 ? '+' : '') + trim.value + ' dB'; };
showDb();
trim.addEventListener('input', () => { showDb(); setOutGain(lang, Number(trim.value)); });
row.appendChild(trim);
row.appendChild(db);
box.appendChild(row);
}
}
let outGainTimer = null;
function setOutGain(lang, db) {
const gain = Object.assign({}, (state.cfg && state.cfg.outputGain) || {});
if (db) gain[lang] = db; else delete gain[lang];
if (state.cfg) state.cfg.outputGain = gain;
/* Heard immediately, written to disk once the operator stops dragging. */
window.OutRouting.setGain(gain);
clearTimeout(outGainTimer);
outGainTimer = setTimeout(() => {
window.engineHost.setConfig({ outputGain: gain }).then((c) => { state.cfg = c; });
}, 400);
}
async function toggleOutChannel(lang, ch) {
const map = Object.assign({}, (state.cfg && state.cfg.outputMap) || {});
const cur = new Set(map[lang] || []);
if (cur.has(ch)) cur.delete(ch); else cur.add(ch);
map[lang] = Array.from(cur).sort((a, b) => a - b);
if (!map[lang].length) delete map[lang];
state.cfg = await window.engineHost.setConfig({ outputMap: map });
/* No graph rebuild — the map is read per clip, so this takes effect on the
next sentence and never interrupts one that is playing. */
window.OutRouting.setMap(map);
renderOutMap();
renderSummaries();
}
$('outDevice').addEventListener('change', async () => {
const sel = $('outDevice');
/* The label is the durable handle — see rescanOutputs. */
state.cfg = await window.engineHost.setConfig({
outputDeviceId: sel.value,
outputDeviceLabel: sel.value ? (sel.options[sel.selectedIndex] || {}).textContent || '' : ''
});
await applyOutput();
});
$('outRefresh').addEventListener('click', () => rescanOutputs(true));
/* ---- the address the congregation opens ------------------------------- */
/*
* Which address the QR points at, and whether anything about that is wrong.
*
* The address is a DHCP lease. A printed QR outlives it: a router reboot, or
* the Mac being off past the lease, and the number the poster shows belongs to
* something else. Worse, if Wi-Fi drops for a moment the interface list is
* down to the wired NIC — on this Mac that is the Dante/NAS network, which no
* phone can route to — and the QR would quietly re-point there. It looks
* perfectly healthy in both cases; the only symptom is a room full of people
* whose phones will not load the page.
*
* So: prefer the pinned address, then Wi-Fi, then whatever is left, and say
* out loud when the answer is not the one that was pinned.
*/
function shareChoice() {
if (!state.local) return null;
const list = state.local.addresses || [];
if (!list.length) return { none: true };
const saved = state.cfg && state.cfg.shareAddress;
const pinned = saved ? list.find((a) => a.address === saved) : null;
const chosen = pinned || list.find((a) => a.wifi) || list[0];
return {
addr: chosen,
/* A pin that no longer matches anything is the DHCP case. */
lostPin: !!(saved && !pinned),
was: saved || '',
anyWifi: list.some((a) => a.wifi)
};
}
/* This Mac on the church network. The operator page lives here and NOWHERE
else: it returns 404 to anything arriving through a proxy, by design. */
function lanBase() {
const c = shareChoice();
if (!c || c.none) return '';
return 'http://' + c.addr.address + ':' + state.local.port;
}
/*
* WHAT THE CONGREGATION OPENS.
*
* A church that has put this on a domain has said so in the operator page's
* Public address, and from that moment the LAN address is the wrong thing to
* print: it is not what is on the poster, not what the QR should encode, and
* not what someone off the building's wifi can reach. The app window went on
* showing the IP anyway, because this half of it never read that setting --
* the operator page honoured it and the main window did not, so the two
* disagreed about the one fact they both exist to display.
*/
function publicBaseConfigured() {
const u = opConfig && typeof opConfig.publicUrl === 'string' ? opConfig.publicUrl.trim() : '';
return u ? u.replace(/\/+$/, '') : '';
}
function shareBase() {
return publicBaseConfigured() || lanBase();
}
function renderQrWarning() {
const el = $('qrWarn');
/*
* On a domain the warnings below are about an address nobody is being
* given any more. Saying "Wi-Fi is down, phones cannot reach the code"
* while the code points at a proxy would send the desk after the wrong
* thing entirely.
*/
const pub = publicBaseConfigured();
if (pub) {
el.hidden = false;
el.textContent = 'The code points at ' + pub + ', set as the public address. ' +
'This Mac still has to be reachable from there; its own address is ' +
(lanBase() || 'unavailable') + '.';
return;
}
const c = shareChoice();
if (!c) { el.hidden = true; return; }
let head = '', rest = '';
if (c.none) {
head = 'No network.';
rest = ' Nothing can reach this Mac right now, so the code below goes nowhere. ' +
'Check Wi-Fi.';
} else if (!c.anyWifi) {
/* Ahead of the changed-address case on purpose: when Wi-Fi is gone, the
address moving is the symptom and "print a new poster" is the wrong
instruction. Reconnecting Wi-Fi is the fix. */
head = 'Wi-Fi is down.';
rest = ' The code has fallen back to ' + c.addr.address + ' on ' +
(c.addr.label || 'a wired network') + ', which phones cannot reach. ' +
'Reconnect this Mac to Wi-Fi and the old address comes back.';
} else if (c.lostPin) {
head = 'The address changed.';
rest = ' This Mac was on ' + c.was + ' and is now on ' + c.addr.address +
' — a printed QR with the old number will not work. Print a new poster, ' +
'or ask for a fixed address on the router.';
} else if (!c.addr.wifi) {
head = 'This is not the Wi-Fi address.';
rest = ' The code points at ' + c.addr.address + ' on ' +
(c.addr.label || 'a wired network') + '. Phones are on Wi-Fi and almost ' +
'certainly cannot reach that.';
}
/* Built as nodes, not markup: the interface label comes from the OS. */
el.textContent = '';
if (head) {
const b = document.createElement('b');
b.textContent = head;
el.appendChild(b);
el.appendChild(document.createTextNode(rest));
}
el.hidden = !head;
}
/*
* How many people are reading, and in what.
*
* The total answers "is anyone out there". The breakdown answers the question
* a church actually has at the end of a service — which languages earned their
* place, and whether the one nobody asked for is quietly being used.
*/
function renderShare() {
if (!state.local) return;
const list = state.local.addresses || [];
const box = $('addrList');
box.textContent = '';
/*
* WHICH INTERFACE THE QR USES IS A QUESTION ONLY A LAN SETUP HAS.
*
* These rows let the operator pick the address the code points at. Once a
* public address is configured the code points at the domain whatever they
* pick, so the list is a row of numbers that do nothing — and the one
* marked IN USE is not the address anyone is being given. Hide it.
*/
const addrBox = $('addrList');
addrBox.hidden = !!publicBaseConfigured();
if (addrBox.hidden) {
/* Still draw the code itself — it is the only thing left in this panel. */
const only = shareBase();
$('qr').src = only
? 'http://127.0.0.1:' + state.local.port + '/qr.svg?url=' + encodeURIComponent(only + '/')
: '';
renderQrWarning();
renderOperatorUrl();
return;
}
/* Mark the row the QR is actually using, not the row that was saved — when
a lease moves those are two different things and the saved one is gone. */
const chosen = shareChoice();
const savedAddr = chosen && chosen.addr ? chosen.addr.address : '';
list.forEach((a) => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'addr' + (a.address === savedAddr ? ' is-chosen' : '');
const b = document.createElement('b');
b.textContent = a.address + ':' + state.local.port;
const tag = document.createElement('span');
/* Name the network, not just the number — 'Wi-Fi' is the one phones can
reach, and that is not guessable from an IP. */
tag.textContent = (a.address === savedAddr ? 'in use' : 'use this') + (a.label ? ' · ' + a.label : '');
row.appendChild(b);
row.appendChild(tag);
row.addEventListener('click', async () => {
state.cfg = await window.engineHost.setConfig({ shareAddress: a.address });
renderShare();
});
box.appendChild(row);
});
const base = shareBase();
$('qr').src = base
? 'http://127.0.0.1:' + state.local.port + '/qr.svg?url=' + encodeURIComponent(base + '/')
: '';
renderQrWarning();
renderOperatorUrl();
}
$('copyAddr').addEventListener('click', () => {
const base = shareBase();
if (!base) return;
window.engineHost.copyText(base + '/')
.then(() => log('copied ' + base + '/', 'ok'))
.catch((err) => log('could not copy: ' + err.message, 'err'));
});
/*
* The QR itself, saved as a file, because the poster people actually print
* is made in their own design tool. The built-in poster page is still there
* at /poster for anyone who wants it; it is just not what this button does.
*/
$('saveQr').addEventListener('click', async () => {
const base = shareBase();
if (!base) { log('no address to encode yet', 'err'); return; }
/*
* Fetch the code and hand the browser a BLOB rather than linking straight at
* the server. This window is served from the asset port and the QR comes off
* the congregation port, so a plain <a download> is CROSS-ORIGIN — the
* download attribute is ignored and the window navigates to the image
* instead. With no back button, no reload item and no address bar, that
* strands the operator on a full-screen QR with no way out; build 123 did
* exactly that to a booth Mac. A blob: URL is same-origin, so it downloads.
*/
const url = 'http://127.0.0.1:' + state.local.port + '/qr.svg?url=' +
encodeURIComponent(base + '/');
try {
const r = await fetch(url, { cache: 'no-store' });
if (!r.ok) throw new Error('the server answered ' + r.status);
const href = URL.createObjectURL(await r.blob());
const a = document.createElement('a');
a.href = href;
a.download = 'church-translate-qr.svg';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(href), 10000);
log('saved the QR for ' + base + '/ to Downloads', 'ok');
} catch (err) {
log('could not save the QR: ' + err.message, 'err');
}
});
$('openOperator').addEventListener('click', () => {
window.open('http://127.0.0.1:' + state.local.port + '/operator', '_blank');
});
/*
* The operator page's address on the NETWORK, not 127.0.0.1 — the whole point
* of it is running the desk from a phone, and localhost is the one address a
* phone cannot use. Follows whichever interface the QR is using, so the two
* can never disagree.
*/
/*
* What each collapsed section says about itself. A section you have to open to
* find out whether it is set up correctly is a section you will open every
* time, which defeats collapsing it.
*/
/* Says where the desk can be reached from, because that is what guards it —
not how long the PIN is. */
function renderPinStrength() {
const pin = String((opConfig && opConfig.operatorPin) || '');
const el = $('opPinHint');
if (!pin || document.activeElement === $('opPin')) return;
el.textContent = pin.length + ' characters · local network only.';
el.style.color = '';
}
function renderSummaries() {
const dev = (state.cfg && state.cfg.deviceLabel) || '';
const picked = pickedChannels();
const chan = !state.channels ? ''
: picked ? ' · ch ' + picked.map((c) => c + 1).join('+')
: (state.channels > 1 ? ' · all ' + state.channels + ' ch' : '');
$('inputSummary').textContent = dev ? dev + chan : 'no input yet';
const oc = state.outChannels || 0;
const routed = oc ? Object.keys((state.cfg && state.cfg.outputMap) || {})
.filter((l) => ((state.cfg.outputMap[l] || []).some((c) => c < oc))).length : 0;
$('outSummary').textContent = !oc ? 'off'
: routed + ' of ' + outLanguages().length + ' routed · ' + oc + ' ch';
if (opConfig) {
const on = (opConfig.featured || []).length;
const pin = (opConfig.pinned || []).length;
$('langSummary').textContent = on + ' on' + (pin ? ' · ' + pin + ' pinned' : '');
const d = Number(opConfig.streamDelaySec) || 0;
$('settingsSummary').textContent = d ? d + 's stream delay' : 'no stream delay';
}
}
function renderBuildTag() {
const l = state.local;
if (!l || !l.version) return;
$('buildTag').textContent = l.version + ' · ' + l.build;
}
function renderOperatorUrl() {
/* Deliberately lanBase, not shareBase: over a proxy /operator is a 404. */
const base = lanBase();
$('opPageUrl').textContent = base ? base + '/operator' : 'no network';
}
$('savePort').addEventListener('click', async () => {
const port = Number($('port').value);
if (!port || port < 1024 || port > 65535) { $('portHint').textContent = 'Pick a port between 1024 and 65535.'; return; }
await window.engineHost.setConfig({ port });
$('portHint').textContent = 'Saved — quit and reopen the app to use it.';
});
if (window.engineHost.onServerUp) {
window.engineHost.onServerUp((info) => {
state.local = info;
renderBuildTag();
renderShare();
/*
* RECONNECT ONLY IF WE ARE NOT ALREADY CONNECTED.
*
* main.js sends this both when the server really (re)starts AND every time
* the machine's ADDRESS LIST changes — Wi-Fi joining, a DHCP lease moving,
* a cable pulled. This socket is loopback; none of that concerns it. But
* reconnecting unconditionally dropped the live one, and the server treats
* an engine disconnect as "stop the service".
*
* MEASURED: switching Wi-Fi off mid-service stopped the translation
* outright — on a system whose entire point is running with no network at
* all, wired into the transmitters. A DHCP renewal would have done the
* same thing, silently, in the middle of a sermon.
*
* If the server genuinely restarted, this socket is already CLOSING or
* CLOSED and the test below reconnects. If it is OPEN or still CONNECTING,
* there is nothing to do.
*/
if (!state.ws || state.ws.readyState === 2 || state.ws.readyState === 3) connectServer();
});
}
if (window.engineHost.onServerDown) {
window.engineHost.onServerDown(() => {
setServerPip(false, 'Server restarting');
log('congregation server stopped — restarting it', 'err');
});
}
// ────────────────────────────────────────────────────────── boot
/*
* Set up the audio side: permission, then device list, then capture.
* Returns a short reason string on failure rather than throwing, because the
* caller wants to carry on regardless — the models are worth downloading even
* if the microphone is still waiting on a dialog.
*/
async function setupAudio() {
const status = await window.engineHost.micStatus();
console.log('boot: microphone permission = ' + status);
if (status !== 'granted') {
const ok = await window.engineHost.askMic();
console.log('boot: microphone granted = ' + ok);
if (!ok) return 'permission';
}
const inputs = await listDevices(state.cfg.deviceId);
console.log('boot: found ' + inputs.length + ' audio input(s)');
if (!inputs.length) return 'no-inputs';
try {
await startCapture($('device').value);
} catch (e) {
log('capture failed: ' + e.message, 'err');
return 'capture:' + e.message;
}
/* Outputs are listed after capture, because enumerateDevices only returns
real labels once media permission has actually been granted — before that
every device is a blank string and the picker is unusable. */
try { await rescanOutputs(); } catch (e) { log('output routing: ' + e.message, 'err'); }
return null;
}
function showAudioProblem(reason) {
if (reason === 'permission') {
hero('error', 'macOS is blocking audio input',
'Allow the microphone for this app in System Settings › Privacy & Security › Microphone, then reopen it.');
$('heroBtn').hidden = false;
$('heroBtn').textContent = 'Open Privacy Settings';
$('heroBtn').onclick = () => window.engineHost.openPrivacy();
} else if (reason === 'no-inputs') {
hero('error', 'No audio inputs',
'Connect Dante Virtual Soundcard, Waves SoundGrid, or the console feed, then press Rescan.');
} else {
hero('error', 'Could not open that input', reason.replace(/^capture:/, ''));
}
}
async function boot() {
console.log('boot: start');
if (!window.engineHost) { console.error('boot: preload bridge missing'); return; }
state.cfg = await window.engineHost.getConfig();
console.log('boot: config loaded, port=' + (state.cfg.port || 3100));
$('port').value = state.cfg.port || 3100;
$('modelInfo').textContent = state.cfg.asrModel + ' + ' + state.cfg.mtModel;
/* The server may already be up by the time the window paints. */
state.local = await window.engineHost.localInfo();
renderBuildTag();
renderShare();
/*
* Ask, do not only listen.
*
* The voices process starts with the app and usually reports ready before
* this page has finished loading, so the 'tts:ready' event fires into a
* renderer that is not listening yet and is simply lost. Captions kept
* working and no clip was ever generated — a silent failure with no error
* anywhere. Asking on boot closes that race.
*/
try {
const info = await window.engineHost.ttsInfo();
state.ttsReady = !!info.ready;
state.spokenLanguages = info.spoken || [];
state.spokenDefaults = info.defaults || state.spokenDefaults || {};
if (state.ttsReady) {
log('built-in voices ready — ' + state.spokenLanguages.length + ' languages', 'ok');
}
} catch (_) { /* captions do not depend on voices */ }
/* Config drives the DOM, never the reverse. */
/* Reopen on the pane the operator actually works from. */
/* Everything is on one page now, so this is unconditional. */
setMode();
$('optDynamics').checked = !!(state.cfg && state.cfg.dynamics);
const savedSens = Number(state.cfg && state.cfg.sensitivity);
state.sensitivity = Number.isFinite(savedSens) && savedSens > 0 ? savedSens : 3.5;
applyThreshFromConfig();
try { $('updToken').value = (state.cfg && state.cfg.updateToken) || ''; } catch (_) {}
refreshCacheInfo();
/* Start the download first and do not wait for it. It is the slow part, it
needs no permission, and on a first run the microphone prompt may sit
unanswered for minutes while somebody goes to find the volunteer. */
const models = loadModels().then(() => null).catch((e) => {
log('model load failed: ' + e.message, 'err');
return 'models:' + e.message;
});
const audioProblem = await setupAudio();
const modelProblem = await models;
if (modelProblem) {
/* An ONNX abort leaves the WASM instance unusable, so trying again in this
page would fail identically. Drop to smaller weights, remember that, and
restart the renderer once. */
if (!state.cfg.dtypeDowngraded) {
log('model load failed — retrying once with smaller weights', 'err');
/*
* ASR only. The abort being recovered from happens in THIS renderer,
* whose 32-bit WASM heap caps at 4GB — smaller ASR weights are the right
* answer there. Translation runs in a plain-Node process with a normal
* 64-bit heap and never had that problem, and quantising it is actively
* harmful: MEASURED, q8 NLLB is 2.4x SLOWER than fp32 in that runtime,
* and low-bit NLLB in ONNX Runtime emits fluent nonsense stitched out of
* a dozen unrelated languages. Downgrading mtDtype here turned one model
* load failure into permanently slower, permanently wrong translation —
* it persisted through dtypeDowngraded and nothing on screen said so.
*/
await window.engineHost.setConfig({
asrDtype: JSON.stringify({ encoder_model: 'q4', decoder_model_merged: 'q4' }),
dtypeDowngraded: true
});
location.reload();
return;
}
hero('error', 'The models could not load', modelProblem.replace(/^models:/, ''));
return;
}
connectServer();
if (audioProblem) showAudioProblem(audioProblem);
else updateIdleHero();
}
// ────────────────────────────────────────────────────────── operator mode
/*
* Setup and Operator are two faces of one window, not two windows.
*
* The operator page still exists and still works in a browser — it is what a
* volunteer on a phone at the sound desk uses. But the person at the Mac should
* not be juggling windows mid-service, so the same controls live here.
*
* Nothing is shown twice. The hero already states what the system is doing, so
* in operator mode it becomes the START/STOP control rather than sitting above
* a second button saying the same thing, and the listener counts moved here
* from the share panel instead of appearing in both.
*/
let opConfig = null;
function opBase() { return 'http://127.0.0.1:' + (state.local ? state.local.port : 3100); }
async function opApi(path, opts) {
opts = opts || {};
/*
* Prefer the PIN from the config we last loaded. state.local.operatorPin is a
* snapshot main.js took once when the server started and never refreshes, so
* the moment the operator changed their PIN every subsequent save came back
* 401 — languages and corrections silently stopped saving for the rest of the
* session, with the only clue on a pane the operator was not looking at.
*/
opts.headers = Object.assign(
{ 'X-Operator-Pin': (opConfig && opConfig.operatorPin) ||
(state.local && state.local.operatorPin) || '' },
opts.headers || {}
);
if (opts.body) opts.headers['Content-Type'] = 'application/json';
const r = await fetch(opBase() + path, opts);
const j = await r.json();
if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
return j;
}
/*
* There are no modes any more, and this is the fix for two separate faults.
*
* The page was split into "Setup" and "Operator" tabs, but every card in both
* was the same kind of thing: settings the operator configures. The split had
* no meaning to anyone using it — and it hid a real bug. loadOperator() was
* called ONLY from the tab switch, and the switch returned early when the mode
* had not changed, so on launch the operator half either never loaded at all or
* loaded once before the server child was listening and never tried again. The
* only way to populate it was to click Setup and then Operator, which changed
* the mode and re-ran the fetch.
*
* One page, everything always mounted, and the settings load retries until the
* server answers. Kept as a function because config still carries a uiMode and
* older configs will call in here.
*/
function setMode() {
$('heroGo').hidden = false;
$('opStats').hidden = false;
loadOperator();
renderOperatorLive();
}
/*
* The server child takes a few seconds to come up, and at launch this fetch
* loses that race. Retry until it wins — an empty settings page that never
* fills in is indistinguishable from a broken app.
*/
let opLoadTimer = null;
let opRetryMs = 0;
function loadOperatorSoon(delay) {
clearTimeout(opLoadTimer);
opLoadTimer = setTimeout(() => {
opLoadTimer = null;
loadOperator();
}, delay);
}
/*
* Stream Deck needs a button per action, not one shared toggle: an operator
* hitting a toggle cannot tell from the key cap whether the next press starts
* or stops, and during a service that guess is expensive. Start and stop are
* idempotent, so a Companion button that always means "on" always means on.
* Status is here so a button can show live/ready/offline instead of guessing.
*/
/*
* One list, used both to fill these in and to blank them when the server is
* unreachable. They were two hand-written lists, and the second one had
* already fallen behind the first once — an id that exists in one and not the
* other throws inside the error path, which is the worst possible place for it.
*/
const CONTROL_URLS = [
['opStartUrl', '/api/control/start'],
['opStopUrl', '/api/control/stop'],
['opToggleUrl', '/api/control/toggle'],
['opHoldUrl', '/api/control/hold'],
['opResumeUrl', '/api/control/resume'],
['opHoldToggleUrl', '/api/control/hold-toggle'],
['opDelayUpUrl', '/api/control/delay-up?by=1'],
['opDelayDownUrl', '/api/control/delay-down?by=1'],
['opDelayOffUrl', '/api/control/delay-off'],
['opStatusUrl', '/api/status.txt'],
['opHoldStateUrl', '/api/hold.txt'],
['opDelayStateUrl', '/api/delay.txt']
];
function renderControlUrls() {
const base = opBase();
const token = encodeURIComponent(opConfig.controlToken);
for (const [id, path] of CONTROL_URLS) {
/* Some of these already carry a query string (by=), so the key cannot
always be the first parameter. */
$(id).textContent = base + path + (path.indexOf('?') === -1 ? '?' : '&') + 'key=' + token;
}
renderSummaries();
renderPinStrength();
}
async function loadOperator() {
try {
const j = await opApi('/api/config');
opConfig = j.config;
renderShare(); /* publicUrl may have just arrived */
clearTimeout(opLoadTimer); opLoadTimer = null; // it answered; stop retrying
opRetryMs = 0;
renderOpLanguages();
/* Do not overwrite a draft the operator is in the middle of typing —
flicking to Setup to check the meter and back used to discard it with no
warning. */
const box = $('opFixes');
const onServer = ((opConfig.glossary && opConfig.glossary.fixes) || []).join('\n');
if (!box.dataset.dirty || box.value === onServer) {
box.value = onServer;
delete box.dataset.dirty;
} else {
$('opFixHint').textContent = 'You have unsaved changes here';
}
renderControlUrls();
$('opPin').value = opConfig.operatorPin || '';
$('opDelay').value = String(opConfig.streamDelaySec || 0);
renderCloudCard();
} catch (err) {
/* "None" everywhere reads as "you have no languages", which is a lie and
invites someone to re-add what is already there. */
opConfig = null;
log('could not load operator settings: ' + err.message, 'err');
/* The language list is driven by opConfig, which is now null — clearing it
is honest here, and #opFeatured no longer exists (it became a checkbox
list), so touching it would throw inside the very error path that runs
when the server is unreachable. */
$('opLangOn').textContent = '';
$('opLangOff').textContent = '';
$('opLangCount').textContent = '';
$('opPinned').textContent = '';
for (const [id] of CONTROL_URLS) $(id).textContent = 'unavailable';
$('opPin').value = '';
/*
* RETRY. This used to give up permanently, and at launch it always lost the
* race against the server child starting — which is why the settings page
* came up empty and only filled in if you toggled the tabs. Backs off to
* 5s so a genuinely absent server does not spin.
*/
opRetryMs = Math.min((opRetryMs || 600) * 1.6, 5000);
loadOperatorSoon(opRetryMs);
$('opFixHint').textContent = 'Waiting for the server\u2026';
log('operator settings not ready yet, retrying in ' + Math.round(opRetryMs) + 'ms', 'warn');
} finally {
/* Independent of the server, so it must survive the catch above. */
renderVoiceLangs();
renderVoiceList();
}
}
async function opSave(patch, okMsg) {
try {
const j = await opApi('/api/config', { method: 'POST', body: JSON.stringify(patch) });
opConfig = j.config;
renderShare(); /* publicUrl may have just arrived */
if (okMsg) log(okMsg, 'ok');
return j;
} catch (e) { log('could not save: ' + e.message, 'err'); throw e; }
}
function opChip(code, onRemove) {
const l = window.Languages && window.Languages.get(code);
const el = document.createElement('span');
el.className = 'chip';
el.dataset.code = code;
el.textContent = l ? l.native : code;
const x = document.createElement('button');
x.type = 'button';
x.textContent = '×';
x.setAttribute('aria-label', 'Remove ' + (l ? l.name : code));
x.addEventListener('click', () => onRemove(code));
el.appendChild(x);
return el;
}
/*
* Pinned languages, without rebuilding the strip. Emptying the container and
* re-appending every chip is what made a pin appear from nowhere and an
* unpinned one vanish mid-blink — the same "teleport" the language columns
* had. Chips that are still pinned keep their node (so nothing flickers), a
* new one grows in, and a removed one shrinks out and then leaves.
*/
const CHIP_OUT_MS = 190;
function unpin(code) {
const el = $('opPinned').querySelector('.chip[data-code="' + code + '"]');
if (el) leaveChip(el);
return opSave({ pinned: (opConfig.pinned || []).filter((x) => x !== code) })
.then(renderOpLanguages);
}
function leaveChip(el) {
if (el.dataset.leaving) return;
el.dataset.leaving = '1';
el.classList.add('chip-out');
setTimeout(() => el.remove(), CHIP_OUT_MS);
}
function renderPinnedChips() {
const pin = $('opPinned');
const want = (opConfig.pinned || []);
/* A chip already on its way out is not a chip: it must not be reused, and
it must not make the strip look occupied to :empty. */
const have = new Map();
for (const el of Array.from(pin.children)) {
if (el.dataset.leaving) continue;
have.set(el.dataset.code, el);
}
for (const [code, el] of have) if (want.indexOf(code) === -1) leaveChip(el);
want.forEach((code, i) => {
let el = have.get(code);
if (!el) {
el = opChip(code, unpin);
el.classList.add('chip-in');
/* Dropped rather than left on: a class that outlives its animation
replays it the next time the browser recalculates style. */
setTimeout(() => el.classList.remove('chip-in'), 260);
}
/*
* Position among the chips that are STAYING. Counting the leaving ones
* would slide a chip that is mid-exit to the end of the row before it
* finished shrinking — a chip you just removed would jump sideways first,
* which is the exact effect this whole change exists to remove.
*/
const live = pin.children ? Array.prototype.filter.call(pin.children, (c) => !c.dataset.leaving) : [];
const at = live[i];
if (at !== el) pin.insertBefore(el, at || null);
});
}
/* A pin flies from the search result it was picked from to the chip it
becomes, for the same reason the language rows do: two lists far apart on a
long page, and no way to tell where a thing went if it simply appears. */
function flyChip(fromRect, code) {
if (!fromRect) return;
try {
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
} catch (_) {}
const dest = $('opPinned').querySelector('.chip[data-code="' + code + '"]');
if (!dest) return;
const to = dest.getBoundingClientRect();
if (!to.width || !to.height) return;
const fly = dest.cloneNode(true);
fly.className = 'chip chipfly';
fly.style.left = fromRect.left + 'px';
fly.style.top = fromRect.top + 'px';
document.body.appendChild(fly);
dest.classList.add('is-landing');
const dx = to.left - fromRect.left, dy = to.top - fromRect.top;
requestAnimationFrame(() => { requestAnimationFrame(() => {
fly.style.transition = 'transform 240ms cubic-bezier(.2,.7,.3,1), opacity 240ms ease-out';
fly.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
fly.style.opacity = '.9';
}); });
/* A timer, not transitionend — the chip strip can be rebuilt underneath. */
setTimeout(() => { fly.remove(); dest.classList.remove('is-landing'); }, 300);
}
/* The routing matrix lists the languages actually in play, so it has to be
redrawn when that set changes. */
function refreshOutMapIfShown() {
if (state.outChannels) { renderOutMap(); renderSummaries(); }
}
function renderOpLanguages() {
if (!opConfig) return;
renderOpLangList();
renderPinnedChips();
/*
* Text and speech do not cover the same languages and never will, so say
* both numbers rather than one. The count of languages that can be SPOKEN
* comes from the voice catalog, not from a hardcoded figure, so it stays
* true as voices are added or dropped.
*/
const all = !!opConfig.allowAllLanguages;
$('opAllowAll').checked = all;
const total = (window.Languages && window.Languages.codes().length) || 0;
const spoken = (window.VoiceCatalog && window.VoiceCatalog.languages().length) || 0;
$('opAllowAllHint').textContent = all
? 'On — all ' + total + ' languages are offered. ' + spoken + ' of them have a voice; ' +
'the rest show captions only.'
: 'Off — listeners can only choose the ' + (opConfig.featured || []).length +
' languages on the front page, out of ' + total + '.';
renderSummaries();
}
function opSearch(inputId, resultsId, apply) {
let timer = null;
/*
* An empty box used to render an empty list, so there was no way to SEE what
* could be pinned — you had to already know the name to type it. Empty now
* means "show everything", alphabetically.
*/
const render = (q) => {
const box = $(resultsId);
box.textContent = '';
if (!window.Languages) return;
const hits = q.trim()
? window.Languages.search(q)
: window.Languages.codes()
.map((c) => window.Languages.get(c))
.filter(Boolean)
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
hits.slice(0, q.trim() ? 20 : 200).forEach((l) => {
const b = document.createElement('button');
b.type = 'button';
b.textContent = l.native;
const s = document.createElement('span');
s.textContent = l.name;
b.appendChild(s);
b.addEventListener('click', () => {
/* Measured BEFORE the list closes — afterwards there is nothing to
measure and the flight would start from the origin. */
apply(l.code, b.getBoundingClientRect());
$(inputId).value = '';
box.textContent = '';
});
box.appendChild(b);
});
};
$(inputId).addEventListener('input', (e) => {
const q = e.target.value;
clearTimeout(timer);
timer = setTimeout(() => render(q), 90);
});
/* Opening the box is a request to see the options. */
$(inputId).addEventListener('focus', () => render($(inputId).value));
$(inputId).addEventListener('blur', () => {
/* Let a click on a result land before the list disappears. */
setTimeout(() => { if (document.activeElement !== $(inputId)) $(resultsId).textContent = ''; }, 180);
});
}
/* Live numbers, refreshed from the same status the operator page uses. */
function renderOperatorLive() {
const live = state.live;
const go = $('heroGo');
go.dataset.live = live ? 'true' : 'false';
go.textContent = live ? 'STOP TRANSLATING' : 'START TRANSLATING';
go.disabled = !state.modelsReady || !state.ws || state.ws.readyState !== 1;
/*
* A hold is silent and invisible from the back of the room — it looks exactly
* like the whole thing being broken. So it gets the loudest thing on the page,
* and its own button, because a cough button you can only reach from a Stream
* Deck is no use to whoever is actually standing at the Mac.
*/
const hold = !!state.hold;
const holdBtn = $('heroHold');
holdBtn.hidden = !live;
holdBtn.textContent = hold ? 'RESUME SENDING' : 'HOLD';
holdBtn.dataset.held = hold ? 'true' : 'false';
$('holdBar').hidden = !(live && hold);
const h = state.health;
const hEl = $('opHealth');
if (hEl) {
hEl.hidden = !h;
if (h) {
hEl.dataset.level = h.level;
hEl.textContent = h.detail;
}
}
$('stListeners').textContent = state.listeners || 0;
$('stLangs').textContent = (state.targets || []).length;
$('stElapsed').textContent = state.startedAt
? Math.floor((Date.now() - state.startedAt) / 60000) + ':' +
String(Math.floor(((Date.now() - state.startedAt) % 60000) / 1000)).padStart(2, '0')
: '—';
const who = $('opWho');
who.textContent = '';
(state.listenerDetail || []).forEach((d) => {
const l = window.Languages && window.Languages.get(d.lang);
const row = document.createElement('div');
row.className = 'wholine';
const dev = document.createElement('b');
dev.textContent = d.device || 'Unknown device';
const meta = document.createElement('span');
meta.textContent = (d.remote ? 'remote' : d.ip) + ' · ' + (l ? l.native : d.lang) +
(d.speaking ? ' · speaking' : '') + (d.minutes ? ' · ' + d.minutes + 'm' : '');
row.appendChild(dev);
row.appendChild(meta);
who.appendChild(row);
});
}
/* ── the Voices card: audition every voice, choose per language ────────── */
let auditionAudio = null; // one <audio> element, reused
let auditionBusy = false;
function voiceChoices() {
return (state.cfg && state.cfg.voiceChoices) || {};
}
/* Which alternates listeners may pick, per language. */
function listenerVoices() {
return (state.cfg && state.cfg.listenerVoices) || {};
}
function isOfferedVoice(lang, v) {
const key = voiceKeyOf(v);
return !!key && (listenerVoices()[lang] || []).indexOf(key) !== -1;
}
/*
* Offer, or stop offering, one voice to listeners of one language.
*
* Saved through the app (never by writing config underneath it) and pushed to
* the server immediately, so a listener with the page already open sees the
* chooser appear or disappear without reloading.
*/
async function toggleListenerVoice(lang, v) {
const key = voiceKeyOf(v);
if (!key) return;
const map = {};
for (const k of Object.keys(listenerVoices())) map[k] = (listenerVoices()[k] || []).slice();
const list = map[lang] || (map[lang] = []);
const at = list.indexOf(key);
if (at === -1) list.push(key); else list.splice(at, 1);
try {
const saved = await window.engineHost.ttsSetListenerVoices(map);
state.cfg = Object.assign({}, state.cfg, { listenerVoices: saved });
sendVoiceOptions();
renderVoiceList();
log((at === -1 ? 'listeners can now pick ' : 'listeners can no longer pick ')
+ v.label + ' for ' + lang, 'ok');
} catch (err) {
log('could not change the listener voices: ' + err.message, 'err');
}
}
/*
* The catalog entry that will actually speak this language right now: the
* operator's pick if they made one, otherwise the shipped default. Every
* label on this card reads from here, so the dropdown, the sidecar and the
* row markers can never disagree about which voice is live.
*/
function currentVoiceFor(lang) {
const list = (window.VoiceCatalog && window.VoiceCatalog.forLanguage(lang)) || [];
const c = voiceChoices()[lang];
if (c) {
const hit = list.find((v) => v.engine === c.engine && voiceKeyOf(v) === voiceKeyOf(c));
/* A saved pick can outlive its voice — the gender census dropped 151
entries from the catalog, so a config written before it may name one
that is gone. Report the name with a flag rather than a blank. */
return hit || { engine: c.engine, label: c.label || c.speaker || c.bundle, gone: true };
}
const shipped = (state.spokenDefaults || {})[lang];
return (shipped && list.find((v) => v.engine === 'builtin' && v.bundle === shipped)) || list[0] || null;
}
/* Just the speaker's name for the dropdown — "sharvard", not
"sharvard · medium (es_ES)". The detail belongs in the list below. */
function voiceShortName(v) {
return v ? String(v.label || v.speaker || v.bundle || '').split(' · ')[0].trim() : '';
}
/*
* Which field identifies a voice depends on its engine.
*
* Piper voices are named by `bundle`; the sidecar engines — Qwen3 and Kokoro —
* are named by `speaker` and have no bundle at all. Testing `bundle` on one of
* those compares undefined with undefined, which is true for EVERY voice of
* that engine — so all three Kokoro rows for Spanish showed "in use" at once.
*/
function voiceKeyOf(v) {
if (!v) return '';
return (v.engine === 'local' || v.engine === 'kokoro')
? String(v.speaker || '')
: String(v.bundle || '');
}
/* Is this catalog entry the one currently in use for the language? */
function isCurrentVoice(lang, v) {
const cur = currentVoiceFor(lang);
if (!cur || cur.gone || cur.engine !== v.engine) return false;
const key = voiceKeyOf(v);
return !!key && voiceKeyOf(cur) === key;
}
function renderVoiceLangs() {
const sel = $('voiceLang');
if (!sel || !window.VoiceCatalog) return;
const langs = window.VoiceCatalog.languages();
/* Languages anyone is actually using first — that is what an operator is
tuning mid-service — then the rest alphabetically by name. */
const active = new Set(state.targets || []);
const name = (l) => ((window.Languages && window.Languages.get(l)) || {}).name || l;
langs.sort((a, b) => name(a).localeCompare(name(b)));
const keep = sel.value;
sel.textContent = '';
/*
* Each option names the voice that will actually speak, so the dropdown is
* a summary of the assignments: "Spanish — sharvard".
*
* It used to read "Spanish — in use", where "in use" meant the LANGUAGE was
* being translated — while the row buttons below use the same two words to
* mean the VOICE is the live one. One phrase, two subjects, one card. The
* language state moves to a group heading, which can't be misread as a
* property of the voice.
*/
const groups = active.size
? [{ head: 'Translating now', of: langs.filter((l) => active.has(l)) },
{ head: 'Other languages', of: langs.filter((l) => !active.has(l)) }]
: [{ head: 'All languages', of: langs }];
for (const g of groups) {
if (!g.of.length) continue;
const grp = document.createElement('optgroup');
grp.label = g.head;
for (const l of g.of) {
const o = document.createElement('option');
o.value = l;
const who = voiceShortName(currentVoiceFor(l));
o.textContent = name(l) + (who ? ' — ' + who : '');
grp.appendChild(o);
}
sel.appendChild(grp);
}
sel.value = (keep && langs.indexOf(keep) !== -1) ? keep
: (langs.indexOf('spa_Latn') !== -1 ? 'spa_Latn' : langs[0]);
/* This list is shorter than the language list and that looks like a bug
until it is explained. Free offline TTS simply covers fewer languages
than NLLB translates; the rest are captioned, not dropped. */
const total = (window.Languages && window.Languages.codes().length) || 0;
const cov = $('voiceCoverage');
if (cov) {
cov.textContent = langs.length + ' of the ' + total + ' languages this app translates have a ' +
'built-in voice. The other ' + (total - langs.length) + ' show captions, and a listener\u2019s ' +
'own phone may still read them aloud.';
}
}
function renderVoiceList() {
const box = $('voiceList');
const lang = $('voiceLang').value;
if (!box || !lang || !window.VoiceCatalog) return;
box.textContent = '';
const list = window.VoiceCatalog.forLanguage(lang);
const chosen = voiceChoices()[lang];
const cur = currentVoiceFor(lang);
/* "your pick" vs "default" is the one thing the dropdown can't show, and it
is what tells the operator whether they have tuned this language yet. */
$('voiceNow').textContent = !cur ? 'no voices for this language'
: cur.gone ? 'your pick · ' + cur.label + ' — no longer available'
: (chosen ? 'your pick · ' : 'default · ') + cur.label;
for (const v of list) {
const row = document.createElement('div');
row.className = 'vrow' + (isCurrentVoice(lang, v) ? ' is-current' : '');
const nm = document.createElement('span');
nm.className = 'vname';
nm.textContent = v.label;
row.appendChild(nm);
const tag = document.createElement('span');
tag.className = 'vtag';
/*
* Say which rows can take a delivery direction, and say it ON THE ROW.
* The instruction box carried the caveat and the list did not, so
* auditioning any of the five Piper voices Spanish lists ABOVE the first
* local one produced a plain reading and no explanation — which reads as
* "the feature does nothing".
*/
const wantsDirection = !!($('voiceInstruct') && $('voiceInstruct').value.trim());
if (v.engine === 'local') {
tag.textContent = 'expressive · slow' + (wantsDirection ? ' · takes direction' : '');
} else if (v.engine === 'kokoro') {
/* MEASURED: RTF 0.05, and 0.12 with three at once — the only natural
voice here fast enough to cover every language during a service. */
tag.textContent = 'natural · fast' + (wantsDirection ? ' · ignores direction' : '');
if (wantsDirection) tag.className = 'vtag warn';
} else {
tag.textContent = 'built-in' + (wantsDirection ? ' · ignores direction' : '');
if (wantsDirection) tag.className = 'vtag warn';
}
row.appendChild(tag);
const msg = document.createElement('span');
msg.className = 'vmsg';
row.appendChild(msg);
const play = document.createElement('button');
play.className = 'btn';
play.textContent = '▶';
play.title = 'Hear this voice';
play.addEventListener('click', () => auditionVoice(lang, v, play, msg));
row.appendChild(play);
/*
* "Offer" — may a listener pick this voice for themselves?
*
* Not shown on the row that is already in use: that voice is what everyone
* hears by default, so offering it as an alternative would be a checkbox
* that changes nothing.
*/
if (!isCurrentVoice(lang, v)) {
const offer = document.createElement('label');
offer.className = 'voffer';
offer.title = 'Let listeners choose this voice on their phone';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = isOfferedVoice(lang, v);
cb.addEventListener('change', () => toggleListenerVoice(lang, v));
offer.appendChild(cb);
offer.appendChild(document.createTextNode('Offer'));
row.appendChild(offer);
}
const use = document.createElement('button');
use.className = 'btn';
use.textContent = isCurrentVoice(lang, v) ? '\u2713 in use' : 'Use';
use.disabled = isCurrentVoice(lang, v);
use.addEventListener('click', () => chooseVoice(lang, v));
row.appendChild(use);
box.appendChild(row);
}
}
/*
* WHERE AN AUDITION PLAYS: never on the card that feeds the room.
*
* An <audio> element with no sink plays on the system default output, and on
* the production Mini that IS the Dante Virtual Soundcard (system_profiler:
* "Default Output Device: Yes") — the same card that carries Spanish on its
* third channel (outputMap spa_Latn -> [2]). So ▶ there put the sample line
* onto the Dante network, service or no service. The phones were never
* exposed: an audition comes back to this page as bytes and never becomes a
* clip the server can see.
*
* Which of the card's channels it reached is Chromium's mono up-mix and the
* Dante routing, neither of which this page controls, so it does not try to
* pick a "safe" channel. It plays on another output, or not at all, and says
* why.
*/
async function auditionSink() {
const routedId = ($('outDevice') && $('outDevice').value) || '';
const routedLabel = (state.cfg && state.cfg.outputDeviceLabel) || '';
if (!routedId && !routedLabel) return ''; // no hardware output in use
const strip = (l) => String(l || '').toLowerCase().replace(/^default\s*-\s*/, '').trim();
let outs = [];
try {
outs = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'audiooutput');
} catch (_) { /* refused below rather than guessed */ }
const card = strip(((outs.find((d) => d.deviceId === routedId) || {}).label) || routedLabel);
const isCard = (d) => (!!routedId && d.deviceId === routedId) || (!!card && strip(d.label) === card);
const def = outs.find((d) => d.deviceId === 'default');
if (def && !isCard(def)) return ''; // the system default is somewhere else
const other = outs.find((d) => d.deviceId !== 'default' && d.deviceId !== 'communications' && !isCard(d));
if (other) return other.deviceId;
throw new Error('the only output here is the one feeding the hardware channels — auditions stay off it');
}
async function auditionVoice(lang, v, btn, msg) {
if (auditionBusy) return;
auditionBusy = true;
const was = btn.textContent;
btn.textContent = '…';
btn.disabled = true;
msg.className = 'vmsg';
msg.textContent = (v.engine === 'builtin' || v.engine === 'kokoro')
? 'preparing (may download)\u2026' : '';
try {
/* Delivery direction, if the operator typed one. Local voices only — the
Piper path has nowhere to put it. */
const typed = ($('voiceInstruct').value || '').trim();
/* Only the expressive model takes one. Kokoro and Piper have no such
control and would silently ignore it. */
const instruct = (v.engine === 'local') ? typed : '';
if (typed && v.engine !== 'local') {
/* Told, not ignored. */
msg.className = 'vmsg bad';
msg.textContent = 'this voice cannot take direction — pick a Local one';
await new Promise((r) => setTimeout(r, 1200));
msg.className = 'vmsg';
}
if (instruct) msg.textContent = 'with your direction…';
/* Where it will play is settled BEFORE the voice process builds anything:
an audition that cannot play should cost the service nothing. */
const sink = await auditionSink();
const out = await window.engineHost.ttsAudition({ lang, voice: v, instruct });
if (!auditionAudio) auditionAudio = new Audio();
if (!out.wav) throw new Error('that voice produced no audio');
if (typeof auditionAudio.setSinkId === 'function') await auditionAudio.setSinkId(sink);
else if (sink) throw new Error('this build cannot keep auditions off the hardware output');
auditionAudio.src = 'data:audio/wav;base64,' + out.wav;
/* play() rejects on autoplay policy or a decode failure; either way the
operator needs the reason, not a silent button. */
await auditionAudio.play();
if (out.warning) {
msg.className = 'vmsg';
msg.textContent = out.warning;
} else {
msg.className = 'vmsg';
msg.textContent = (out.seconds || 0).toFixed(1) + 's';
}
} catch (err) {
msg.className = 'vmsg bad';
msg.textContent = String(err.message || err).replace(/^Error invoking remote method [^:]+: Error: /, '');
} finally {
btn.textContent = was;
btn.disabled = false;
auditionBusy = false;
}
}
async function chooseVoice(lang, v) {
const choices = Object.assign({}, voiceChoices());
choices[lang] = (v.engine === 'local' || v.engine === 'kokoro')
? { engine: v.engine, speaker: v.speaker, label: v.label }
: { engine: 'builtin', bundle: v.bundle, model: v.model, label: v.label };
try {
const saved = await window.engineHost.ttsSetVoices(choices);
state.cfg = Object.assign({}, state.cfg, { voiceChoices: saved });
/*
* A voice promoted to DEFAULT stops being an alternate. Leaving it in both
* lists would show listeners a chooser whose two entries are the same
* voice, and would make the engine synthesise it twice for anyone who
* picked it.
*/
const key = voiceKeyOf(v);
const offered = (listenerVoices()[lang] || []);
if (key && offered.indexOf(key) !== -1) {
const map = {};
for (const k of Object.keys(listenerVoices())) map[k] = (listenerVoices()[k] || []).slice();
map[lang] = offered.filter((k) => k !== key);
try {
const kept = await window.engineHost.ttsSetListenerVoices(map);
state.cfg = Object.assign({}, state.cfg, { listenerVoices: kept });
} catch (_) {}
}
sendVoiceOptions();
renderVoiceLangs(); // the option text names the voice — keep it honest
renderVoiceList();
log('voice for ' + lang + ' set to ' + v.label, 'ok');
} catch (err) {
log('could not set the voice: ' + err.message, 'err');
}
}
$('voiceLang').addEventListener('change', renderVoiceList);
$('opAllowAll').addEventListener('change', async (e) => {
const want = e.target.checked;
try {
await opSave({ allowAllLanguages: want },
want ? 'listeners can pick any language' : 'listeners are limited to the front-page languages');
renderOpLanguages();
} catch (_) {
e.target.checked = !want; // the save failed; do not show a state the server does not have
}
});
/* ── premium voices ───────────────────────────────────────────────────── */
function renderCloudCard() {
const c = (state.cfg && state.cfg.cloudTts) || { provider: 'off' };
$('cloudProvider').value = c.provider || 'off';
$('cloudFields').hidden = (c.provider || 'off') === 'off';
$('cloudKey').value = c.provider === 'google' ? (c.googleKey || '') : (c.fishKey || '');
$('cloudVoice').value = c.fishVoice || '';
$('cloudPerLang').value = Object.entries(c.perLang || {})
.map(([k, v]) => k + ' = ' + v).join('\n');
}
$('cloudProvider').addEventListener('change', () => {
$('cloudFields').hidden = $('cloudProvider').value === 'off';
});
$('cloudSave').addEventListener('click', async () => {
const provider = $('cloudProvider').value;
const key = $('cloudKey').value.trim();
const perLang = {};
for (const line of $('cloudPerLang').value.split('\n')) {
const m = line.split('=');
if (m.length === 2 && m[0].trim() && m[1].trim()) perLang[m[0].trim()] = m[1].trim();
}
const prev = state.cfg.cloudTts || {};
const cloudTts = Object.assign({}, prev, {
provider,
fishKey: provider === 'fish' ? key : (prev.fishKey || ''),
fishModel: 's2.1-pro',
fishVoice: provider === 'fish' ? $('cloudVoice').value.trim() : (prev.fishVoice || ''),
googleKey: provider === 'google' ? key : (prev.googleKey || ''),
localVoice: provider === 'local' ? ($('cloudVoice').value.trim() || 'aiden') : (prev.localVoice || 'aiden'),
perLang
});
$('cloudHint').textContent = 'Saving…';
try {
state.cfg = await window.engineHost.setConfig({ cloudTts });
$('cloudHint').textContent = provider === 'off' ? 'Off — built-in voices' : 'Saved';
} catch (err) { $('cloudHint').textContent = 'Not saved: ' + err.message; }
});
$('cloudTest').addEventListener('click', async () => {
/* One real sentence through the real path, reporting which engine spoke. */
$('cloudHint').textContent = 'Testing…';
const lang = (state.targets && state.targets[0]) || 'spa_Latn';
const t0 = Date.now();
try {
const clip = await window.engineHost.ttsSpeak({ lang, text: 'The Lord bless you and keep you.' });
$('cloudHint').textContent = (clip.source === 'piper' ? 'Built-in voice (premium unavailable)'
: clip.source === 'local' ? 'Local voice (this Mac)' : 'Premium voice') +
' · ' + (Date.now() - t0) + 'ms · ' + lang;
} catch (err) { $('cloudHint').textContent = 'Test failed: ' + err.message; }
});
if (window.engineHost.onLocalVoices) {
window.engineHost.onLocalVoices((s) => {
if ((($('cloudProvider') || {}).value) !== 'local') return;
$('cloudState').textContent =
s.ready ? 'Local voices running' :
s.running ? 'Starting local voices…' :
s.error || 'Off';
});
}
if (window.engineHost.onTtsCloud) {
window.engineHost.onTtsCloud((s) => {
const prev = state.cloudStats || {};
state.cloudStats = s;
$('cloudState').textContent = s.parked
? 'DOWN — using built-in voices'
: (s.ok + ' spoken · ' + s.fallback + ' fell back' + (s.sidecarReady === false ? ' · voices starting' : ''));
/*
* On the hero, where someone standing at the Mac actually looks. The
* morning a service ran five minutes in the wrong voice, the only sign
* was a hint under a dropdown in a collapsed card.
*/
const warn = $('voiceWarn');
if (warn) {
const recentFallback = s.fallbackAt && Date.now() - s.fallbackAt < 60000;
if (s.parked) {
warn.textContent = 'Natural voices are DOWN — the built-in voices are speaking' +
(s.parkedReason ? ' (' + s.parkedReason + ')' : '') + '.';
warn.hidden = false;
} else if (recentFallback) {
warn.textContent = 'A natural voice just fell back to the built-in voice for a sentence (' +
s.fallback + ' so far this session).';
warn.hidden = false;
} else if (s.sidecarReady === false && state.live) {
warn.textContent = 'Natural voices are still starting — built-in voices speak until they are ready.';
warn.hidden = false;
} else warn.hidden = true;
}
if ((s.fallback || 0) > (prev.fallback || 0)) log('a natural voice fell back to built-in (' + s.fallback + ' total)' + (s.lastError ? ': ' + s.lastError : ''), 'err');
if (s.parked && !prev.parked) log('natural voices PARKED — built-in voices speaking' + (s.parkedReason ? ' (' + s.parkedReason + ')' : ''), 'err');
if (!s.parked && prev.parked) log('natural voices are back', 'ok');
});
}
/* The internet-sharing panel is gone from this page — one published address is
the plan, and the quick tunnel handed out a NEW url every launch, which is
the opposite of something you can print. The machinery is untouched in
main.js (tunnelStart/tunnelStop/onTunnel) for when a stream-viewer address
with its own delay is wanted. */
$('heroHold').addEventListener('click', () => {
if (!opConfig || !opConfig.controlToken) { log('operator settings have not loaded', 'err'); return; }
/* Through the server, like the start button, so this button, the Stream Deck
and the operator page can never disagree about whether we are held. */
fetch(opBase() + '/api/control/' + (state.hold ? 'resume' : 'hold') +
'?key=' + encodeURIComponent(opConfig.controlToken))
.catch((e) => log('could not change hold: ' + e.message, 'err'));
});
$('heroGo').addEventListener('click', () => {
if (!state.ws || state.ws.readyState !== 1) { log('not connected to the server yet', 'err'); return; }
if (!opConfig || !opConfig.controlToken) { log('operator settings have not loaded — cannot start', 'err'); return; }
/* The engine cannot start itself — the server owns that state, and going
through it keeps the Stream Deck, the operator page and this button all
telling the same story. */
fetch(opBase() + '/api/control/' + (state.live ? 'stop' : 'start') +
'?key=' + encodeURIComponent((opConfig && opConfig.controlToken) || ''))
.catch((e) => log('could not toggle: ' + e.message, 'err'));
});
$('opSaveFixes').addEventListener('click', () => {
/* Refuse to save from a pane that never loaded. Writing an empty textarea back
would silently delete every correction the church has built up. */
if (!opConfig) { $('opFixHint').textContent = 'Not loaded — nothing saved'; return; }
const lines = $('opFixes').value.split('\n').map((s) => s.trim()).filter(Boolean);
const good = lines.filter((l) => l.indexOf('=>') !== -1);
const bad = lines.length - good.length;
const glossary = Object.assign({}, opConfig.glossary, { fixes: good });
$('opFixHint').textContent = 'Saving…';
opSave({ glossary }, good.length + ' corrections saved')
.then(() => {
delete $('opFixes').dataset.dirty;
$('opFixHint').textContent = bad ? 'Saved · ' + bad + ' line(s) need "=>"' : 'Saved';
})
.catch((err) => { $('opFixHint').textContent = 'Not saved: ' + err.message; });
});
/*
* The PIN guards the browser operator page, which is how someone runs the desk
* from a tablet. This tab is inside the app and never asks for it, so it lives
* here rather than on the setup page — a credential on the screen the room can
* see is the one place it should not be.
*/
$('opFixes').addEventListener('input', () => { $('opFixes').dataset.dirty = '1'; });
$('opSaveDelay').addEventListener('click', () => {
const n = Number($('opDelay').value);
if (!Number.isFinite(n) || n < 0 || n > 120) { $('opDelayHint').textContent = '0 to 120 seconds'; return; }
$('opDelayHint').textContent = 'Saving…';
opSave({ streamDelaySec: Math.round(n) })
.then(() => { $('opDelayHint').textContent = n > 0 ? 'Holding ' + Math.round(n) + 's' : 'Off'; })
.catch((err) => { $('opDelayHint').textContent = 'Not saved: ' + err.message; });
});
$('opSavePin').addEventListener('click', () => {
const pin = $('opPin').value.trim();
if (pin.length < 4) { $('opPinHint').textContent = 'Use at least 4 characters.'; return; }
$('opPinHint').textContent = 'Saving…';
opSave({ operatorPin: pin })
.then(() => { $('opPinHint').textContent = 'Changed'; setTimeout(() => { $('opPinHint').textContent = ''; }, 2000); })
.catch((err) => { $('opPinHint').textContent = 'Not changed: ' + err.message; });
});
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-copy]');
if (!btn) return;
const text = $(btn.dataset.copy).textContent;
if (!text || text === '—' || text === 'unavailable') { log('nothing to copy yet', 'err'); return; }
window.engineHost.copyText(text)
.then(() => {
/* Confirm on the button itself — the log is halfway down another pane. */
const was = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = was; }, 1200);
})
.catch((err) => log('could not copy: ' + err.message, 'err'));
});
/*
* Toggling is optimistic and the save is debounced. Ticking twenty languages
* in a row must not be twenty writes to a config file that carries the
* operator's months of work and gets snapshotted on every write.
*/
let lastConfigSig = '';
let featuredSaveTimer = null;
/*
* Fly a copy of the row from where it was to where it lands.
*
* A CLONE ON <body>, not the row itself, and that is the whole design. The
* columns are `overflow: auto`, so a real row animating between them is
* clipped by its own column across exactly the part of the flight that
* crosses the gap — z-index cannot escape an ancestor's clip. And the server
* pushes a fresh operator snapshot every 3 seconds (consoleTick in
* server.js), which rebuilds all 105 rows; a real-node animation would be
* destroyed mid-air roughly one toggle in ten. A fixed-position clone is
* subject to neither.
*
* The destination is scrolled into view FIRST. Measured on the prototype:
* turning a language off put its alphabetical home somewhere in 3000px of
* off-screen list in 105 of 105 cases, so the flight had nowhere visible to
* land and the row simply vanished.
*/
function flyRow(code, fromRect) {
if (!fromRect) return;
try {
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
} catch (_) {}
const dest = document.querySelector('.langcols .langrow[data-code="' + code + '"]');
if (!dest) return;
const box = dest.closest('.langlist');
if (box) {
/* Scroll the column itself, never scrollIntoView — that would also move
the whole settings page out from under the operator. */
const top = dest.offsetTop, h = dest.offsetHeight;
if (top < box.scrollTop) box.scrollTop = top;
else if (top + h > box.scrollTop + box.clientHeight) box.scrollTop = top + h - box.clientHeight;
}
const to = dest.getBoundingClientRect();
if (!to.width || !to.height) return;
const fly = dest.cloneNode(true);
fly.className = 'langfly langrow';
fly.style.left = fromRect.left + 'px';
fly.style.top = fromRect.top + 'px';
fly.style.width = fromRect.width + 'px';
fly.style.height = fromRect.height + 'px';
fly.style.transform = 'translate(0,0)';
fly.style.opacity = '1';
document.body.appendChild(fly);
dest.classList.add('is-landing');
const dx = to.left - fromRect.left, dy = to.top - fromRect.top;
const done = () => {
fly.remove();
dest.classList.remove('is-landing');
};
requestAnimationFrame(() => { requestAnimationFrame(() => {
fly.style.transition = 'transform 240ms cubic-bezier(.2,.7,.3,1), opacity 240ms ease-out';
fly.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
fly.style.opacity = '.92';
}); });
/* A timer, not transitionend: the row is recreated by the 3s rebuild and
the listener would be lost with it, stranding a clone on screen. */
setTimeout(done, 300);
}
function toggleFeatured(code, on, row) {
/* Measured BEFORE the state change, while the row is still where the
operator clicked it. */
const fromRect = row ? row.getBoundingClientRect() : null;
const list = (opConfig.featured || []).slice();
const at = list.indexOf(code);
if (on && at === -1) list.push(code);
else if (!on && at !== -1) list.splice(at, 1);
else return;
const before = opConfig.featured;
opConfig.featured = list; // show it immediately
renderOpLangList(); // the row changes column
renderOpLangCount();
flyRow(code, fromRect);
const landed = document.querySelector('.langcols .langrow[data-code="' + code + '"]');
if (landed) { landed.classList.add('touched'); setTimeout(() => landed.classList.remove('touched'), 500); }
clearTimeout(featuredSaveTimer);
featuredSaveTimer = setTimeout(() => {
opSave({ featured: opConfig.featured }).catch(() => {
/* Never leave a tick on screen the server did not accept. */
opConfig.featured = before;
renderOpLangList();
});
}, 500);
}
function renderOpLangCount() {
const on = (opConfig.featured || []).length;
const total = (window.Languages && window.Languages.codes().length) || 0;
const el = $('opLangCount');
if (el) el.textContent = on + ' of ' + total + ' on. These are what a listener sees first' +
(opConfig.allowAllLanguages ? ' — the rest stay reachable by search.' : ' — and all they can choose.');
}
function renderOpLangList() {
const onBox = $('opLangOn'), offBox = $('opLangOff');
if (!onBox || !offBox || !window.Languages) return;
const q = ($('opLangFilter').value || '').trim().toLowerCase();
const voiceOnly = $('opLangVoiceOnly').checked;
const on = new Set(opConfig.featured || []);
const spoken = new Set((window.VoiceCatalog && window.VoiceCatalog.languages()) || []);
/* Alphabetical, and it stays alphabetical while you click. Re-sorting a
104-row list under the cursor makes it impossible to work down. */
const codes = window.Languages.codes().slice().sort((a, b) => {
const la = window.Languages.get(a), lb = window.Languages.get(b);
return (la.name || a).localeCompare(lb.name || b);
});
onBox.textContent = '';
offBox.textContent = '';
let shown = 0, nOn = 0, nOff = 0;
for (const code of codes) {
const l = window.Languages.get(code) || { name: code, native: code };
const has = spoken.has(code);
if (voiceOnly && !has) continue;
if (q && (l.name + ' ' + l.native + ' ' + code).toLowerCase().indexOf(q) === -1) continue;
shown++;
const row = document.createElement('label');
row.className = 'langrow' + (has ? '' : ' novoice');
row.dataset.code = code;
/* The native name is hidden in two-column mode to stop the variant names
truncating; keep it reachable. */
row.title = l.native && l.native !== l.name ? l.name + ' \u2014 ' + l.native : l.name;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = on.has(code);
/* The checkbox stays. It is the only thing here that is focusable, that
Space operates, and that a screen reader announces as a control — a bare
row with a hover background is none of those. */
cb.addEventListener('change', () => toggleFeatured(code, cb.checked, row));
row.appendChild(cb);
/*
* ENGLISH NAME LEADS, and it is also the sort key — so the list reads
* alphabetically. Leading with the native name made a correctly sorted list
* look shuffled: "Afrikaans, Shqip, አማርኛ, العربية, Հայերեն" is Afrikaans,
* Albanian, Amharic, Arabic, Armenian, but nobody can see that. The
* listener sees the native name; the operator is looking for "Korean".
*/
const nm = document.createElement('span');
nm.className = 'lname';
nm.textContent = l.name;
row.appendChild(nm);
const nat = document.createElement('span');
nat.className = 'lsub';
nat.textContent = l.native === l.name ? '' : l.native;
row.appendChild(nat);
const tag = document.createElement('span');
tag.className = 'ltag';
/* Which languages can be SPOKEN is the thing being tuned, so it belongs
on the row rather than in a separate list to cross-reference. */
tag.textContent = has ? 'voice' : 'captions only';
row.appendChild(tag);
if (on.has(code)) { nOn++; onBox.appendChild(row); }
else { nOff++; offBox.appendChild(row); }
}
$('opLangNOn').textContent = nOn;
$('opLangNOff').textContent = nOff;
/*
* An empty column means two different things and they need different words.
* Saying "nothing on yet" while the header reads "On 3" — which is what a
* filter with no matches produces — invites the operator to switch on
* languages that are already on.
*/
const filtering = !!q || voiceOnly;
const note = (el, empty, filtered) => {
if (el.children.length) return;
const p = document.createElement('p');
p.className = 'hint';
p.style.padding = '.75rem .625rem';
p.textContent = filtering ? filtered : empty;
el.appendChild(p);
};
note(onBox, 'Nothing on yet \u2014 tick a language to add it.', 'None of the languages that are on match that.');
note(offBox, 'Every language is on.', 'No other language matches that.');
void shown;
renderOpLangCount();
}
$('opLangFilter').addEventListener('input', renderOpLangList);
$('opLangVoiceOnly').addEventListener('change', renderOpLangList);
$('opLangNone').addEventListener('click', () => {
if (!opConfig) return;
opConfig.featured = [];
renderOpLangList();
opSave({ featured: [] }, 'all languages turned off').catch(() => loadOperator());
});
opSearch('opPinSearch', 'opPinResults', (code, fromRect) => {
const pinned = (opConfig.pinned || []).slice();
if (pinned.indexOf(code) !== -1) return; // already pinned: nothing to do
pinned.push(code);
opConfig.pinned = pinned;
/* Draw the chip first so there is something to fly to, then save. */
renderPinnedChips();
flyChip(fromRect, code);
opSave({ pinned }).then(renderOpLanguages).catch(() => loadOperator());
});
setInterval(renderOperatorLive, 1000);
window.addEventListener('error', (e) => {
/* Where it happened, not just what. An "offset is out of bounds" with no
location took a morning to place. */
const at = e.filename ? ' (' + String(e.filename).split('/').pop() + ':' + e.lineno + ')' : '';
log('error: ' + e.message + at, 'err');
});
window.addEventListener('unhandledrejection', (e) => log('error: ' + (e.reason && e.reason.message || e.reason), 'err'));
boot();
/*
* ───────────────────────── collapsible sections ───────────────────────────
*
* Height is animated with grid-template-rows 0fr -> 1fr rather than a pixel
* height, so nothing has to measure the content — which matters here because
* these sections contain live meters, a 105-row language list and a QR that
* all change size after they are opened. A measured max-height would be wrong
* the moment any of them did.
*
* Which sections are open is per-machine, not per-service, so it lives in
* localStorage rather than the config the server owns.
*/
const GROUP_KEY = 'ct:groups';
function loadGroupState() {
try { return JSON.parse(localStorage.getItem(GROUP_KEY)) || {}; } catch (_) { return {}; }
}
function saveGroupState(m) {
try { localStorage.setItem(GROUP_KEY, JSON.stringify(m)); } catch (_) {}
}
function setGroup(sec, open, remember) {
sec.dataset.open = open ? 'true' : 'false';
const head = sec.querySelector('.group-head');
if (head) head.setAttribute('aria-expanded', open ? 'true' : 'false');
if (remember) {
const m = loadGroupState();
m[sec.id] = open;
saveGroupState(m);
}
}
(function initGroups() {
const saved = loadGroupState();
for (const sec of document.querySelectorAll('.group')) {
const head = sec.querySelector('.group-head');
if (!head) continue;
/* The markup's data-open is the default for a machine that has never been
used; a remembered choice wins. */
const want = Object.prototype.hasOwnProperty.call(saved, sec.id)
? !!saved[sec.id]
: sec.dataset.open === 'true';
setGroup(sec, want, false);
head.addEventListener('click', () => {
setGroup(sec, sec.dataset.open !== 'true', true);
});
}
})();