/* * 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 }; // ────────────────────────────────────────────────────────── 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); while (el.children.length > 200) el.removeChild(el.firstChild); el.scrollTop = el.scrollHeight; } 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'); } state.asrWorker = await loadWorkerWithFallback('speech', 'automatic-speech-recognition', state.asrModelInUse || cfg.asrModel, resolveDtype(cfg.asrDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' }), { encoder_model: 'q4', decoder_model_merged: 'q4' }); log('speech model restarted' + (lighter ? ' on ' + lighter.split('/').pop() : ''), 'ok'); state.slowRun = 0; } } 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; want.forEach((l) => preloadedVoices.add(l)); /* One at a time in the child anyway; cap the request so a burst of joins cannot queue up a re-download marathon. */ window.engineHost.ttsPreload(want.slice(0, 4)); } /* 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. */ 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) ? '(? 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 && r.lang && r.lang !== state.source && (r.n || 0) > 0) .sort((a, b) => (b.n || 0) - (a.n || 0)) .map((r) => r.lang) .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[row.lang] = row.n || 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(); if (ageSec > 12) { targets = []; state.captionOnlyFinals++; log('audio ' + ageSec.toFixed(0) + 's stale — caption only, translations skipped', 'err'); } else if (ageSec > 6) { const listening = new Set((state.byLanguage || []).map((r) => r.lang)); const before = targets.length; targets = targets.filter((t) => listening.has(t)); if (targets.length < before) state.degradedFinals++; } /* 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. */ /* * 0.85 and a backlog of five, both measured rather than chosen. * * The Mini's sentences ran 4.4 s against 4.5 s of audio — nominally FASTER * than real time, while the queue sat at 46 and the lag climbed past 150 s. * Per-sentence time alone therefore misses it: the work outside this timer * (segmenting, the preview model sharing the GPU, the scheduler) is enough * to lose on its own. The margin catches "near enough to real time to be * losing"; the queue depth is the proof that it actually is, and keeps one * slow sentence on a healthy machine from touching anything. */ const losingGround = jobAudioMs > 0 && took > jobAudioMs * 0.85 && queue.length >= 5; if (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, ' + queue.length + ' waiting) — ' + '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((r) => r.lang)); 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); while (box.children.length > 24) box.removeChild(box.firstChild); box.scrollTop = box.scrollHeight; } /* * 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'); if (box) box.scrollTop = box.scrollHeight; } /* * 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. */ 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); while (box.children.length > 30) box.removeChild(box.firstChild); } box.scrollTop = box.scrollHeight; } // ────────────────────────────────────────────────────────── 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; /* 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, 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) }; } function shareBase() { const c = shareChoice(); if (!c || c.none) return ''; return 'http://' + c.addr.address + ':' + state.local.port; } function renderQrWarning() { const el = $('qrWarn'); 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 = ''; /* 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')); }); $('openPoster').addEventListener('click', () => { window.open('http://127.0.0.1:' + state.local.port + '/poster', '_blank'); }); $('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() { const base = shareBase(); $('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; 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; 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