2026-09-23 15:41:21 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* 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)
|
2026-09-24 14:32:25 -04:00
|
|
|
|
captionOnlyFinals: 0, // finals that shipped with no translations at all
|
|
|
|
|
|
captionOnlyAt: 0, // when the last one happened
|
|
|
|
|
|
degradedAt: 0
|
2026-09-23 15:41:21 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────── 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');
|
|
|
|
|
|
}
|
2026-09-24 13:54:26 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* A LIGHTER MODEL GETS THE PREVIEW'S DTYPE, NOT THE BIG ONE'S.
|
|
|
|
|
|
*
|
|
|
|
|
|
* asrDtype is chosen for the accurate model and its weights are cached against
|
|
|
|
|
|
* it. Reusing it when stepping down asks for a variant that was never
|
|
|
|
|
|
* downloaded — and those weights live in the browser cache, with no network at
|
|
|
|
|
|
* a venue — so the step-down failed and the ladder could not move at all.
|
|
|
|
|
|
*
|
|
|
|
|
|
* MEASURED on a campus Mac: the cache held whisper-large-v3-turbo as
|
|
|
|
|
|
* encoder_model_q4f16.onnx (its asrDtype) and whisper-small as
|
|
|
|
|
|
* encoder_model_fp16.onnx (the preview's). Stepping down asked for
|
|
|
|
|
|
* whisper-small at q4f16, a file that is not there; it went to the network,
|
|
|
|
|
|
* failed, and logged "Unsupported model type: whisper" three times a minute
|
|
|
|
|
|
* for ever while the service sat dead.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The preview runs whisper-small on exactly these settings every service, so
|
|
|
|
|
|
* they are the one combination this machine is known to be able to load.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const stepDtype = lighter
|
|
|
|
|
|
? resolveDtype(cfg.asrFastDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' })
|
|
|
|
|
|
: resolveDtype(cfg.asrDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' });
|
2026-09-23 15:41:21 -04:00
|
|
|
|
state.asrWorker = await loadWorkerWithFallback('speech', 'automatic-speech-recognition',
|
|
|
|
|
|
state.asrModelInUse || cfg.asrModel,
|
2026-09-24 13:54:26 -04:00
|
|
|
|
stepDtype,
|
2026-09-23 15:41:21 -04:00
|
|
|
|
{ encoder_model: 'q4', decoder_model_merged: 'q4' });
|
|
|
|
|
|
log('speech model restarted' + (lighter ? ' on ' + lighter.split('/').pop() : ''), 'ok');
|
|
|
|
|
|
state.slowRun = 0;
|
2026-09-24 13:54:26 -04:00
|
|
|
|
if (lighter) {
|
|
|
|
|
|
/*
|
|
|
|
|
|
* THROW AWAY THE BACKLOG THAT CAUSED THIS, AND LET THE NEW MODEL SETTLE.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Swapping the model does nothing about the queue that triggered the swap.
|
|
|
|
|
|
* Those chunks are already past the caption-only cutoff, so they can no
|
|
|
|
|
|
* longer be translated in time and are worth nothing to the room — but the
|
|
|
|
|
|
* first of them gets handed to a model that has just loaded, takes the full
|
|
|
|
|
|
* worker timeout, and trips the absolute rule. MEASURED: a campus Mac fell
|
|
|
|
|
|
* large-v3-turbo -> small -> base in 34 seconds exactly that way, landing on
|
|
|
|
|
|
* a model SLOWER than the one it started from.
|
|
|
|
|
|
*
|
|
|
|
|
|
* So drop them, and refuse to step down again for a grace window, so the new
|
|
|
|
|
|
* model is judged on sentences it was actually given.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const dropped = queue.length;
|
|
|
|
|
|
queue.length = 0;
|
|
|
|
|
|
state.steppedDownAt = Date.now();
|
|
|
|
|
|
if (dropped) log('dropped ' + dropped + ' stale sentence' + (dropped === 1 ? '' : 's') +
|
|
|
|
|
|
' the old model never reached', 'warn');
|
|
|
|
|
|
}
|
2026-09-23 15:41:21 -04:00
|
|
|
|
}
|
|
|
|
|
|
} 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) ? '(?<![' + WORDY + '])' : '';
|
|
|
|
|
|
post = WORDY_END.test(from) ? '(?![' + WORDY + '])' : '';
|
|
|
|
|
|
} else if (from.length <= 1) {
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Chinese, Japanese, Korean and Thai have no spaces, so \b is meaningless
|
|
|
|
|
|
* and plain substring matching eats words from the inside — an override of
|
|
|
|
|
|
* 구원 turned 구원자 ("saviour") into 救い자.
|
|
|
|
|
|
*
|
|
|
|
|
|
* But refusing every glued match is worse: in Chinese EVERYTHING is glued,
|
|
|
|
|
|
* so it blocked the very correction it was added for (哈巴古书 → 哈巴谷书
|
|
|
|
|
|
* sits between 向 and 第).
|
|
|
|
|
|
*
|
|
|
|
|
|
* The floor is now ONE character, not two. At two, the names that matter
|
|
|
|
|
|
* most could never fire — 约翰, 摩西, 彼得, 保罗, 마가 are all two
|
|
|
|
|
|
* characters, and in running text their neighbours are always CJK, so the
|
|
|
|
|
|
* lookaround blocked every one of them silently. A single character is
|
|
|
|
|
|
* still guarded, because one Han character is almost always a fragment.
|
|
|
|
|
|
*
|
|
|
|
|
|
* ACCEPTED COST: a two-character COMMON noun can now match inside a longer
|
|
|
|
|
|
* word again — an override of 구원 will touch 구원자 ("saviour"). The
|
|
|
|
|
|
* remedy is longest-match-first, which this table already does: add 구원자
|
|
|
|
|
|
* as its own entry and it wins. For proper nouns, matching inside a
|
|
|
|
|
|
* compound is what you want anyway (马太 inside 马太福音).
|
|
|
|
|
|
*/
|
|
|
|
|
|
pre = '(?<![' + SCRIPT_NO_SPACES + '])';
|
|
|
|
|
|
post = '(?![' + SCRIPT_NO_SPACES + '])';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* A term written with a capital is matched exactly. Without this, "job"
|
|
|
|
|
|
became "Job" in "I finally got the job I wanted" — in every language. */
|
|
|
|
|
|
/* \p{Lu}, not [A-Z]: É is a capital too, and treating "Éxodo" as lowercase
|
|
|
|
|
|
made it match the ordinary Spanish noun "éxodo". Scripts with no case
|
|
|
|
|
|
(Arabic, Hebrew, Devanagari, CJK) have no \p{Lu} and stay forgiving. */
|
|
|
|
|
|
const flags = (/\p{Lu}/u.test(from) ? 'yu' : 'iyu');
|
|
|
|
|
|
try { return new RegExp(pre + esc + post, flags); } catch (_) { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* One left-to-right pass, longest term first, each match consuming its span.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Running the rules one after another over the whole string lets a later rule
|
|
|
|
|
|
* chew up what an earlier one produced: "first Corinthians" became
|
|
|
|
|
|
* "1 Corinthians" and then a "Corinthians" rule turned that into "1 X". Walking
|
|
|
|
|
|
* the string once and skipping past each replacement makes that impossible.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function applyTable(text, pairs) {
|
|
|
|
|
|
const rules = pairs
|
|
|
|
|
|
.filter((p) => p && p.from)
|
|
|
|
|
|
.sort((a, b) => b.from.length - a.from.length)
|
|
|
|
|
|
.map((p) => ({ re: termMatcher(p.from), to: String(p.to == null ? '' : p.to) }))
|
|
|
|
|
|
.filter((r) => r.re);
|
|
|
|
|
|
if (!rules.length) return text;
|
|
|
|
|
|
|
|
|
|
|
|
let out = '';
|
|
|
|
|
|
let i = 0;
|
|
|
|
|
|
while (i < text.length) {
|
|
|
|
|
|
let hit = null;
|
|
|
|
|
|
for (const rule of rules) {
|
|
|
|
|
|
rule.re.lastIndex = i; // sticky: only matches AT i
|
|
|
|
|
|
const m = rule.re.exec(text);
|
|
|
|
|
|
if (m && m[0].length) { hit = { len: m[0].length, to: rule.to }; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hit) { out += hit.to; i += hit.len; }
|
|
|
|
|
|
else { out += text[i]; i += 1; }
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyFixes(text) {
|
|
|
|
|
|
const fixes = ((state.glossary && state.glossary.fixes) || [])
|
|
|
|
|
|
.filter((f) => f && f.from);
|
|
|
|
|
|
return applyTable(text, fixes);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyOverrides(text, lang) {
|
|
|
|
|
|
const o = (state.glossary && state.glossary.overrides) || {};
|
|
|
|
|
|
/* A variant inherits the base language's pins. Months of an operator's
|
|
|
|
|
|
corrections live under spa_Latn; a Spain listener must not lose them. A
|
|
|
|
|
|
pin set on the variant itself still wins. */
|
|
|
|
|
|
const table = o[lang] || o[NLLB_ALIAS[lang]] || null;
|
|
|
|
|
|
if (!table) return text;
|
|
|
|
|
|
return applyTable(text, Object.keys(table).map((from) => ({ from, to: table[from] })));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function whisperLang(flores) {
|
|
|
|
|
|
const l = window.Languages && window.Languages.get(flores);
|
|
|
|
|
|
return l ? l.tts.split('-')[0] : 'en';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function transcribe(audio, { fast } = {}) {
|
|
|
|
|
|
/* Previews go to the small model, finished sentences to the accurate one.
|
|
|
|
|
|
The buffer is handed to the worker rather than copied — flatten() builds a
|
|
|
|
|
|
fresh array for every job, so detaching it here costs nothing. */
|
|
|
|
|
|
const worker = (fast && state.asrFastWorker) || state.asrWorker;
|
|
|
|
|
|
const opts = {
|
|
|
|
|
|
language: whisperLang(state.source),
|
|
|
|
|
|
task: 'transcribe',
|
|
|
|
|
|
return_timestamps: false
|
|
|
|
|
|
};
|
|
|
|
|
|
/* Cap generation on previews. Handed mid-sentence audio a model can decide to
|
|
|
|
|
|
hallucinate and keep going: one preview ran away to 10 seconds of invented
|
|
|
|
|
|
text, and because the queue runs one job at a time it blocked every other
|
|
|
|
|
|
preview behind it. A finished sentence gets no cap — it needs to say
|
|
|
|
|
|
everything that was said. */
|
|
|
|
|
|
if (fast) opts.max_new_tokens = 96;
|
|
|
|
|
|
|
|
|
|
|
|
const out = await callWorker(worker, audio, opts, [audio.buffer]);
|
|
|
|
|
|
return cleanTranscript(Array.isArray(out) ? out.map((o) => o.text).join(' ') : out.text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* NLLB is a sentence-level model. Handed two sentences it will translate the
|
|
|
|
|
|
* first and quietly stop — which showed up in testing as Haitian and Arabic
|
|
|
|
|
|
* listeners losing half of every long utterance while Spanish looked fine.
|
|
|
|
|
|
* Split first, translate each, rejoin.
|
|
|
|
|
|
*/
|
|
|
|
|
|
/* Titles and initials: a full stop here does not end a sentence. Anchored to
|
|
|
|
|
|
the END of a piece, not the whole of it — the piece is "We heard Dr.", not
|
|
|
|
|
|
"Dr.", except when the title happens to open the segment. */
|
|
|
|
|
|
const ABBREV_TAIL = /(?:^|\s)(?:[A-Z]|Dr|Mr|Mrs|Ms|St|Jr|Sr|Rev|Prof|Fr|Gen|Col|Sgt|Lt|Mt|No|vs|etc)\.$/;
|
|
|
|
|
|
|
|
|
|
|
|
function splitSentences(text) {
|
|
|
|
|
|
const parts = String(text).match(/[^.!?]+[.!?]+["'\)\]]*\s*|[^.!?]+$/g);
|
|
|
|
|
|
if (!parts) return [text];
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
/*
|
|
|
|
|
|
* A short trailing piece is usually an abbreviation or a stray number that
|
|
|
|
|
|
* belongs to its neighbour. But it can also be a whole short sentence, and
|
|
|
|
|
|
* gluing those was losing them outright: NLLB is sentence-level, so
|
|
|
|
|
|
* "God is good. Amen." arrived as one string and came back translated only
|
|
|
|
|
|
* as far as the first full stop. The Amen simply vanished — verified.
|
|
|
|
|
|
*
|
|
|
|
|
|
* A continuation starts lowercase or with a digit. A sentence does not.
|
|
|
|
|
|
* An abbreviation is held and prepended to what follows, instead of being
|
|
|
|
|
|
* pushed alone ("Dr." used to reach NLLB by itself when it led a segment).
|
|
|
|
|
|
*/
|
|
|
|
|
|
let pending = '';
|
|
|
|
|
|
for (const raw of parts) {
|
|
|
|
|
|
let part = raw.trim();
|
|
|
|
|
|
if (!part) continue;
|
|
|
|
|
|
if (pending) { part = pending + ' ' + part; pending = ''; }
|
|
|
|
|
|
if (ABBREV_TAIL.test(part)) { pending = part; continue; }
|
|
|
|
|
|
|
|
|
|
|
|
const continuation = /^[\p{Ll}\p{N}]/u.test(part);
|
|
|
|
|
|
if (out.length && part.length < 12 && continuation) {
|
|
|
|
|
|
const prev = out[out.length - 1];
|
|
|
|
|
|
/* "It cost 3.5 million." splits at the decimal point. Rejoining with a
|
|
|
|
|
|
space left "It cost 3. 5 million."; close the number back up. */
|
|
|
|
|
|
out[out.length - 1] = (/\d\.$/.test(prev) && /^\d/.test(part))
|
|
|
|
|
|
? prev.slice(0, -1) + '.' + part
|
|
|
|
|
|
: prev + ' ' + part;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
out.push(part);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pending) {
|
|
|
|
|
|
if (out.length) out[out.length - 1] += ' ' + pending;
|
|
|
|
|
|
else out.push(pending);
|
|
|
|
|
|
}
|
|
|
|
|
|
return out.length ? out : [text];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* How many languages may be in flight at once. This is no longer a limit on
|
|
|
|
|
|
* parallel inferences — it is how many languages get to join the same batched
|
|
|
|
|
|
* decode (see mtq below). Every language that arrives inside the window rides
|
|
|
|
|
|
* one pass over the model, so a wider number is now cheaper, not dearer.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const MT_CONCURRENCY = 12;
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* ──────────────────────────────── mtq ─────────────────────────────────────
|
|
|
|
|
|
*
|
|
|
|
|
|
* Every language asking for the SAME sentence inside one short window is sent
|
|
|
|
|
|
* to the model together and comes back from a single batched decode. NLLB
|
|
|
|
|
|
* chooses its target with one decoder token, so the languages differ by
|
|
|
|
|
|
* exactly one token and share everything else — see mt-service.js for why
|
|
|
|
|
|
* that is true and what had to be built to exploit it.
|
|
|
|
|
|
*
|
|
|
|
|
|
* MEASURED on this M2 Max against the same work one language at a time:
|
|
|
|
|
|
* 1.59x for two languages, 1.93x for three, 3.06x for five, 3.89x for nine,
|
|
|
|
|
|
* with byte-identical output in 9 of 9 languages.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The window is 12 ms. Nothing waits on it in practice: the translation
|
|
|
|
|
|
* process is serial, so the first language to ask was going to sit behind the
|
|
|
|
|
|
* model anyway, and every language of one sentence is dispatched in the same
|
|
|
|
|
|
* turn of the event loop.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const MT_BATCH_MS = 12;
|
|
|
|
|
|
const MT_BATCH_MAX = 16;
|
|
|
|
|
|
let mtBatch = null;
|
|
|
|
|
|
|
|
|
|
|
|
function flushMtBatch() {
|
|
|
|
|
|
const b = mtBatch;
|
|
|
|
|
|
if (!b) return;
|
|
|
|
|
|
mtBatch = null;
|
|
|
|
|
|
clearTimeout(b.timer);
|
|
|
|
|
|
|
|
|
|
|
|
const tgts = Array.from(b.waiters.keys());
|
|
|
|
|
|
const settle = (tgt, text) => {
|
|
|
|
|
|
for (const w of b.waiters.get(tgt)) w.resolve(text);
|
|
|
|
|
|
};
|
|
|
|
|
|
const fail = (tgt, err) => {
|
|
|
|
|
|
for (const w of b.waiters.get(tgt)) w.reject(err);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/* One language is not a batch — the ordinary call has less to set up. */
|
|
|
|
|
|
if (tgts.length === 1) {
|
|
|
|
|
|
window.engineHost.mtTranslate({ text: b.text, src: b.src, tgt: tgts[0] })
|
|
|
|
|
|
.then((t) => settle(tgts[0], t), (e) => fail(tgts[0], e));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.engineHost.mtTranslateMulti({ text: b.text, src: b.src, tgts })
|
|
|
|
|
|
.then((texts) => {
|
|
|
|
|
|
for (const t of tgts) {
|
|
|
|
|
|
/* A language the batch somehow skipped still gets its sentence. */
|
|
|
|
|
|
if (typeof texts[t] === 'string') settle(t, texts[t]);
|
|
|
|
|
|
else window.engineHost.mtTranslate({ text: b.text, src: b.src, tgt: t })
|
|
|
|
|
|
.then((x) => settle(t, x), (e) => fail(t, e));
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((e) => {
|
|
|
|
|
|
/* Batching is an optimisation, never a dependency: an older main
|
|
|
|
|
|
process, or a library that moved what the batch needs, falls back to
|
|
|
|
|
|
one pass per language and the service keeps running. */
|
|
|
|
|
|
log('batched translation failed (' + e.message + ') — one at a time', 'warn');
|
|
|
|
|
|
for (const t of tgts) {
|
|
|
|
|
|
window.engineHost.mtTranslate({ text: b.text, src: b.src, tgt: t })
|
|
|
|
|
|
.then((x) => settle(t, x), (err) => fail(t, err));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mtq(text, src, tgt) {
|
|
|
|
|
|
if (!window.engineHost.mtTranslateMulti) {
|
|
|
|
|
|
return window.engineHost.mtTranslate({ text, src, tgt });
|
|
|
|
|
|
}
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
/* A batch is one sentence: different text cannot share an encode. */
|
|
|
|
|
|
if (mtBatch && (mtBatch.text !== text || mtBatch.src !== src)) flushMtBatch();
|
|
|
|
|
|
if (!mtBatch) {
|
|
|
|
|
|
mtBatch = { text, src, waiters: new Map(), timer: null };
|
|
|
|
|
|
mtBatch.timer = setTimeout(flushMtBatch, MT_BATCH_MS);
|
|
|
|
|
|
}
|
|
|
|
|
|
const b = mtBatch;
|
|
|
|
|
|
let arr = b.waiters.get(tgt);
|
|
|
|
|
|
if (!arr) { arr = []; b.waiters.set(tgt, arr); }
|
|
|
|
|
|
arr.push({ resolve, reject });
|
|
|
|
|
|
if (b.waiters.size >= MT_BATCH_MAX) flushMtBatch();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function translateOne(sentences, target, tail) {
|
|
|
|
|
|
/* Sentences stay in order within a language — they get rejoined. */
|
|
|
|
|
|
const parts = [];
|
|
|
|
|
|
for (const sentence of sentences) {
|
|
|
|
|
|
/* Same short-cuts as the final path, or a preview shows the invented line
|
|
|
|
|
|
for a second before the final replaces it. Not on a TAIL window: its
|
|
|
|
|
|
first "sentence" is the middle of a longer one, and "What" from "What
|
|
|
|
|
|
shall we say to these things?" is not a question of its own. */
|
|
|
|
|
|
const fixed = !tail && ((window.Liturgy && window.Liturgy.lookup(sentence, target)) ||
|
|
|
|
|
|
(state.source === 'eng_Latn' && window.ShortQuestions && window.ShortQuestions.lookup(sentence, target)));
|
|
|
|
|
|
if (fixed) { parts.push(fixed); continue; }
|
|
|
|
|
|
const pre = window.Preach ? window.Preach.forTranslation(sentence) : sentence;
|
|
|
|
|
|
const sd = window.Preach ? window.Preach.stripShortPeriod(pre) : { text: pre, hadPeriod: false };
|
|
|
|
|
|
let piece = await mtq(sd.text, state.source, NLLB_ALIAS[target] || target);
|
|
|
|
|
|
/* The same guard the final gets — a preview used to show the invented
|
|
|
|
|
|
line for a second before the final replaced it. */
|
|
|
|
|
|
if (window.MtGuard && piece) piece = window.MtGuard.clean(sd.text, piece).text;
|
|
|
|
|
|
if (window.Preach) piece = window.Preach.restorePeriod(piece, sd.hadPeriod);
|
|
|
|
|
|
if (window.PtVariants && piece) {
|
|
|
|
|
|
if (target === 'por_Latn') piece = window.PtVariants.toBrazilian(piece);
|
|
|
|
|
|
else if (target === 'por_PT') piece = window.PtVariants.toEuropean(piece);
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Previews applied the Portuguese passes but never the Spanish ones, so a
|
|
|
|
|
|
preview read "bendecemos" and the final swapped to "bendecimos". */
|
|
|
|
|
|
if (window.SpaVariants && piece && (target === 'spa_Latn' || target === 'spa_ES' || target === 'spa_MX')) {
|
|
|
|
|
|
piece = window.SpaVariants.toChurchRegister(piece);
|
|
|
|
|
|
piece = target === 'spa_ES' ? window.SpaVariants.toPeninsular(piece) : window.SpaVariants.toLatinAmerican(piece);
|
|
|
|
|
|
}
|
|
|
|
|
|
parts.push(piece);
|
|
|
|
|
|
}
|
|
|
|
|
|
return parts.filter(Boolean).join(' ').trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function translateAll(text, targets, tail) {
|
|
|
|
|
|
const sentences = splitSentences(text);
|
|
|
|
|
|
const tr = {};
|
|
|
|
|
|
|
|
|
|
|
|
const queue = targets.filter((t) => {
|
|
|
|
|
|
if (t === state.source) { tr[t] = text; return false; }
|
|
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/* Languages run in parallel. Sequentially, a congregation reading four
|
|
|
|
|
|
languages waited for all four to be produced one after another — the last
|
|
|
|
|
|
one was seconds behind the preacher. */
|
|
|
|
|
|
let next = 0;
|
|
|
|
|
|
async function worker() {
|
|
|
|
|
|
while (next < queue.length) {
|
|
|
|
|
|
const target = queue[next++];
|
|
|
|
|
|
try {
|
|
|
|
|
|
const joined = await translateOne(sentences, target, tail);
|
|
|
|
|
|
if (joined) tr[target] = applyOverrides(joined, target);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
/* One bad language must not cost the others their sentence. */
|
|
|
|
|
|
log('translation failed for ' + target + ': ' + e.message, 'err');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
|
Array.from({ length: Math.min(MT_CONCURRENCY, queue.length) }, worker)
|
|
|
|
|
|
);
|
|
|
|
|
|
return tr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────── work queue
|
|
|
|
|
|
|
|
|
|
|
|
const queue = [];
|
|
|
|
|
|
|
|
|
|
|
|
function queueFinal(frames, reason) {
|
|
|
|
|
|
queue.push({ frames, at: Date.now(), reason });
|
|
|
|
|
|
/* The sentence is over; anything still queued as a preview describes audio
|
|
|
|
|
|
the congregation is about to read properly. */
|
|
|
|
|
|
partialNext = null;
|
|
|
|
|
|
pump();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Previews run on their own track, not in the queue with finished sentences.
|
|
|
|
|
|
*
|
|
|
|
|
|
* They share nothing but the GPU: the preview uses whisper-small in one worker,
|
|
|
|
|
|
* the finished line uses the accurate model in another. Putting both through
|
|
|
|
|
|
* one queue meant every finished sentence waited behind whatever preview
|
|
|
|
|
|
* happened to be running — measured, that pushed the finished line from 1.3 s
|
|
|
|
|
|
* to 3.3 s behind the speaker. The whole point of a preview is to cost the real
|
|
|
|
|
|
* captions nothing.
|
|
|
|
|
|
*/
|
|
|
|
|
|
let partialBusy = false;
|
|
|
|
|
|
let partialNext = null;
|
|
|
|
|
|
|
|
|
|
|
|
function queuePartial() {
|
|
|
|
|
|
const long = state.segment.length > PARTIAL_WINDOW;
|
|
|
|
|
|
const frames = long ? state.segment.slice(-PARTIAL_WINDOW) : state.segment.slice();
|
|
|
|
|
|
/* Only the newest guess is worth anything — replace, never queue up. */
|
|
|
|
|
|
partialNext = { frames, at: Date.now(), tail: long };
|
|
|
|
|
|
pumpPartial();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function pumpPartial() {
|
|
|
|
|
|
if (partialBusy || !partialNext || !state.modelsReady || !state.live) return;
|
|
|
|
|
|
const job = partialNext;
|
|
|
|
|
|
partialNext = null;
|
|
|
|
|
|
partialBusy = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const text = await transcribe(flatten(job.frames), { fast: true });
|
|
|
|
|
|
/* Speech may have ended while this was in flight; a preview after the real
|
|
|
|
|
|
line has landed would overwrite it with something less accurate. */
|
|
|
|
|
|
if (!state.live || !state.speaking) return;
|
|
|
|
|
|
/* A one-word fragment left over from the rolling window is noise, not a
|
|
|
|
|
|
preview. */
|
|
|
|
|
|
if (!text || (job.tail && text.length < 4)) return;
|
|
|
|
|
|
/* A preview of a filler is the invented line, one second earlier. */
|
|
|
|
|
|
if (classifyFragment(text)) return;
|
|
|
|
|
|
|
|
|
|
|
|
/* Segmentation hint: a preview that ends in terminal punctuation means the
|
|
|
|
|
|
sentence is probably finished. Tail windows lie about this (they start
|
|
|
|
|
|
mid-sentence), so they never set it. */
|
|
|
|
|
|
state.previewPunct = !job.tail && /[.!?\u3002\uff01\uff1f]["'\u201d\u2019)\]]*\s*$/.test(text);
|
|
|
|
|
|
state.previewAt = Date.now();
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Preview the languages with live listeners, most-listened first, up to
|
|
|
|
|
|
* the budget. Falls back to the active set only when nobody has reported
|
|
|
|
|
|
* a language yet (the first seconds of a service), so an early listener
|
|
|
|
|
|
* still gets a preview.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const heard = (state.byLanguage || [])
|
|
|
|
|
|
.filter((r) => r && 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();
|
2026-09-24 13:24:05 -04:00
|
|
|
|
let jobAudioMs = 0;
|
2026-09-23 15:41:21 -04:00
|
|
|
|
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);
|
2026-09-24 13:24:05 -04:00
|
|
|
|
/* 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;
|
2026-09-23 15:41:21 -04:00
|
|
|
|
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();
|
2026-09-24 14:32:25 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* SKIPPING A TRANSLATION ONLY BUYS SOMETHING IF THERE IS A QUEUE TO DRAIN.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Dropping the translations of stale audio is how the app catches up: the
|
|
|
|
|
|
* listener has moved on, so the work is wasted and the time is better spent on
|
|
|
|
|
|
* the sentences behind it. That reasoning needs a backlog. With nothing waiting
|
|
|
|
|
|
* behind it, skipping costs the room a sentence and saves nothing at all.
|
|
|
|
|
|
*
|
|
|
|
|
|
* MEASURED: a campus Mac took one 16 s stall — two Screen Sharing sessions
|
|
|
|
|
|
* competing for the machine — and skipped that sentence's translations with an
|
|
|
|
|
|
* EMPTY queue. The room lost a line of the sermon to a catch-up that had
|
|
|
|
|
|
* nothing to catch up on.
|
|
|
|
|
|
*/
|
|
|
|
|
|
if (ageSec > 12 && queue.length > 0) {
|
2026-09-23 15:41:21 -04:00
|
|
|
|
targets = [];
|
|
|
|
|
|
state.captionOnlyFinals++;
|
2026-09-24 14:32:25 -04:00
|
|
|
|
state.captionOnlyAt = Date.now();
|
|
|
|
|
|
log('audio ' + ageSec.toFixed(0) + 's stale with ' + queue.length + ' waiting — ' +
|
|
|
|
|
|
'caption only, translations skipped', 'err');
|
|
|
|
|
|
} else if (ageSec > 12) {
|
|
|
|
|
|
/* Late, but nothing is behind it: say so, and translate it anyway. */
|
|
|
|
|
|
log('audio ' + ageSec.toFixed(0) + 's late, but nothing is queued — translating it anyway', 'warn');
|
2026-09-23 15:41:21 -04:00
|
|
|
|
} 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));
|
2026-09-24 14:32:25 -04:00
|
|
|
|
if (targets.length < before) { state.degradedFinals++; state.degradedAt = Date.now(); }
|
2026-09-23 15:41:21 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 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.
|
|
|
|
|
|
*/
|
2026-09-24 13:24:05 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*/
|
|
|
|
|
|
/*
|
2026-09-24 13:32:37 -04:00
|
|
|
|
* SLOWER THAN THE AUDIO IT COVERS, AND LANDING LATE.
|
2026-09-24 13:24:05 -04:00
|
|
|
|
*
|
2026-09-24 13:32:37 -04:00
|
|
|
|
* This used to fire only past 8 s, on the reasoning that 8 s meant slower
|
|
|
|
|
|
* than people talk. That held when a chunk could be 8 s long; chunks are
|
|
|
|
|
|
* capped at HARD_CUT_MAX, 5.5 s, so a 4.4 s sentence is already losing and
|
|
|
|
|
|
* never came near the old threshold.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Both numbers below were measured on a campus M4 Pro, and the second one
|
|
|
|
|
|
* only after a first attempt got it wrong. 0.85, because that Mac ran 4.4 s
|
|
|
|
|
|
* against 4.5 s of audio — nominally FASTER than real time while losing
|
|
|
|
|
|
* badly, since the work outside this timer (segmenting, the preview model
|
|
|
|
|
|
* sharing the GPU, the scheduler) loses on its own.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The second test was a queue depth, and watching that Mac go red disproved
|
|
|
|
|
|
* it: the queue sat at 2-3 while the lag held at 15 s and the verdict was
|
|
|
|
|
|
* already red. A machine can be persistently late without ever stacking work
|
|
|
|
|
|
* up — it finishes each sentence just slightly too slowly, for ever. So the
|
|
|
|
|
|
* test is how far behind the speaker the sentence actually landed, which is
|
|
|
|
|
|
* what the operator is complaining about in the first place.
|
2026-09-24 13:24:05 -04:00
|
|
|
|
*/
|
2026-09-24 13:54:26 -04:00
|
|
|
|
/* A model that loaded seconds ago has not had a fair sentence yet. */
|
|
|
|
|
|
const settling = state.steppedDownAt && (Date.now() - state.steppedDownAt) < STEP_SETTLE_MS;
|
2026-09-24 13:32:37 -04:00
|
|
|
|
const behindMs = Date.now() - job.at;
|
|
|
|
|
|
const losingGround = jobAudioMs > 0 && took > jobAudioMs * 0.85 && behindMs > 8000;
|
2026-09-24 13:54:26 -04:00
|
|
|
|
if (!settling && (took > 8000 || losingGround)) {
|
2026-09-23 15:41:21 -04:00
|
|
|
|
state.slowRun = (state.slowRun || 0) + 1;
|
|
|
|
|
|
if (state.slowRun >= 3 && !respawning && lighterAsr(state.asrModelInUse || state.cfg.asrModel)) {
|
|
|
|
|
|
state.slowRun = 0;
|
2026-09-24 13:24:05 -04:00
|
|
|
|
log('transcription is slower than people are speaking (' + took + ' ms for ' +
|
2026-09-24 13:32:37 -04:00
|
|
|
|
Math.round(jobAudioMs) + ' ms of audio, ' + Math.round(behindMs / 1000) + 's behind) — ' +
|
2026-09-24 13:24:05 -04:00
|
|
|
|
'stepping down to a lighter model', 'err');
|
2026-09-23 15:41:21 -04:00
|
|
|
|
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.
|
|
|
|
|
|
*/
|
2026-09-24 13:54:26 -04:00
|
|
|
|
/* How long a freshly swapped speech model is left alone. Long enough to cover a
|
|
|
|
|
|
cold first inference, short enough that a genuinely hopeless model still falls
|
|
|
|
|
|
through the ladder within a sentence or two. */
|
|
|
|
|
|
const STEP_SETTLE_MS = 20000;
|
|
|
|
|
|
|
2026-09-23 15:41:21 -04:00
|
|
|
|
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;
|
2026-09-24 14:32:25 -04:00
|
|
|
|
state.captionOnlyAt = 0; state.degradedAt = 0;
|
2026-09-23 15:41:21 -04:00
|
|
|
|
/* 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,
|
2026-09-24 14:32:25 -04:00
|
|
|
|
/* WHEN, not just how many — the verdict needs to know whether this is
|
|
|
|
|
|
happening now or happened once an hour ago. */
|
|
|
|
|
|
captionOnlyAt: state.captionOnlyAt || 0,
|
|
|
|
|
|
degradedAt: state.degradedAt || 0,
|
2026-09-23 15:41:21 -04:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 12:09:08 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* ─────────────────────────── 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; }
|
2026-09-24 12:50:05 -04:00
|
|
|
|
$('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;
|
|
|
|
|
|
}
|
2026-09-24 12:09:08 -04:00
|
|
|
|
} 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.';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-23 15:41:21 -04:00
|
|
|
|
$('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)
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 13:40:30 -04:00
|
|
|
|
/* This Mac on the church network. The operator page lives here and NOWHERE
|
|
|
|
|
|
else: it returns 404 to anything arriving through a proxy, by design. */
|
|
|
|
|
|
function lanBase() {
|
2026-09-23 15:41:21 -04:00
|
|
|
|
const c = shareChoice();
|
|
|
|
|
|
if (!c || c.none) return '';
|
|
|
|
|
|
return 'http://' + c.addr.address + ':' + state.local.port;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 13:40:30 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* WHAT THE CONGREGATION OPENS.
|
|
|
|
|
|
*
|
|
|
|
|
|
* A church that has put this on a domain has said so in the operator page's
|
|
|
|
|
|
* Public address, and from that moment the LAN address is the wrong thing to
|
|
|
|
|
|
* print: it is not what is on the poster, not what the QR should encode, and
|
|
|
|
|
|
* not what someone off the building's wifi can reach. The app window went on
|
|
|
|
|
|
* showing the IP anyway, because this half of it never read that setting --
|
|
|
|
|
|
* the operator page honoured it and the main window did not, so the two
|
|
|
|
|
|
* disagreed about the one fact they both exist to display.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function publicBaseConfigured() {
|
|
|
|
|
|
const u = opConfig && typeof opConfig.publicUrl === 'string' ? opConfig.publicUrl.trim() : '';
|
|
|
|
|
|
return u ? u.replace(/\/+$/, '') : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function shareBase() {
|
|
|
|
|
|
return publicBaseConfigured() || lanBase();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 15:41:21 -04:00
|
|
|
|
function renderQrWarning() {
|
|
|
|
|
|
const el = $('qrWarn');
|
2026-10-15 13:40:30 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* On a domain the warnings below are about an address nobody is being
|
|
|
|
|
|
* given any more. Saying "Wi-Fi is down, phones cannot reach the code"
|
|
|
|
|
|
* while the code points at a proxy would send the desk after the wrong
|
|
|
|
|
|
* thing entirely.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const pub = publicBaseConfigured();
|
|
|
|
|
|
if (pub) {
|
|
|
|
|
|
el.hidden = false;
|
|
|
|
|
|
el.textContent = 'The code points at ' + pub + ', set as the public address. ' +
|
|
|
|
|
|
'This Mac still has to be reachable from there; its own address is ' +
|
|
|
|
|
|
(lanBase() || 'unavailable') + '.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-23 15:41:21 -04:00
|
|
|
|
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 = '';
|
2026-10-15 13:48:18 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* WHICH INTERFACE THE QR USES IS A QUESTION ONLY A LAN SETUP HAS.
|
|
|
|
|
|
*
|
|
|
|
|
|
* These rows let the operator pick the address the code points at. Once a
|
|
|
|
|
|
* public address is configured the code points at the domain whatever they
|
|
|
|
|
|
* pick, so the list is a row of numbers that do nothing — and the one
|
|
|
|
|
|
* marked IN USE is not the address anyone is being given. Hide it.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const addrBox = $('addrList');
|
|
|
|
|
|
addrBox.hidden = !!publicBaseConfigured();
|
|
|
|
|
|
if (addrBox.hidden) {
|
|
|
|
|
|
/* Still draw the code itself — it is the only thing left in this panel. */
|
|
|
|
|
|
const only = shareBase();
|
|
|
|
|
|
$('qr').src = only
|
|
|
|
|
|
? 'http://127.0.0.1:' + state.local.port + '/qr.svg?url=' + encodeURIComponent(only + '/')
|
|
|
|
|
|
: '';
|
|
|
|
|
|
renderQrWarning();
|
|
|
|
|
|
renderOperatorUrl();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-23 15:41:21 -04:00
|
|
|
|
|
|
|
|
|
|
/* 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'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-15 13:48:18 -04:00
|
|
|
|
/*
|
|
|
|
|
|
* The QR itself, saved as a file, because the poster people actually print
|
|
|
|
|
|
* is made in their own design tool. The built-in poster page is still there
|
|
|
|
|
|
* at /poster for anyone who wants it; it is just not what this button does.
|
|
|
|
|
|
*/
|
|
|
|
|
|
$('saveQr').addEventListener('click', () => {
|
|
|
|
|
|
const base = shareBase();
|
|
|
|
|
|
if (!base) { log('no address to encode yet', 'err'); return; }
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = 'http://127.0.0.1:' + state.local.port + '/qr.svg?url=' +
|
|
|
|
|
|
encodeURIComponent(base + '/');
|
|
|
|
|
|
a.download = 'church-translate-qr.svg';
|
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
a.remove();
|
|
|
|
|
|
log('saved the QR for ' + base + '/', 'ok');
|
2026-09-23 15:41:21 -04:00
|
|
|
|
});
|
|
|
|
|
|
$('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() {
|
2026-10-15 13:40:30 -04:00
|
|
|
|
/* Deliberately lanBase, not shareBase: over a proxy /operator is a 404. */
|
|
|
|
|
|
const base = lanBase();
|
2026-09-23 15:41:21 -04:00
|
|
|
|
$('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();
|
2026-09-24 12:09:08 -04:00
|
|
|
|
try { $('updToken').value = (state.cfg && state.cfg.updateToken) || ''; } catch (_) {}
|
2026-09-23 15:41:21 -04:00
|
|
|
|
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;
|
2026-10-15 13:40:30 -04:00
|
|
|
|
renderShare(); /* publicUrl may have just arrived */
|
2026-09-23 15:41:21 -04:00
|
|
|
|
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;
|
2026-10-15 13:40:30 -04:00
|
|
|
|
renderShare(); /* publicUrl may have just arrived */
|
2026-09-23 15:41:21 -04:00
|
|
|
|
if (okMsg) log(okMsg, 'ok');
|
|
|
|
|
|
return j;
|
|
|
|
|
|
} catch (e) { log('could not save: ' + e.message, 'err'); throw e; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function opChip(code, onRemove) {
|
|
|
|
|
|
const l = window.Languages && window.Languages.get(code);
|
|
|
|
|
|
const el = document.createElement('span');
|
|
|
|
|
|
el.className = 'chip';
|
|
|
|
|
|
el.dataset.code = code;
|
|
|
|
|
|
el.textContent = l ? l.native : code;
|
|
|
|
|
|
const x = document.createElement('button');
|
|
|
|
|
|
x.type = 'button';
|
|
|
|
|
|
x.textContent = '×';
|
|
|
|
|
|
x.setAttribute('aria-label', 'Remove ' + (l ? l.name : code));
|
|
|
|
|
|
x.addEventListener('click', () => onRemove(code));
|
|
|
|
|
|
el.appendChild(x);
|
|
|
|
|
|
return el;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Pinned languages, without rebuilding the strip. Emptying the container and
|
|
|
|
|
|
* re-appending every chip is what made a pin appear from nowhere and an
|
|
|
|
|
|
* unpinned one vanish mid-blink — the same "teleport" the language columns
|
|
|
|
|
|
* had. Chips that are still pinned keep their node (so nothing flickers), a
|
|
|
|
|
|
* new one grows in, and a removed one shrinks out and then leaves.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const CHIP_OUT_MS = 190;
|
|
|
|
|
|
|
|
|
|
|
|
function unpin(code) {
|
|
|
|
|
|
const el = $('opPinned').querySelector('.chip[data-code="' + code + '"]');
|
|
|
|
|
|
if (el) leaveChip(el);
|
|
|
|
|
|
return opSave({ pinned: (opConfig.pinned || []).filter((x) => x !== code) })
|
|
|
|
|
|
.then(renderOpLanguages);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function leaveChip(el) {
|
|
|
|
|
|
if (el.dataset.leaving) return;
|
|
|
|
|
|
el.dataset.leaving = '1';
|
|
|
|
|
|
el.classList.add('chip-out');
|
|
|
|
|
|
setTimeout(() => el.remove(), CHIP_OUT_MS);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderPinnedChips() {
|
|
|
|
|
|
const pin = $('opPinned');
|
|
|
|
|
|
const want = (opConfig.pinned || []);
|
|
|
|
|
|
|
|
|
|
|
|
/* A chip already on its way out is not a chip: it must not be reused, and
|
|
|
|
|
|
it must not make the strip look occupied to :empty. */
|
|
|
|
|
|
const have = new Map();
|
|
|
|
|
|
for (const el of Array.from(pin.children)) {
|
|
|
|
|
|
if (el.dataset.leaving) continue;
|
|
|
|
|
|
have.set(el.dataset.code, el);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [code, el] of have) if (want.indexOf(code) === -1) leaveChip(el);
|
|
|
|
|
|
|
|
|
|
|
|
want.forEach((code, i) => {
|
|
|
|
|
|
let el = have.get(code);
|
|
|
|
|
|
if (!el) {
|
|
|
|
|
|
el = opChip(code, unpin);
|
|
|
|
|
|
el.classList.add('chip-in');
|
|
|
|
|
|
/* Dropped rather than left on: a class that outlives its animation
|
|
|
|
|
|
replays it the next time the browser recalculates style. */
|
|
|
|
|
|
setTimeout(() => el.classList.remove('chip-in'), 260);
|
|
|
|
|
|
}
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Position among the chips that are STAYING. Counting the leaving ones
|
|
|
|
|
|
* would slide a chip that is mid-exit to the end of the row before it
|
|
|
|
|
|
* finished shrinking — a chip you just removed would jump sideways first,
|
|
|
|
|
|
* which is the exact effect this whole change exists to remove.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const live = pin.children ? Array.prototype.filter.call(pin.children, (c) => !c.dataset.leaving) : [];
|
|
|
|
|
|
const at = live[i];
|
|
|
|
|
|
if (at !== el) pin.insertBefore(el, at || null);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* A pin flies from the search result it was picked from to the chip it
|
|
|
|
|
|
becomes, for the same reason the language rows do: two lists far apart on a
|
|
|
|
|
|
long page, and no way to tell where a thing went if it simply appears. */
|
|
|
|
|
|
function flyChip(fromRect, code) {
|
|
|
|
|
|
if (!fromRect) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
const dest = $('opPinned').querySelector('.chip[data-code="' + code + '"]');
|
|
|
|
|
|
if (!dest) return;
|
|
|
|
|
|
const to = dest.getBoundingClientRect();
|
|
|
|
|
|
if (!to.width || !to.height) return;
|
|
|
|
|
|
|
|
|
|
|
|
const fly = dest.cloneNode(true);
|
|
|
|
|
|
fly.className = 'chip chipfly';
|
|
|
|
|
|
fly.style.left = fromRect.left + 'px';
|
|
|
|
|
|
fly.style.top = fromRect.top + 'px';
|
|
|
|
|
|
document.body.appendChild(fly);
|
|
|
|
|
|
dest.classList.add('is-landing');
|
|
|
|
|
|
|
|
|
|
|
|
const dx = to.left - fromRect.left, dy = to.top - fromRect.top;
|
|
|
|
|
|
requestAnimationFrame(() => { requestAnimationFrame(() => {
|
|
|
|
|
|
fly.style.transition = 'transform 240ms cubic-bezier(.2,.7,.3,1), opacity 240ms ease-out';
|
|
|
|
|
|
fly.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
|
|
|
|
|
|
fly.style.opacity = '.9';
|
|
|
|
|
|
}); });
|
|
|
|
|
|
/* A timer, not transitionend — the chip strip can be rebuilt underneath. */
|
|
|
|
|
|
setTimeout(() => { fly.remove(); dest.classList.remove('is-landing'); }, 300);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* The routing matrix lists the languages actually in play, so it has to be
|
|
|
|
|
|
redrawn when that set changes. */
|
|
|
|
|
|
function refreshOutMapIfShown() {
|
|
|
|
|
|
if (state.outChannels) { renderOutMap(); renderSummaries(); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderOpLanguages() {
|
|
|
|
|
|
if (!opConfig) return;
|
|
|
|
|
|
renderOpLangList();
|
|
|
|
|
|
|
|
|
|
|
|
renderPinnedChips();
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Text and speech do not cover the same languages and never will, so say
|
|
|
|
|
|
* both numbers rather than one. The count of languages that can be SPOKEN
|
|
|
|
|
|
* comes from the voice catalog, not from a hardcoded figure, so it stays
|
|
|
|
|
|
* true as voices are added or dropped.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const all = !!opConfig.allowAllLanguages;
|
|
|
|
|
|
$('opAllowAll').checked = all;
|
|
|
|
|
|
const total = (window.Languages && window.Languages.codes().length) || 0;
|
|
|
|
|
|
const spoken = (window.VoiceCatalog && window.VoiceCatalog.languages().length) || 0;
|
|
|
|
|
|
$('opAllowAllHint').textContent = all
|
|
|
|
|
|
? 'On — all ' + total + ' languages are offered. ' + spoken + ' of them have a voice; ' +
|
|
|
|
|
|
'the rest show captions only.'
|
|
|
|
|
|
: 'Off — listeners can only choose the ' + (opConfig.featured || []).length +
|
|
|
|
|
|
' languages on the front page, out of ' + total + '.';
|
|
|
|
|
|
renderSummaries();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function opSearch(inputId, resultsId, apply) {
|
|
|
|
|
|
let timer = null;
|
|
|
|
|
|
/*
|
|
|
|
|
|
* An empty box used to render an empty list, so there was no way to SEE what
|
|
|
|
|
|
* could be pinned — you had to already know the name to type it. Empty now
|
|
|
|
|
|
* means "show everything", alphabetically.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const render = (q) => {
|
|
|
|
|
|
const box = $(resultsId);
|
|
|
|
|
|
box.textContent = '';
|
|
|
|
|
|
if (!window.Languages) return;
|
|
|
|
|
|
const hits = q.trim()
|
|
|
|
|
|
? window.Languages.search(q)
|
|
|
|
|
|
: window.Languages.codes()
|
|
|
|
|
|
.map((c) => window.Languages.get(c))
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
|
|
|
|
hits.slice(0, q.trim() ? 20 : 200).forEach((l) => {
|
|
|
|
|
|
const b = document.createElement('button');
|
|
|
|
|
|
b.type = 'button';
|
|
|
|
|
|
b.textContent = l.native;
|
|
|
|
|
|
const s = document.createElement('span');
|
|
|
|
|
|
s.textContent = l.name;
|
|
|
|
|
|
b.appendChild(s);
|
|
|
|
|
|
b.addEventListener('click', () => {
|
|
|
|
|
|
/* Measured BEFORE the list closes — afterwards there is nothing to
|
|
|
|
|
|
measure and the flight would start from the origin. */
|
|
|
|
|
|
apply(l.code, b.getBoundingClientRect());
|
|
|
|
|
|
$(inputId).value = '';
|
|
|
|
|
|
box.textContent = '';
|
|
|
|
|
|
});
|
|
|
|
|
|
box.appendChild(b);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
$(inputId).addEventListener('input', (e) => {
|
|
|
|
|
|
const q = e.target.value;
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
timer = setTimeout(() => render(q), 90);
|
|
|
|
|
|
});
|
|
|
|
|
|
/* Opening the box is a request to see the options. */
|
|
|
|
|
|
$(inputId).addEventListener('focus', () => render($(inputId).value));
|
|
|
|
|
|
$(inputId).addEventListener('blur', () => {
|
|
|
|
|
|
/* Let a click on a result land before the list disappears. */
|
|
|
|
|
|
setTimeout(() => { if (document.activeElement !== $(inputId)) $(resultsId).textContent = ''; }, 180);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Live numbers, refreshed from the same status the operator page uses. */
|
|
|
|
|
|
function renderOperatorLive() {
|
|
|
|
|
|
|
|
|
|
|
|
const live = state.live;
|
|
|
|
|
|
const go = $('heroGo');
|
|
|
|
|
|
go.dataset.live = live ? 'true' : 'false';
|
|
|
|
|
|
go.textContent = live ? 'STOP TRANSLATING' : 'START TRANSLATING';
|
|
|
|
|
|
go.disabled = !state.modelsReady || !state.ws || state.ws.readyState !== 1;
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* A hold is silent and invisible from the back of the room — it looks exactly
|
|
|
|
|
|
* like the whole thing being broken. So it gets the loudest thing on the page,
|
|
|
|
|
|
* and its own button, because a cough button you can only reach from a Stream
|
|
|
|
|
|
* Deck is no use to whoever is actually standing at the Mac.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const hold = !!state.hold;
|
|
|
|
|
|
const holdBtn = $('heroHold');
|
|
|
|
|
|
holdBtn.hidden = !live;
|
|
|
|
|
|
holdBtn.textContent = hold ? 'RESUME SENDING' : 'HOLD';
|
|
|
|
|
|
holdBtn.dataset.held = hold ? 'true' : 'false';
|
|
|
|
|
|
$('holdBar').hidden = !(live && hold);
|
|
|
|
|
|
|
|
|
|
|
|
const h = state.health;
|
|
|
|
|
|
const hEl = $('opHealth');
|
|
|
|
|
|
if (hEl) {
|
|
|
|
|
|
hEl.hidden = !h;
|
|
|
|
|
|
if (h) {
|
|
|
|
|
|
hEl.dataset.level = h.level;
|
|
|
|
|
|
hEl.textContent = h.detail;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$('stListeners').textContent = state.listeners || 0;
|
|
|
|
|
|
$('stLangs').textContent = (state.targets || []).length;
|
|
|
|
|
|
$('stElapsed').textContent = state.startedAt
|
|
|
|
|
|
? Math.floor((Date.now() - state.startedAt) / 60000) + ':' +
|
|
|
|
|
|
String(Math.floor(((Date.now() - state.startedAt) % 60000) / 1000)).padStart(2, '0')
|
|
|
|
|
|
: '—';
|
|
|
|
|
|
|
|
|
|
|
|
const who = $('opWho');
|
|
|
|
|
|
who.textContent = '';
|
|
|
|
|
|
(state.listenerDetail || []).forEach((d) => {
|
|
|
|
|
|
const l = window.Languages && window.Languages.get(d.lang);
|
|
|
|
|
|
const row = document.createElement('div');
|
|
|
|
|
|
row.className = 'wholine';
|
|
|
|
|
|
const dev = document.createElement('b');
|
|
|
|
|
|
dev.textContent = d.device || 'Unknown device';
|
|
|
|
|
|
const meta = document.createElement('span');
|
|
|
|
|
|
meta.textContent = (d.remote ? 'remote' : d.ip) + ' · ' + (l ? l.native : d.lang) +
|
|
|
|
|
|
(d.speaking ? ' · speaking' : '') + (d.minutes ? ' · ' + d.minutes + 'm' : '');
|
|
|
|
|
|
row.appendChild(dev);
|
|
|
|
|
|
row.appendChild(meta);
|
|
|
|
|
|
who.appendChild(row);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── the Voices card: audition every voice, choose per language ────────── */
|
|
|
|
|
|
|
|
|
|
|
|
let auditionAudio = null; // one <audio> element, reused
|
|
|
|
|
|
let auditionBusy = false;
|
|
|
|
|
|
|
|
|
|
|
|
function voiceChoices() {
|
|
|
|
|
|
return (state.cfg && state.cfg.voiceChoices) || {};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Which alternates listeners may pick, per language. */
|
|
|
|
|
|
function listenerVoices() {
|
|
|
|
|
|
return (state.cfg && state.cfg.listenerVoices) || {};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isOfferedVoice(lang, v) {
|
|
|
|
|
|
const key = voiceKeyOf(v);
|
|
|
|
|
|
return !!key && (listenerVoices()[lang] || []).indexOf(key) !== -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Offer, or stop offering, one voice to listeners of one language.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Saved through the app (never by writing config underneath it) and pushed to
|
|
|
|
|
|
* the server immediately, so a listener with the page already open sees the
|
|
|
|
|
|
* chooser appear or disappear without reloading.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function toggleListenerVoice(lang, v) {
|
|
|
|
|
|
const key = voiceKeyOf(v);
|
|
|
|
|
|
if (!key) return;
|
|
|
|
|
|
const map = {};
|
|
|
|
|
|
for (const k of Object.keys(listenerVoices())) map[k] = (listenerVoices()[k] || []).slice();
|
|
|
|
|
|
const list = map[lang] || (map[lang] = []);
|
|
|
|
|
|
const at = list.indexOf(key);
|
|
|
|
|
|
if (at === -1) list.push(key); else list.splice(at, 1);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const saved = await window.engineHost.ttsSetListenerVoices(map);
|
|
|
|
|
|
state.cfg = Object.assign({}, state.cfg, { listenerVoices: saved });
|
|
|
|
|
|
sendVoiceOptions();
|
|
|
|
|
|
renderVoiceList();
|
|
|
|
|
|
log((at === -1 ? 'listeners can now pick ' : 'listeners can no longer pick ')
|
|
|
|
|
|
+ v.label + ' for ' + lang, 'ok');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log('could not change the listener voices: ' + err.message, 'err');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* The catalog entry that will actually speak this language right now: the
|
|
|
|
|
|
* operator's pick if they made one, otherwise the shipped default. Every
|
|
|
|
|
|
* label on this card reads from here, so the dropdown, the sidecar and the
|
|
|
|
|
|
* row markers can never disagree about which voice is live.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function currentVoiceFor(lang) {
|
|
|
|
|
|
const list = (window.VoiceCatalog && window.VoiceCatalog.forLanguage(lang)) || [];
|
|
|
|
|
|
const c = voiceChoices()[lang];
|
|
|
|
|
|
if (c) {
|
|
|
|
|
|
const hit = list.find((v) => v.engine === c.engine && voiceKeyOf(v) === voiceKeyOf(c));
|
|
|
|
|
|
/* A saved pick can outlive its voice — the gender census dropped 151
|
|
|
|
|
|
entries from the catalog, so a config written before it may name one
|
|
|
|
|
|
that is gone. Report the name with a flag rather than a blank. */
|
|
|
|
|
|
return hit || { engine: c.engine, label: c.label || c.speaker || c.bundle, gone: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
const shipped = (state.spokenDefaults || {})[lang];
|
|
|
|
|
|
return (shipped && list.find((v) => v.engine === 'builtin' && v.bundle === shipped)) || list[0] || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Just the speaker's name for the dropdown — "sharvard", not
|
|
|
|
|
|
"sharvard · medium (es_ES)". The detail belongs in the list below. */
|
|
|
|
|
|
function voiceShortName(v) {
|
|
|
|
|
|
return v ? String(v.label || v.speaker || v.bundle || '').split(' · ')[0].trim() : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Which field identifies a voice depends on its engine.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Piper voices are named by `bundle`; the sidecar engines — Qwen3 and Kokoro —
|
|
|
|
|
|
* are named by `speaker` and have no bundle at all. Testing `bundle` on one of
|
|
|
|
|
|
* those compares undefined with undefined, which is true for EVERY voice of
|
|
|
|
|
|
* that engine — so all three Kokoro rows for Spanish showed "in use" at once.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function voiceKeyOf(v) {
|
|
|
|
|
|
if (!v) return '';
|
|
|
|
|
|
return (v.engine === 'local' || v.engine === 'kokoro')
|
|
|
|
|
|
? String(v.speaker || '')
|
|
|
|
|
|
: String(v.bundle || '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Is this catalog entry the one currently in use for the language? */
|
|
|
|
|
|
function isCurrentVoice(lang, v) {
|
|
|
|
|
|
const cur = currentVoiceFor(lang);
|
|
|
|
|
|
if (!cur || cur.gone || cur.engine !== v.engine) return false;
|
|
|
|
|
|
const key = voiceKeyOf(v);
|
|
|
|
|
|
return !!key && voiceKeyOf(cur) === key;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderVoiceLangs() {
|
|
|
|
|
|
const sel = $('voiceLang');
|
|
|
|
|
|
if (!sel || !window.VoiceCatalog) return;
|
|
|
|
|
|
const langs = window.VoiceCatalog.languages();
|
|
|
|
|
|
/* Languages anyone is actually using first — that is what an operator is
|
|
|
|
|
|
tuning mid-service — then the rest alphabetically by name. */
|
|
|
|
|
|
const active = new Set(state.targets || []);
|
|
|
|
|
|
const name = (l) => ((window.Languages && window.Languages.get(l)) || {}).name || l;
|
|
|
|
|
|
langs.sort((a, b) => name(a).localeCompare(name(b)));
|
|
|
|
|
|
const keep = sel.value;
|
|
|
|
|
|
sel.textContent = '';
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Each option names the voice that will actually speak, so the dropdown is
|
|
|
|
|
|
* a summary of the assignments: "Spanish — sharvard".
|
|
|
|
|
|
*
|
|
|
|
|
|
* It used to read "Spanish — in use", where "in use" meant the LANGUAGE was
|
|
|
|
|
|
* being translated — while the row buttons below use the same two words to
|
|
|
|
|
|
* mean the VOICE is the live one. One phrase, two subjects, one card. The
|
|
|
|
|
|
* language state moves to a group heading, which can't be misread as a
|
|
|
|
|
|
* property of the voice.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const groups = active.size
|
|
|
|
|
|
? [{ head: 'Translating now', of: langs.filter((l) => active.has(l)) },
|
|
|
|
|
|
{ head: 'Other languages', of: langs.filter((l) => !active.has(l)) }]
|
|
|
|
|
|
: [{ head: 'All languages', of: langs }];
|
|
|
|
|
|
for (const g of groups) {
|
|
|
|
|
|
if (!g.of.length) continue;
|
|
|
|
|
|
const grp = document.createElement('optgroup');
|
|
|
|
|
|
grp.label = g.head;
|
|
|
|
|
|
for (const l of g.of) {
|
|
|
|
|
|
const o = document.createElement('option');
|
|
|
|
|
|
o.value = l;
|
|
|
|
|
|
const who = voiceShortName(currentVoiceFor(l));
|
|
|
|
|
|
o.textContent = name(l) + (who ? ' — ' + who : '');
|
|
|
|
|
|
grp.appendChild(o);
|
|
|
|
|
|
}
|
|
|
|
|
|
sel.appendChild(grp);
|
|
|
|
|
|
}
|
|
|
|
|
|
sel.value = (keep && langs.indexOf(keep) !== -1) ? keep
|
|
|
|
|
|
: (langs.indexOf('spa_Latn') !== -1 ? 'spa_Latn' : langs[0]);
|
|
|
|
|
|
|
|
|
|
|
|
/* This list is shorter than the language list and that looks like a bug
|
|
|
|
|
|
until it is explained. Free offline TTS simply covers fewer languages
|
|
|
|
|
|
than NLLB translates; the rest are captioned, not dropped. */
|
|
|
|
|
|
const total = (window.Languages && window.Languages.codes().length) || 0;
|
|
|
|
|
|
const cov = $('voiceCoverage');
|
|
|
|
|
|
if (cov) {
|
|
|
|
|
|
cov.textContent = langs.length + ' of the ' + total + ' languages this app translates have a ' +
|
|
|
|
|
|
'built-in voice. The other ' + (total - langs.length) + ' show captions, and a listener\u2019s ' +
|
|
|
|
|
|
'own phone may still read them aloud.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderVoiceList() {
|
|
|
|
|
|
const box = $('voiceList');
|
|
|
|
|
|
const lang = $('voiceLang').value;
|
|
|
|
|
|
if (!box || !lang || !window.VoiceCatalog) return;
|
|
|
|
|
|
box.textContent = '';
|
|
|
|
|
|
|
|
|
|
|
|
const list = window.VoiceCatalog.forLanguage(lang);
|
|
|
|
|
|
const chosen = voiceChoices()[lang];
|
|
|
|
|
|
const cur = currentVoiceFor(lang);
|
|
|
|
|
|
/* "your pick" vs "default" is the one thing the dropdown can't show, and it
|
|
|
|
|
|
is what tells the operator whether they have tuned this language yet. */
|
|
|
|
|
|
$('voiceNow').textContent = !cur ? 'no voices for this language'
|
|
|
|
|
|
: cur.gone ? 'your pick · ' + cur.label + ' — no longer available'
|
|
|
|
|
|
: (chosen ? 'your pick · ' : 'default · ') + cur.label;
|
|
|
|
|
|
|
|
|
|
|
|
for (const v of list) {
|
|
|
|
|
|
const row = document.createElement('div');
|
|
|
|
|
|
row.className = 'vrow' + (isCurrentVoice(lang, v) ? ' is-current' : '');
|
|
|
|
|
|
|
|
|
|
|
|
const nm = document.createElement('span');
|
|
|
|
|
|
nm.className = 'vname';
|
|
|
|
|
|
nm.textContent = v.label;
|
|
|
|
|
|
row.appendChild(nm);
|
|
|
|
|
|
|
|
|
|
|
|
const tag = document.createElement('span');
|
|
|
|
|
|
tag.className = 'vtag';
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Say which rows can take a delivery direction, and say it ON THE ROW.
|
|
|
|
|
|
* The instruction box carried the caveat and the list did not, so
|
|
|
|
|
|
* auditioning any of the five Piper voices Spanish lists ABOVE the first
|
|
|
|
|
|
* local one produced a plain reading and no explanation — which reads as
|
|
|
|
|
|
* "the feature does nothing".
|
|
|
|
|
|
*/
|
|
|
|
|
|
const wantsDirection = !!($('voiceInstruct') && $('voiceInstruct').value.trim());
|
|
|
|
|
|
if (v.engine === 'local') {
|
|
|
|
|
|
tag.textContent = 'expressive · slow' + (wantsDirection ? ' · takes direction' : '');
|
|
|
|
|
|
} else if (v.engine === 'kokoro') {
|
|
|
|
|
|
/* MEASURED: RTF 0.05, and 0.12 with three at once — the only natural
|
|
|
|
|
|
voice here fast enough to cover every language during a service. */
|
|
|
|
|
|
tag.textContent = 'natural · fast' + (wantsDirection ? ' · ignores direction' : '');
|
|
|
|
|
|
if (wantsDirection) tag.className = 'vtag warn';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
tag.textContent = 'built-in' + (wantsDirection ? ' · ignores direction' : '');
|
|
|
|
|
|
if (wantsDirection) tag.className = 'vtag warn';
|
|
|
|
|
|
}
|
|
|
|
|
|
row.appendChild(tag);
|
|
|
|
|
|
|
|
|
|
|
|
const msg = document.createElement('span');
|
|
|
|
|
|
msg.className = 'vmsg';
|
|
|
|
|
|
row.appendChild(msg);
|
|
|
|
|
|
|
|
|
|
|
|
const play = document.createElement('button');
|
|
|
|
|
|
play.className = 'btn';
|
|
|
|
|
|
play.textContent = '▶';
|
|
|
|
|
|
play.title = 'Hear this voice';
|
|
|
|
|
|
play.addEventListener('click', () => auditionVoice(lang, v, play, msg));
|
|
|
|
|
|
row.appendChild(play);
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* "Offer" — may a listener pick this voice for themselves?
|
|
|
|
|
|
*
|
|
|
|
|
|
* Not shown on the row that is already in use: that voice is what everyone
|
|
|
|
|
|
* hears by default, so offering it as an alternative would be a checkbox
|
|
|
|
|
|
* that changes nothing.
|
|
|
|
|
|
*/
|
|
|
|
|
|
if (!isCurrentVoice(lang, v)) {
|
|
|
|
|
|
const offer = document.createElement('label');
|
|
|
|
|
|
offer.className = 'voffer';
|
|
|
|
|
|
offer.title = 'Let listeners choose this voice on their phone';
|
|
|
|
|
|
const cb = document.createElement('input');
|
|
|
|
|
|
cb.type = 'checkbox';
|
|
|
|
|
|
cb.checked = isOfferedVoice(lang, v);
|
|
|
|
|
|
cb.addEventListener('change', () => toggleListenerVoice(lang, v));
|
|
|
|
|
|
offer.appendChild(cb);
|
|
|
|
|
|
offer.appendChild(document.createTextNode('Offer'));
|
|
|
|
|
|
row.appendChild(offer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const use = document.createElement('button');
|
|
|
|
|
|
use.className = 'btn';
|
|
|
|
|
|
use.textContent = isCurrentVoice(lang, v) ? '\u2713 in use' : 'Use';
|
|
|
|
|
|
use.disabled = isCurrentVoice(lang, v);
|
|
|
|
|
|
use.addEventListener('click', () => chooseVoice(lang, v));
|
|
|
|
|
|
row.appendChild(use);
|
|
|
|
|
|
|
|
|
|
|
|
box.appendChild(row);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function auditionVoice(lang, v, btn, msg) {
|
|
|
|
|
|
if (auditionBusy) return;
|
|
|
|
|
|
auditionBusy = true;
|
|
|
|
|
|
const was = btn.textContent;
|
|
|
|
|
|
btn.textContent = '…';
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
msg.className = 'vmsg';
|
|
|
|
|
|
msg.textContent = (v.engine === 'builtin' || v.engine === 'kokoro')
|
|
|
|
|
|
? 'preparing (may download)\u2026' : '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
/* Delivery direction, if the operator typed one. Local voices only — the
|
|
|
|
|
|
Piper path has nowhere to put it. */
|
|
|
|
|
|
const typed = ($('voiceInstruct').value || '').trim();
|
|
|
|
|
|
/* Only the expressive model takes one. Kokoro and Piper have no such
|
|
|
|
|
|
control and would silently ignore it. */
|
|
|
|
|
|
const instruct = (v.engine === 'local') ? typed : '';
|
|
|
|
|
|
if (typed && v.engine !== 'local') {
|
|
|
|
|
|
/* Told, not ignored. */
|
|
|
|
|
|
msg.className = 'vmsg bad';
|
|
|
|
|
|
msg.textContent = 'this voice cannot take direction — pick a Local one';
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 1200));
|
|
|
|
|
|
msg.className = 'vmsg';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (instruct) msg.textContent = 'with your direction…';
|
|
|
|
|
|
const out = await window.engineHost.ttsAudition({ lang, voice: v, instruct });
|
|
|
|
|
|
if (!auditionAudio) auditionAudio = new Audio();
|
|
|
|
|
|
if (!out.wav) throw new Error('that voice produced no audio');
|
|
|
|
|
|
auditionAudio.src = 'data:audio/wav;base64,' + out.wav;
|
|
|
|
|
|
/* play() rejects on autoplay policy or a decode failure; either way the
|
|
|
|
|
|
operator needs the reason, not a silent button. */
|
|
|
|
|
|
await auditionAudio.play();
|
|
|
|
|
|
if (out.warning) {
|
|
|
|
|
|
msg.className = 'vmsg';
|
|
|
|
|
|
msg.textContent = out.warning;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
msg.className = 'vmsg';
|
|
|
|
|
|
msg.textContent = (out.seconds || 0).toFixed(1) + 's';
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
msg.className = 'vmsg bad';
|
|
|
|
|
|
msg.textContent = String(err.message || err).replace(/^Error invoking remote method [^:]+: Error: /, '');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
btn.textContent = was;
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
auditionBusy = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function chooseVoice(lang, v) {
|
|
|
|
|
|
const choices = Object.assign({}, voiceChoices());
|
|
|
|
|
|
choices[lang] = (v.engine === 'local' || v.engine === 'kokoro')
|
|
|
|
|
|
? { engine: v.engine, speaker: v.speaker, label: v.label }
|
|
|
|
|
|
: { engine: 'builtin', bundle: v.bundle, model: v.model, label: v.label };
|
|
|
|
|
|
try {
|
|
|
|
|
|
const saved = await window.engineHost.ttsSetVoices(choices);
|
|
|
|
|
|
state.cfg = Object.assign({}, state.cfg, { voiceChoices: saved });
|
|
|
|
|
|
/*
|
|
|
|
|
|
* A voice promoted to DEFAULT stops being an alternate. Leaving it in both
|
|
|
|
|
|
* lists would show listeners a chooser whose two entries are the same
|
|
|
|
|
|
* voice, and would make the engine synthesise it twice for anyone who
|
|
|
|
|
|
* picked it.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const key = voiceKeyOf(v);
|
|
|
|
|
|
const offered = (listenerVoices()[lang] || []);
|
|
|
|
|
|
if (key && offered.indexOf(key) !== -1) {
|
|
|
|
|
|
const map = {};
|
|
|
|
|
|
for (const k of Object.keys(listenerVoices())) map[k] = (listenerVoices()[k] || []).slice();
|
|
|
|
|
|
map[lang] = offered.filter((k) => k !== key);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const kept = await window.engineHost.ttsSetListenerVoices(map);
|
|
|
|
|
|
state.cfg = Object.assign({}, state.cfg, { listenerVoices: kept });
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
sendVoiceOptions();
|
|
|
|
|
|
renderVoiceLangs(); // the option text names the voice — keep it honest
|
|
|
|
|
|
renderVoiceList();
|
|
|
|
|
|
log('voice for ' + lang + ' set to ' + v.label, 'ok');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log('could not set the voice: ' + err.message, 'err');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$('voiceLang').addEventListener('change', renderVoiceList);
|
|
|
|
|
|
|
|
|
|
|
|
$('opAllowAll').addEventListener('change', async (e) => {
|
|
|
|
|
|
const want = e.target.checked;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await opSave({ allowAllLanguages: want },
|
|
|
|
|
|
want ? 'listeners can pick any language' : 'listeners are limited to the front-page languages');
|
|
|
|
|
|
renderOpLanguages();
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
e.target.checked = !want; // the save failed; do not show a state the server does not have
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/* ── premium voices ───────────────────────────────────────────────────── */
|
|
|
|
|
|
function renderCloudCard() {
|
|
|
|
|
|
const c = (state.cfg && state.cfg.cloudTts) || { provider: 'off' };
|
|
|
|
|
|
$('cloudProvider').value = c.provider || 'off';
|
|
|
|
|
|
$('cloudFields').hidden = (c.provider || 'off') === 'off';
|
|
|
|
|
|
$('cloudKey').value = c.provider === 'google' ? (c.googleKey || '') : (c.fishKey || '');
|
|
|
|
|
|
$('cloudVoice').value = c.fishVoice || '';
|
|
|
|
|
|
$('cloudPerLang').value = Object.entries(c.perLang || {})
|
|
|
|
|
|
.map(([k, v]) => k + ' = ' + v).join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$('cloudProvider').addEventListener('change', () => {
|
|
|
|
|
|
$('cloudFields').hidden = $('cloudProvider').value === 'off';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$('cloudSave').addEventListener('click', async () => {
|
|
|
|
|
|
const provider = $('cloudProvider').value;
|
|
|
|
|
|
const key = $('cloudKey').value.trim();
|
|
|
|
|
|
const perLang = {};
|
|
|
|
|
|
for (const line of $('cloudPerLang').value.split('\n')) {
|
|
|
|
|
|
const m = line.split('=');
|
|
|
|
|
|
if (m.length === 2 && m[0].trim() && m[1].trim()) perLang[m[0].trim()] = m[1].trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
const prev = state.cfg.cloudTts || {};
|
|
|
|
|
|
const cloudTts = Object.assign({}, prev, {
|
|
|
|
|
|
provider,
|
|
|
|
|
|
fishKey: provider === 'fish' ? key : (prev.fishKey || ''),
|
|
|
|
|
|
fishModel: 's2.1-pro',
|
|
|
|
|
|
fishVoice: provider === 'fish' ? $('cloudVoice').value.trim() : (prev.fishVoice || ''),
|
|
|
|
|
|
googleKey: provider === 'google' ? key : (prev.googleKey || ''),
|
|
|
|
|
|
localVoice: provider === 'local' ? ($('cloudVoice').value.trim() || 'aiden') : (prev.localVoice || 'aiden'),
|
|
|
|
|
|
perLang
|
|
|
|
|
|
});
|
|
|
|
|
|
$('cloudHint').textContent = 'Saving…';
|
|
|
|
|
|
try {
|
|
|
|
|
|
state.cfg = await window.engineHost.setConfig({ cloudTts });
|
|
|
|
|
|
$('cloudHint').textContent = provider === 'off' ? 'Off — built-in voices' : 'Saved';
|
|
|
|
|
|
} catch (err) { $('cloudHint').textContent = 'Not saved: ' + err.message; }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$('cloudTest').addEventListener('click', async () => {
|
|
|
|
|
|
/* One real sentence through the real path, reporting which engine spoke. */
|
|
|
|
|
|
$('cloudHint').textContent = 'Testing…';
|
|
|
|
|
|
const lang = (state.targets && state.targets[0]) || 'spa_Latn';
|
|
|
|
|
|
const t0 = Date.now();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const clip = await window.engineHost.ttsSpeak({ lang, text: 'The Lord bless you and keep you.' });
|
|
|
|
|
|
$('cloudHint').textContent = (clip.source === 'piper' ? 'Built-in voice (premium unavailable)'
|
|
|
|
|
|
: clip.source === 'local' ? 'Local voice (this Mac)' : 'Premium voice') +
|
|
|
|
|
|
' · ' + (Date.now() - t0) + 'ms · ' + lang;
|
|
|
|
|
|
} catch (err) { $('cloudHint').textContent = 'Test failed: ' + err.message; }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (window.engineHost.onLocalVoices) {
|
|
|
|
|
|
window.engineHost.onLocalVoices((s) => {
|
|
|
|
|
|
if ((($('cloudProvider') || {}).value) !== 'local') return;
|
|
|
|
|
|
$('cloudState').textContent =
|
|
|
|
|
|
s.ready ? 'Local voices running' :
|
|
|
|
|
|
s.running ? 'Starting local voices…' :
|
|
|
|
|
|
s.error || 'Off';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (window.engineHost.onTtsCloud) {
|
|
|
|
|
|
window.engineHost.onTtsCloud((s) => {
|
|
|
|
|
|
const prev = state.cloudStats || {};
|
|
|
|
|
|
state.cloudStats = s;
|
|
|
|
|
|
$('cloudState').textContent = s.parked
|
|
|
|
|
|
? 'DOWN — using built-in voices'
|
|
|
|
|
|
: (s.ok + ' spoken · ' + s.fallback + ' fell back' + (s.sidecarReady === false ? ' · voices starting' : ''));
|
|
|
|
|
|
/*
|
|
|
|
|
|
* On the hero, where someone standing at the Mac actually looks. The
|
|
|
|
|
|
* morning a service ran five minutes in the wrong voice, the only sign
|
|
|
|
|
|
* was a hint under a dropdown in a collapsed card.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const warn = $('voiceWarn');
|
|
|
|
|
|
if (warn) {
|
|
|
|
|
|
const recentFallback = s.fallbackAt && Date.now() - s.fallbackAt < 60000;
|
|
|
|
|
|
if (s.parked) {
|
|
|
|
|
|
warn.textContent = 'Natural voices are DOWN — the built-in voices are speaking' +
|
|
|
|
|
|
(s.parkedReason ? ' (' + s.parkedReason + ')' : '') + '.';
|
|
|
|
|
|
warn.hidden = false;
|
|
|
|
|
|
} else if (recentFallback) {
|
|
|
|
|
|
warn.textContent = 'A natural voice just fell back to the built-in voice for a sentence (' +
|
|
|
|
|
|
s.fallback + ' so far this session).';
|
|
|
|
|
|
warn.hidden = false;
|
|
|
|
|
|
} else if (s.sidecarReady === false && state.live) {
|
|
|
|
|
|
warn.textContent = 'Natural voices are still starting — built-in voices speak until they are ready.';
|
|
|
|
|
|
warn.hidden = false;
|
|
|
|
|
|
} else warn.hidden = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((s.fallback || 0) > (prev.fallback || 0)) log('a natural voice fell back to built-in (' + s.fallback + ' total)' + (s.lastError ? ': ' + s.lastError : ''), 'err');
|
|
|
|
|
|
if (s.parked && !prev.parked) log('natural voices PARKED — built-in voices speaking' + (s.parkedReason ? ' (' + s.parkedReason + ')' : ''), 'err');
|
|
|
|
|
|
if (!s.parked && prev.parked) log('natural voices are back', 'ok');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* The internet-sharing panel is gone from this page — one published address is
|
|
|
|
|
|
the plan, and the quick tunnel handed out a NEW url every launch, which is
|
|
|
|
|
|
the opposite of something you can print. The machinery is untouched in
|
|
|
|
|
|
main.js (tunnelStart/tunnelStop/onTunnel) for when a stream-viewer address
|
|
|
|
|
|
with its own delay is wanted. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
$('heroHold').addEventListener('click', () => {
|
|
|
|
|
|
if (!opConfig || !opConfig.controlToken) { log('operator settings have not loaded', 'err'); return; }
|
|
|
|
|
|
/* Through the server, like the start button, so this button, the Stream Deck
|
|
|
|
|
|
and the operator page can never disagree about whether we are held. */
|
|
|
|
|
|
fetch(opBase() + '/api/control/' + (state.hold ? 'resume' : 'hold') +
|
|
|
|
|
|
'?key=' + encodeURIComponent(opConfig.controlToken))
|
|
|
|
|
|
.catch((e) => log('could not change hold: ' + e.message, 'err'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$('heroGo').addEventListener('click', () => {
|
|
|
|
|
|
if (!state.ws || state.ws.readyState !== 1) { log('not connected to the server yet', 'err'); return; }
|
|
|
|
|
|
if (!opConfig || !opConfig.controlToken) { log('operator settings have not loaded — cannot start', 'err'); return; }
|
|
|
|
|
|
/* The engine cannot start itself — the server owns that state, and going
|
|
|
|
|
|
through it keeps the Stream Deck, the operator page and this button all
|
|
|
|
|
|
telling the same story. */
|
|
|
|
|
|
fetch(opBase() + '/api/control/' + (state.live ? 'stop' : 'start') +
|
|
|
|
|
|
'?key=' + encodeURIComponent((opConfig && opConfig.controlToken) || ''))
|
|
|
|
|
|
.catch((e) => log('could not toggle: ' + e.message, 'err'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$('opSaveFixes').addEventListener('click', () => {
|
|
|
|
|
|
/* Refuse to save from a pane that never loaded. Writing an empty textarea back
|
|
|
|
|
|
would silently delete every correction the church has built up. */
|
|
|
|
|
|
if (!opConfig) { $('opFixHint').textContent = 'Not loaded — nothing saved'; return; }
|
|
|
|
|
|
|
|
|
|
|
|
const lines = $('opFixes').value.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
|
|
|
|
const good = lines.filter((l) => l.indexOf('=>') !== -1);
|
|
|
|
|
|
const bad = lines.length - good.length;
|
|
|
|
|
|
const glossary = Object.assign({}, opConfig.glossary, { fixes: good });
|
|
|
|
|
|
|
|
|
|
|
|
$('opFixHint').textContent = 'Saving…';
|
|
|
|
|
|
opSave({ glossary }, good.length + ' corrections saved')
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
delete $('opFixes').dataset.dirty;
|
|
|
|
|
|
$('opFixHint').textContent = bad ? 'Saved · ' + bad + ' line(s) need "=>"' : 'Saved';
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => { $('opFixHint').textContent = 'Not saved: ' + err.message; });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* The PIN guards the browser operator page, which is how someone runs the desk
|
|
|
|
|
|
* from a tablet. This tab is inside the app and never asks for it, so it lives
|
|
|
|
|
|
* here rather than on the setup page — a credential on the screen the room can
|
|
|
|
|
|
* see is the one place it should not be.
|
|
|
|
|
|
*/
|
|
|
|
|
|
$('opFixes').addEventListener('input', () => { $('opFixes').dataset.dirty = '1'; });
|
|
|
|
|
|
|
|
|
|
|
|
$('opSaveDelay').addEventListener('click', () => {
|
|
|
|
|
|
const n = Number($('opDelay').value);
|
|
|
|
|
|
if (!Number.isFinite(n) || n < 0 || n > 120) { $('opDelayHint').textContent = '0 to 120 seconds'; return; }
|
|
|
|
|
|
$('opDelayHint').textContent = 'Saving…';
|
|
|
|
|
|
opSave({ streamDelaySec: Math.round(n) })
|
|
|
|
|
|
.then(() => { $('opDelayHint').textContent = n > 0 ? 'Holding ' + Math.round(n) + 's' : 'Off'; })
|
|
|
|
|
|
.catch((err) => { $('opDelayHint').textContent = 'Not saved: ' + err.message; });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$('opSavePin').addEventListener('click', () => {
|
|
|
|
|
|
const pin = $('opPin').value.trim();
|
|
|
|
|
|
if (pin.length < 4) { $('opPinHint').textContent = 'Use at least 4 characters.'; return; }
|
|
|
|
|
|
$('opPinHint').textContent = 'Saving…';
|
|
|
|
|
|
opSave({ operatorPin: pin })
|
|
|
|
|
|
.then(() => { $('opPinHint').textContent = 'Changed'; setTimeout(() => { $('opPinHint').textContent = ''; }, 2000); })
|
|
|
|
|
|
.catch((err) => { $('opPinHint').textContent = 'Not changed: ' + err.message; });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('click', (e) => {
|
|
|
|
|
|
const btn = e.target.closest('[data-copy]');
|
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
|
const text = $(btn.dataset.copy).textContent;
|
|
|
|
|
|
if (!text || text === '—' || text === 'unavailable') { log('nothing to copy yet', 'err'); return; }
|
|
|
|
|
|
window.engineHost.copyText(text)
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
/* Confirm on the button itself — the log is halfway down another pane. */
|
|
|
|
|
|
const was = btn.textContent;
|
|
|
|
|
|
btn.textContent = 'Copied';
|
|
|
|
|
|
setTimeout(() => { btn.textContent = was; }, 1200);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => log('could not copy: ' + err.message, 'err'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Toggling is optimistic and the save is debounced. Ticking twenty languages
|
|
|
|
|
|
* in a row must not be twenty writes to a config file that carries the
|
|
|
|
|
|
* operator's months of work and gets snapshotted on every write.
|
|
|
|
|
|
*/
|
|
|
|
|
|
let lastConfigSig = '';
|
|
|
|
|
|
let featuredSaveTimer = null;
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Fly a copy of the row from where it was to where it lands.
|
|
|
|
|
|
*
|
|
|
|
|
|
* A CLONE ON <body>, not the row itself, and that is the whole design. The
|
|
|
|
|
|
* columns are `overflow: auto`, so a real row animating between them is
|
|
|
|
|
|
* clipped by its own column across exactly the part of the flight that
|
|
|
|
|
|
* crosses the gap — z-index cannot escape an ancestor's clip. And the server
|
|
|
|
|
|
* pushes a fresh operator snapshot every 3 seconds (consoleTick in
|
|
|
|
|
|
* server.js), which rebuilds all 105 rows; a real-node animation would be
|
|
|
|
|
|
* destroyed mid-air roughly one toggle in ten. A fixed-position clone is
|
|
|
|
|
|
* subject to neither.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The destination is scrolled into view FIRST. Measured on the prototype:
|
|
|
|
|
|
* turning a language off put its alphabetical home somewhere in 3000px of
|
|
|
|
|
|
* off-screen list in 105 of 105 cases, so the flight had nowhere visible to
|
|
|
|
|
|
* land and the row simply vanished.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function flyRow(code, fromRect) {
|
|
|
|
|
|
if (!fromRect) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
|
|
|
|
|
const dest = document.querySelector('.langcols .langrow[data-code="' + code + '"]');
|
|
|
|
|
|
if (!dest) return;
|
|
|
|
|
|
const box = dest.closest('.langlist');
|
|
|
|
|
|
if (box) {
|
|
|
|
|
|
/* Scroll the column itself, never scrollIntoView — that would also move
|
|
|
|
|
|
the whole settings page out from under the operator. */
|
|
|
|
|
|
const top = dest.offsetTop, h = dest.offsetHeight;
|
|
|
|
|
|
if (top < box.scrollTop) box.scrollTop = top;
|
|
|
|
|
|
else if (top + h > box.scrollTop + box.clientHeight) box.scrollTop = top + h - box.clientHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const to = dest.getBoundingClientRect();
|
|
|
|
|
|
if (!to.width || !to.height) return;
|
|
|
|
|
|
|
|
|
|
|
|
const fly = dest.cloneNode(true);
|
|
|
|
|
|
fly.className = 'langfly langrow';
|
|
|
|
|
|
fly.style.left = fromRect.left + 'px';
|
|
|
|
|
|
fly.style.top = fromRect.top + 'px';
|
|
|
|
|
|
fly.style.width = fromRect.width + 'px';
|
|
|
|
|
|
fly.style.height = fromRect.height + 'px';
|
|
|
|
|
|
fly.style.transform = 'translate(0,0)';
|
|
|
|
|
|
fly.style.opacity = '1';
|
|
|
|
|
|
document.body.appendChild(fly);
|
|
|
|
|
|
dest.classList.add('is-landing');
|
|
|
|
|
|
|
|
|
|
|
|
const dx = to.left - fromRect.left, dy = to.top - fromRect.top;
|
|
|
|
|
|
const done = () => {
|
|
|
|
|
|
fly.remove();
|
|
|
|
|
|
dest.classList.remove('is-landing');
|
|
|
|
|
|
};
|
|
|
|
|
|
requestAnimationFrame(() => { requestAnimationFrame(() => {
|
|
|
|
|
|
fly.style.transition = 'transform 240ms cubic-bezier(.2,.7,.3,1), opacity 240ms ease-out';
|
|
|
|
|
|
fly.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
|
|
|
|
|
|
fly.style.opacity = '.92';
|
|
|
|
|
|
}); });
|
|
|
|
|
|
/* A timer, not transitionend: the row is recreated by the 3s rebuild and
|
|
|
|
|
|
the listener would be lost with it, stranding a clone on screen. */
|
|
|
|
|
|
setTimeout(done, 300);
|
|
|
|
|
|
}
|
|
|
|
|
|
function toggleFeatured(code, on, row) {
|
|
|
|
|
|
/* Measured BEFORE the state change, while the row is still where the
|
|
|
|
|
|
operator clicked it. */
|
|
|
|
|
|
const fromRect = row ? row.getBoundingClientRect() : null;
|
|
|
|
|
|
const list = (opConfig.featured || []).slice();
|
|
|
|
|
|
const at = list.indexOf(code);
|
|
|
|
|
|
if (on && at === -1) list.push(code);
|
|
|
|
|
|
else if (!on && at !== -1) list.splice(at, 1);
|
|
|
|
|
|
else return;
|
|
|
|
|
|
const before = opConfig.featured;
|
|
|
|
|
|
opConfig.featured = list; // show it immediately
|
|
|
|
|
|
renderOpLangList(); // the row changes column
|
|
|
|
|
|
renderOpLangCount();
|
|
|
|
|
|
flyRow(code, fromRect);
|
|
|
|
|
|
const landed = document.querySelector('.langcols .langrow[data-code="' + code + '"]');
|
|
|
|
|
|
if (landed) { landed.classList.add('touched'); setTimeout(() => landed.classList.remove('touched'), 500); }
|
|
|
|
|
|
clearTimeout(featuredSaveTimer);
|
|
|
|
|
|
featuredSaveTimer = setTimeout(() => {
|
|
|
|
|
|
opSave({ featured: opConfig.featured }).catch(() => {
|
|
|
|
|
|
/* Never leave a tick on screen the server did not accept. */
|
|
|
|
|
|
opConfig.featured = before;
|
|
|
|
|
|
renderOpLangList();
|
|
|
|
|
|
});
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderOpLangCount() {
|
|
|
|
|
|
const on = (opConfig.featured || []).length;
|
|
|
|
|
|
const total = (window.Languages && window.Languages.codes().length) || 0;
|
|
|
|
|
|
const el = $('opLangCount');
|
|
|
|
|
|
if (el) el.textContent = on + ' of ' + total + ' on. These are what a listener sees first' +
|
|
|
|
|
|
(opConfig.allowAllLanguages ? ' — the rest stay reachable by search.' : ' — and all they can choose.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderOpLangList() {
|
|
|
|
|
|
const onBox = $('opLangOn'), offBox = $('opLangOff');
|
|
|
|
|
|
if (!onBox || !offBox || !window.Languages) return;
|
|
|
|
|
|
const q = ($('opLangFilter').value || '').trim().toLowerCase();
|
|
|
|
|
|
const voiceOnly = $('opLangVoiceOnly').checked;
|
|
|
|
|
|
const on = new Set(opConfig.featured || []);
|
|
|
|
|
|
const spoken = new Set((window.VoiceCatalog && window.VoiceCatalog.languages()) || []);
|
|
|
|
|
|
|
|
|
|
|
|
/* Alphabetical, and it stays alphabetical while you click. Re-sorting a
|
|
|
|
|
|
104-row list under the cursor makes it impossible to work down. */
|
|
|
|
|
|
const codes = window.Languages.codes().slice().sort((a, b) => {
|
|
|
|
|
|
const la = window.Languages.get(a), lb = window.Languages.get(b);
|
|
|
|
|
|
return (la.name || a).localeCompare(lb.name || b);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onBox.textContent = '';
|
|
|
|
|
|
offBox.textContent = '';
|
|
|
|
|
|
let shown = 0, nOn = 0, nOff = 0;
|
|
|
|
|
|
for (const code of codes) {
|
|
|
|
|
|
const l = window.Languages.get(code) || { name: code, native: code };
|
|
|
|
|
|
const has = spoken.has(code);
|
|
|
|
|
|
if (voiceOnly && !has) continue;
|
|
|
|
|
|
if (q && (l.name + ' ' + l.native + ' ' + code).toLowerCase().indexOf(q) === -1) continue;
|
|
|
|
|
|
shown++;
|
|
|
|
|
|
|
|
|
|
|
|
const row = document.createElement('label');
|
|
|
|
|
|
row.className = 'langrow' + (has ? '' : ' novoice');
|
|
|
|
|
|
row.dataset.code = code;
|
|
|
|
|
|
/* The native name is hidden in two-column mode to stop the variant names
|
|
|
|
|
|
truncating; keep it reachable. */
|
|
|
|
|
|
row.title = l.native && l.native !== l.name ? l.name + ' \u2014 ' + l.native : l.name;
|
|
|
|
|
|
const cb = document.createElement('input');
|
|
|
|
|
|
cb.type = 'checkbox';
|
|
|
|
|
|
cb.checked = on.has(code);
|
|
|
|
|
|
/* The checkbox stays. It is the only thing here that is focusable, that
|
|
|
|
|
|
Space operates, and that a screen reader announces as a control — a bare
|
|
|
|
|
|
row with a hover background is none of those. */
|
|
|
|
|
|
cb.addEventListener('change', () => toggleFeatured(code, cb.checked, row));
|
|
|
|
|
|
row.appendChild(cb);
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* ENGLISH NAME LEADS, and it is also the sort key — so the list reads
|
|
|
|
|
|
* alphabetically. Leading with the native name made a correctly sorted list
|
|
|
|
|
|
* look shuffled: "Afrikaans, Shqip, አማርኛ, العربية, Հայերեն" is Afrikaans,
|
|
|
|
|
|
* Albanian, Amharic, Arabic, Armenian, but nobody can see that. The
|
|
|
|
|
|
* listener sees the native name; the operator is looking for "Korean".
|
|
|
|
|
|
*/
|
|
|
|
|
|
const nm = document.createElement('span');
|
|
|
|
|
|
nm.className = 'lname';
|
|
|
|
|
|
nm.textContent = l.name;
|
|
|
|
|
|
row.appendChild(nm);
|
|
|
|
|
|
|
|
|
|
|
|
const nat = document.createElement('span');
|
|
|
|
|
|
nat.className = 'lsub';
|
|
|
|
|
|
nat.textContent = l.native === l.name ? '' : l.native;
|
|
|
|
|
|
row.appendChild(nat);
|
|
|
|
|
|
|
|
|
|
|
|
const tag = document.createElement('span');
|
|
|
|
|
|
tag.className = 'ltag';
|
|
|
|
|
|
/* Which languages can be SPOKEN is the thing being tuned, so it belongs
|
|
|
|
|
|
on the row rather than in a separate list to cross-reference. */
|
|
|
|
|
|
tag.textContent = has ? 'voice' : 'captions only';
|
|
|
|
|
|
row.appendChild(tag);
|
|
|
|
|
|
|
|
|
|
|
|
if (on.has(code)) { nOn++; onBox.appendChild(row); }
|
|
|
|
|
|
else { nOff++; offBox.appendChild(row); }
|
|
|
|
|
|
}
|
|
|
|
|
|
$('opLangNOn').textContent = nOn;
|
|
|
|
|
|
$('opLangNOff').textContent = nOff;
|
|
|
|
|
|
/*
|
|
|
|
|
|
* An empty column means two different things and they need different words.
|
|
|
|
|
|
* Saying "nothing on yet" while the header reads "On 3" — which is what a
|
|
|
|
|
|
* filter with no matches produces — invites the operator to switch on
|
|
|
|
|
|
* languages that are already on.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const filtering = !!q || voiceOnly;
|
|
|
|
|
|
const note = (el, empty, filtered) => {
|
|
|
|
|
|
if (el.children.length) return;
|
|
|
|
|
|
const p = document.createElement('p');
|
|
|
|
|
|
p.className = 'hint';
|
|
|
|
|
|
p.style.padding = '.75rem .625rem';
|
|
|
|
|
|
p.textContent = filtering ? filtered : empty;
|
|
|
|
|
|
el.appendChild(p);
|
|
|
|
|
|
};
|
|
|
|
|
|
note(onBox, 'Nothing on yet \u2014 tick a language to add it.', 'None of the languages that are on match that.');
|
|
|
|
|
|
note(offBox, 'Every language is on.', 'No other language matches that.');
|
|
|
|
|
|
void shown;
|
|
|
|
|
|
renderOpLangCount();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$('opLangFilter').addEventListener('input', renderOpLangList);
|
|
|
|
|
|
$('opLangVoiceOnly').addEventListener('change', renderOpLangList);
|
|
|
|
|
|
$('opLangNone').addEventListener('click', () => {
|
|
|
|
|
|
if (!opConfig) return;
|
|
|
|
|
|
opConfig.featured = [];
|
|
|
|
|
|
renderOpLangList();
|
|
|
|
|
|
opSave({ featured: [] }, 'all languages turned off').catch(() => loadOperator());
|
|
|
|
|
|
});
|
|
|
|
|
|
opSearch('opPinSearch', 'opPinResults', (code, fromRect) => {
|
|
|
|
|
|
const pinned = (opConfig.pinned || []).slice();
|
|
|
|
|
|
if (pinned.indexOf(code) !== -1) return; // already pinned: nothing to do
|
|
|
|
|
|
pinned.push(code);
|
|
|
|
|
|
opConfig.pinned = pinned;
|
|
|
|
|
|
/* Draw the chip first so there is something to fly to, then save. */
|
|
|
|
|
|
renderPinnedChips();
|
|
|
|
|
|
flyChip(fromRect, code);
|
|
|
|
|
|
opSave({ pinned }).then(renderOpLanguages).catch(() => loadOperator());
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setInterval(renderOperatorLive, 1000);
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('error', (e) => {
|
|
|
|
|
|
/* Where it happened, not just what. An "offset is out of bounds" with no
|
|
|
|
|
|
location took a morning to place. */
|
|
|
|
|
|
const at = e.filename ? ' (' + String(e.filename).split('/').pop() + ':' + e.lineno + ')' : '';
|
|
|
|
|
|
log('error: ' + e.message + at, 'err');
|
|
|
|
|
|
});
|
|
|
|
|
|
window.addEventListener('unhandledrejection', (e) => log('error: ' + (e.reason && e.reason.message || e.reason), 'err'));
|
|
|
|
|
|
|
|
|
|
|
|
boot();
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* ───────────────────────── collapsible sections ───────────────────────────
|
|
|
|
|
|
*
|
|
|
|
|
|
* Height is animated with grid-template-rows 0fr -> 1fr rather than a pixel
|
|
|
|
|
|
* height, so nothing has to measure the content — which matters here because
|
|
|
|
|
|
* these sections contain live meters, a 105-row language list and a QR that
|
|
|
|
|
|
* all change size after they are opened. A measured max-height would be wrong
|
|
|
|
|
|
* the moment any of them did.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Which sections are open is per-machine, not per-service, so it lives in
|
|
|
|
|
|
* localStorage rather than the config the server owns.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const GROUP_KEY = 'ct:groups';
|
|
|
|
|
|
|
|
|
|
|
|
function loadGroupState() {
|
|
|
|
|
|
try { return JSON.parse(localStorage.getItem(GROUP_KEY)) || {}; } catch (_) { return {}; }
|
|
|
|
|
|
}
|
|
|
|
|
|
function saveGroupState(m) {
|
|
|
|
|
|
try { localStorage.setItem(GROUP_KEY, JSON.stringify(m)); } catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setGroup(sec, open, remember) {
|
|
|
|
|
|
sec.dataset.open = open ? 'true' : 'false';
|
|
|
|
|
|
const head = sec.querySelector('.group-head');
|
|
|
|
|
|
if (head) head.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
|
|
|
|
if (remember) {
|
|
|
|
|
|
const m = loadGroupState();
|
|
|
|
|
|
m[sec.id] = open;
|
|
|
|
|
|
saveGroupState(m);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
(function initGroups() {
|
|
|
|
|
|
const saved = loadGroupState();
|
|
|
|
|
|
for (const sec of document.querySelectorAll('.group')) {
|
|
|
|
|
|
const head = sec.querySelector('.group-head');
|
|
|
|
|
|
if (!head) continue;
|
|
|
|
|
|
/* The markup's data-open is the default for a machine that has never been
|
|
|
|
|
|
used; a remembered choice wins. */
|
|
|
|
|
|
const want = Object.prototype.hasOwnProperty.call(saved, sec.id)
|
|
|
|
|
|
? !!saved[sec.id]
|
|
|
|
|
|
: sec.dataset.open === 'true';
|
|
|
|
|
|
setGroup(sec, want, false);
|
|
|
|
|
|
head.addEventListener('click', () => {
|
|
|
|
|
|
setGroup(sec, sec.dataset.open !== 'true', true);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|