Compare commits
18
Commits
v3.23.2-112
...
main
@@ -700,3 +700,13 @@ textarea:focus { border-color: var(--accent); }
|
||||
.langfly { display: none; }
|
||||
.langrow.touched { animation: none; }
|
||||
}
|
||||
|
||||
/* Release notes are prose from the forge — wrap them, keep them short, and let
|
||||
them scroll rather than push the rest of Settings off the pane. */
|
||||
.updnotes {
|
||||
max-height: 16rem; overflow: auto; white-space: pre-wrap; word-break: break-word;
|
||||
font-size: .75rem; line-height: 1.45; color: var(--muted);
|
||||
background: var(--panel, rgba(255,255,255,.03));
|
||||
border-radius: 6px; padding: .625rem .75rem; margin: .375rem 0 0;
|
||||
}
|
||||
#updFound { margin-top: .5rem; }
|
||||
|
||||
+453
-27
@@ -190,9 +190,81 @@ const state = {
|
||||
|
||||
/* health counters, reported to the operator page */
|
||||
degradedFinals: 0, // finals that shipped with fewer languages (stale)
|
||||
captionOnlyFinals: 0 // finals that shipped with no translations at all
|
||||
captionOnlyFinals: 0, // finals that shipped with no translations at all
|
||||
captionOnlyAt: 0, // when the last one happened
|
||||
degradedAt: 0
|
||||
};
|
||||
|
||||
/*
|
||||
* THE SHAPE OF A LISTENER ROW. The server sends { code, count } — see
|
||||
* listenerCounts() in server.js — and this file read { lang, n } in five
|
||||
* places, so every set built from the rows was EMPTY: no language ever counted
|
||||
* as being listened to. Mostly harmless, except in the one place it mattered.
|
||||
* When a sentence arrives late, speech is kept only for the languages someone
|
||||
* is actually on, and "someone is on" was never true for any of them — so
|
||||
* under load the app dropped the speech of EVERY language, for everyone,
|
||||
* instead of only the languages nobody had chosen. Late became silent.
|
||||
* MEASURED on a campus Mac, 2026-10-15 15:11-15:12: 26 sentences dropped
|
||||
* across five languages during a stall, every one of them with listeners
|
||||
* present. Both spellings are read, so a server one build older or newer
|
||||
* still counts.
|
||||
*/
|
||||
function rowLang(r) { return r ? (r.code || r.lang || '') : ''; }
|
||||
function rowCount(r) { return r ? (Number(r.count != null ? r.count : r.n) || 0) : 0; }
|
||||
|
||||
/*
|
||||
* FOLLOW THE BOTTOM ONLY WHILE THE OPERATOR IS AT THE BOTTOM.
|
||||
*
|
||||
* Every one of these boxes used to end its update with
|
||||
* `box.scrollTop = box.scrollHeight`, unconditionally. While the service is
|
||||
* running that fires on every sentence, so scrolling back to re-read what was
|
||||
* just said was impossible: the box yanked itself to the live edge a fraction
|
||||
* of a second later, every time. Reported from the desk as "can't scroll on
|
||||
* what's being said when it's live", which is exactly what it was.
|
||||
*
|
||||
* A box follows the live edge until the operator scrolls away from it, and
|
||||
* starts following again when they come back to it. Nothing has to be pressed
|
||||
* to get out, and nothing has to be pressed to get back in.
|
||||
*
|
||||
* The slack matters. A programmatic jump to the bottom also raises a scroll
|
||||
* event, and it lands AT the bottom, so sticking survives its own scrolling.
|
||||
* Sub-pixel layout and a growing last line can leave a couple of pixels
|
||||
* behind, which with a zero threshold would unstick the box on its own output.
|
||||
*/
|
||||
/*
|
||||
* Trimming the TOP of a box the operator is reading moves the text under
|
||||
* their eyes — the other half of "it keeps jumping". While they are scrolled
|
||||
* away the cap is suspended, so the lines they are looking at stay put; when
|
||||
* they return to the live edge the backlog is trimmed in one go. The ceiling
|
||||
* keeps a box that is left scrolled up all service from growing without end.
|
||||
*/
|
||||
function trimTop(box, cap) {
|
||||
if (!box) return;
|
||||
const held = box.dataset.stick === '0';
|
||||
const limit = held ? cap * 6 : cap;
|
||||
while (box.children.length > limit) box.removeChild(box.firstChild);
|
||||
}
|
||||
|
||||
const STICK_SLACK_PX = 48;
|
||||
const stickWired = new WeakSet();
|
||||
|
||||
function followBottom(box) {
|
||||
if (!box) return;
|
||||
if (!stickWired.has(box)) {
|
||||
stickWired.add(box);
|
||||
box.dataset.stick = '1';
|
||||
box.addEventListener('scroll', () => {
|
||||
const atBottom =
|
||||
box.scrollHeight - box.scrollTop - box.clientHeight <= STICK_SLACK_PX;
|
||||
box.dataset.stick = atBottom ? '1' : '0';
|
||||
}, { passive: true });
|
||||
}
|
||||
/* Scrolled away: leave the view exactly where they put it. New content
|
||||
grows BELOW the viewport, so not touching scrollTop is what holds the
|
||||
reader's place — there is nothing else to do here. */
|
||||
if (box.dataset.stick !== '0') box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────── logging
|
||||
|
||||
function log(msg, kind) {
|
||||
@@ -206,8 +278,8 @@ function log(msg, kind) {
|
||||
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;
|
||||
trimTop(el, 200);
|
||||
followBottom(el);
|
||||
}
|
||||
|
||||
function hero(stateName, title, body) {
|
||||
@@ -891,12 +963,54 @@ async function respawnWorker(worker) {
|
||||
log('the speech model is too heavy for this machine — switching to ' +
|
||||
lighter.split('/').pop(), 'err');
|
||||
}
|
||||
/*
|
||||
* A LIGHTER MODEL GETS THE PREVIEW'S DTYPE, NOT THE BIG ONE'S.
|
||||
*
|
||||
* asrDtype is chosen for the accurate model and its weights are cached against
|
||||
* it. Reusing it when stepping down asks for a variant that was never
|
||||
* downloaded — and those weights live in the browser cache, with no network at
|
||||
* a venue — so the step-down failed and the ladder could not move at all.
|
||||
*
|
||||
* MEASURED on a campus Mac: the cache held whisper-large-v3-turbo as
|
||||
* encoder_model_q4f16.onnx (its asrDtype) and whisper-small as
|
||||
* encoder_model_fp16.onnx (the preview's). Stepping down asked for
|
||||
* whisper-small at q4f16, a file that is not there; it went to the network,
|
||||
* failed, and logged "Unsupported model type: whisper" three times a minute
|
||||
* for ever while the service sat dead.
|
||||
*
|
||||
* The preview runs whisper-small on exactly these settings every service, so
|
||||
* they are the one combination this machine is known to be able to load.
|
||||
*/
|
||||
const stepDtype = lighter
|
||||
? resolveDtype(cfg.asrFastDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' })
|
||||
: resolveDtype(cfg.asrDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' });
|
||||
state.asrWorker = await loadWorkerWithFallback('speech', 'automatic-speech-recognition',
|
||||
state.asrModelInUse || cfg.asrModel,
|
||||
resolveDtype(cfg.asrDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' }),
|
||||
stepDtype,
|
||||
{ encoder_model: 'q4', decoder_model_merged: 'q4' });
|
||||
log('speech model restarted' + (lighter ? ' on ' + lighter.split('/').pop() : ''), 'ok');
|
||||
state.slowRun = 0;
|
||||
if (lighter) {
|
||||
/*
|
||||
* THROW AWAY THE BACKLOG THAT CAUSED THIS, AND LET THE NEW MODEL SETTLE.
|
||||
*
|
||||
* Swapping the model does nothing about the queue that triggered the swap.
|
||||
* Those chunks are already past the caption-only cutoff, so they can no
|
||||
* longer be translated in time and are worth nothing to the room — but the
|
||||
* first of them gets handed to a model that has just loaded, takes the full
|
||||
* worker timeout, and trips the absolute rule. MEASURED: a campus Mac fell
|
||||
* large-v3-turbo -> small -> base in 34 seconds exactly that way, landing on
|
||||
* a model SLOWER than the one it started from.
|
||||
*
|
||||
* So drop them, and refuse to step down again for a grace window, so the new
|
||||
* model is judged on sentences it was actually given.
|
||||
*/
|
||||
const dropped = queue.length;
|
||||
queue.length = 0;
|
||||
state.steppedDownAt = Date.now();
|
||||
if (dropped) log('dropped ' + dropped + ' stale sentence' + (dropped === 1 ? '' : 's') +
|
||||
' the old model never reached', 'warn');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log('model restart failed: ' + err.message, 'err');
|
||||
@@ -973,10 +1087,15 @@ 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));
|
||||
cannot queue up a re-download marathon.
|
||||
Only what is SENT counts as warmed. Every wanted language used to be
|
||||
marked and only the first four sent, so a fifth was never preloaded and
|
||||
built its engine on its first sentence instead — por_PT, at all five
|
||||
starts on the Mini on 2026-10-15. Six is the voice process's own cap. */
|
||||
const batch = want.slice(0, 6);
|
||||
batch.forEach((l) => preloadedVoices.add(l));
|
||||
window.engineHost.ttsPreload(batch);
|
||||
}
|
||||
|
||||
/* The translation model reports its download from the main process. */
|
||||
@@ -987,7 +1106,10 @@ if (window.engineHost && window.engineHost.onTtsReady) {
|
||||
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. */
|
||||
service is not the one that pays for the download. A voice process
|
||||
that has just (re)started holds no engines, whatever this page warmed
|
||||
in the last one — so forget that first. */
|
||||
preloadedVoices.clear();
|
||||
preloadNewVoices();
|
||||
});
|
||||
window.engineHost.onTtsProgress((p) => log('voice ' + p.bundle + ': ' + p.status));
|
||||
@@ -1681,9 +1803,9 @@ async function pumpPartial() {
|
||||
* still gets a preview.
|
||||
*/
|
||||
const heard = (state.byLanguage || [])
|
||||
.filter((r) => r && r.lang && r.lang !== state.source && (r.n || 0) > 0)
|
||||
.filter((r) => r && rowLang(r) && rowLang(r) !== state.source && rowCount(r) > 0)
|
||||
.sort((a, b) => (b.n || 0) - (a.n || 0))
|
||||
.map((r) => r.lang)
|
||||
.map(rowLang)
|
||||
.filter((l) => state.targets.indexOf(l) !== -1);
|
||||
const want = (heard.length ? heard : orderedTargets()).slice(0, PARTIAL_MAX_LANGS);
|
||||
/* Never queue behind a finished sentence — a final in flight is worth more
|
||||
@@ -1709,7 +1831,7 @@ async function pumpPartial() {
|
||||
*/
|
||||
function orderedTargets() {
|
||||
const counts = {};
|
||||
for (const row of state.byLanguage || []) counts[row.lang] = row.n || 1;
|
||||
for (const row of state.byLanguage || []) counts[rowLang(row)] = rowCount(row) || 1;
|
||||
return state.targets
|
||||
.filter((t) => t !== state.source)
|
||||
.sort((a, b) => (counts[b] || 0) - (counts[a] || 0));
|
||||
@@ -1794,6 +1916,7 @@ async function pump() {
|
||||
|
||||
state.busy = true;
|
||||
const started = Date.now();
|
||||
let jobAudioMs = 0;
|
||||
try {
|
||||
/* job.at is when the audio ENDED. Live translation of stale audio is
|
||||
worthless, but the WORDS are not: however old the job, it still gets
|
||||
@@ -1805,6 +1928,9 @@ async function pump() {
|
||||
/* Near-silence telemetry (log-only this release): a chunk whose average
|
||||
level barely clears the noise floor is hallucination fuel. */
|
||||
const audio = flatten(job.frames);
|
||||
/* How much speech this chunk actually holds. The step-down below compares
|
||||
against it, because "slow" only means anything relative to the audio. */
|
||||
jobAudioMs = (audio.length / SAMPLE_RATE) * 1000;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < audio.length; i += 4) sum += audio[i] * audio[i];
|
||||
const meanRms = Math.sqrt(sum / Math.ceil(audio.length / 4));
|
||||
@@ -1845,15 +1971,33 @@ async function pump() {
|
||||
const sentences = collapseLoops(splitSentences(text));
|
||||
|
||||
let targets = orderedTargets();
|
||||
if (ageSec > 12) {
|
||||
/*
|
||||
* SKIPPING A TRANSLATION ONLY BUYS SOMETHING IF THERE IS A QUEUE TO DRAIN.
|
||||
*
|
||||
* Dropping the translations of stale audio is how the app catches up: the
|
||||
* listener has moved on, so the work is wasted and the time is better spent on
|
||||
* the sentences behind it. That reasoning needs a backlog. With nothing waiting
|
||||
* behind it, skipping costs the room a sentence and saves nothing at all.
|
||||
*
|
||||
* MEASURED: a campus Mac took one 16 s stall — two Screen Sharing sessions
|
||||
* competing for the machine — and skipped that sentence's translations with an
|
||||
* EMPTY queue. The room lost a line of the sermon to a catch-up that had
|
||||
* nothing to catch up on.
|
||||
*/
|
||||
if (ageSec > 12 && queue.length > 0) {
|
||||
targets = [];
|
||||
state.captionOnlyFinals++;
|
||||
log('audio ' + ageSec.toFixed(0) + 's stale — caption only, translations skipped', 'err');
|
||||
state.captionOnlyAt = Date.now();
|
||||
log('audio ' + ageSec.toFixed(0) + 's stale with ' + queue.length + ' waiting — ' +
|
||||
'caption only, translations skipped', 'err');
|
||||
} else if (ageSec > 12) {
|
||||
/* Late, but nothing is behind it: say so, and translate it anyway. */
|
||||
log('audio ' + ageSec.toFixed(0) + 's late, but nothing is queued — translating it anyway', 'warn');
|
||||
} else if (ageSec > 6) {
|
||||
const listening = new Set((state.byLanguage || []).map((r) => r.lang));
|
||||
const listening = new Set((state.byLanguage || []).map(rowLang));
|
||||
const before = targets.length;
|
||||
targets = targets.filter((t) => listening.has(t));
|
||||
if (targets.length < before) state.degradedFinals++;
|
||||
if (targets.length < before) { state.degradedFinals++; state.degradedAt = Date.now(); }
|
||||
}
|
||||
|
||||
/* The segment's own loudness, measured once and shared by every sentence
|
||||
@@ -1908,11 +2052,56 @@ async function pump() {
|
||||
* 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.
|
||||
*/
|
||||
if (took > 8000) {
|
||||
/*
|
||||
* SLOWER THAN THE AUDIO IT COVERS — not slower than some constant.
|
||||
*
|
||||
* This used to fire only past 8 s, on the reasoning that 8 s meant slower
|
||||
* than people talk. That was true when a chunk could be 8 s long. Chunks
|
||||
* are capped at HARD_CUT_MAX, 5.5 s, so a 4.5 s chunk taking 4.3 s is
|
||||
* already losing ground and never came near the old threshold.
|
||||
*
|
||||
* MEASURED on a campus M4 Pro: every sentence 4.2-4.7 s against roughly
|
||||
* 4-5 s of audio, a queue 46 deep and a lag of 152 s still climbing — with
|
||||
* nobody listening and no language being translated, so this was
|
||||
* transcription alone. The one mechanism built to catch exactly that could
|
||||
* not fire, because 4.4 s is not 8 s.
|
||||
*
|
||||
* The queue test is what separates "this machine cannot keep up" from one
|
||||
* slow sentence: a backlog that is three deep and being fed faster than it
|
||||
* drains does not recover on its own.
|
||||
*/
|
||||
/*
|
||||
* SLOWER THAN THE AUDIO IT COVERS, AND LANDING LATE.
|
||||
*
|
||||
* This used to fire only past 8 s, on the reasoning that 8 s meant slower
|
||||
* than people talk. That held when a chunk could be 8 s long; chunks are
|
||||
* capped at HARD_CUT_MAX, 5.5 s, so a 4.4 s sentence is already losing and
|
||||
* never came near the old threshold.
|
||||
*
|
||||
* Both numbers below were measured on a campus M4 Pro, and the second one
|
||||
* only after a first attempt got it wrong. 0.85, because that Mac ran 4.4 s
|
||||
* against 4.5 s of audio — nominally FASTER than real time while losing
|
||||
* badly, since the work outside this timer (segmenting, the preview model
|
||||
* sharing the GPU, the scheduler) loses on its own.
|
||||
*
|
||||
* The second test was a queue depth, and watching that Mac go red disproved
|
||||
* it: the queue sat at 2-3 while the lag held at 15 s and the verdict was
|
||||
* already red. A machine can be persistently late without ever stacking work
|
||||
* up — it finishes each sentence just slightly too slowly, for ever. So the
|
||||
* test is how far behind the speaker the sentence actually landed, which is
|
||||
* what the operator is complaining about in the first place.
|
||||
*/
|
||||
/* A model that loaded seconds ago has not had a fair sentence yet. */
|
||||
const settling = state.steppedDownAt && (Date.now() - state.steppedDownAt) < STEP_SETTLE_MS;
|
||||
const behindMs = Date.now() - job.at;
|
||||
const losingGround = jobAudioMs > 0 && took > jobAudioMs * 0.85 && behindMs > 8000;
|
||||
if (!settling && (took > 8000 || losingGround)) {
|
||||
state.slowRun = (state.slowRun || 0) + 1;
|
||||
if (state.slowRun >= 3 && !respawning && lighterAsr(state.asrModelInUse || state.cfg.asrModel)) {
|
||||
state.slowRun = 0;
|
||||
log('transcription is slower than people are speaking — stepping down to a lighter model', 'err');
|
||||
log('transcription is slower than people are speaking (' + took + ' ms for ' +
|
||||
Math.round(jobAudioMs) + ' ms of audio, ' + Math.round(behindMs / 1000) + 's behind) — ' +
|
||||
'stepping down to a lighter model', 'err');
|
||||
respawnWorker(state.asrWorker);
|
||||
}
|
||||
} else state.slowRun = 0;
|
||||
@@ -2005,7 +2194,7 @@ setInterval(() => {
|
||||
}, 30000);
|
||||
|
||||
function translateSentence(cid, sentence, targets, gainDb, silent) {
|
||||
const listening = new Set((state.byLanguage || []).map((r) => r.lang));
|
||||
const listening = new Set((state.byLanguage || []).map(rowLang));
|
||||
for (const lang of targets) {
|
||||
if ((mtDepth[lang] || 0) > 6 && !listening.has(lang)) {
|
||||
state.degradedFinals++;
|
||||
@@ -2360,8 +2549,8 @@ function monitorFinal(cid, text) {
|
||||
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;
|
||||
trimTop(box, 24);
|
||||
followBottom(box);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -2403,7 +2592,7 @@ function monitorTranslation(cid, lang, text) {
|
||||
row.classList.add('dropped');
|
||||
}
|
||||
const box = $('monitor');
|
||||
if (box) box.scrollTop = box.scrollHeight;
|
||||
followBottom(box);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -2423,6 +2612,11 @@ function monitorTranslation(cid, lang, text) {
|
||||
* It says it once per episode, and names both numbers so the operator can act
|
||||
* on it rather than go looking.
|
||||
*/
|
||||
/* How long a freshly swapped speech model is left alone. Long enough to cover a
|
||||
cold first inference, short enough that a genuinely hopeless model still falls
|
||||
through the ladder within a sentence or two. */
|
||||
const STEP_SETTLE_MS = 20000;
|
||||
|
||||
const DEAF_AFTER_MS = 90000;
|
||||
setInterval(() => {
|
||||
try {
|
||||
@@ -2465,9 +2659,9 @@ function showMonitor(text, isPartial, langCount) {
|
||||
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);
|
||||
trimTop(box, 30);
|
||||
}
|
||||
box.scrollTop = box.scrollHeight;
|
||||
followBottom(box);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────── church server
|
||||
@@ -2662,6 +2856,7 @@ function goLive(on) {
|
||||
state.live = on;
|
||||
if (on) {
|
||||
state.degradedFinals = 0; state.captionOnlyFinals = 0; state.fragmentFinals = 0;
|
||||
state.captionOnlyAt = 0; state.degradedAt = 0;
|
||||
/* The watchdog measures from the start of the service, not from the last
|
||||
sentence of the previous one. */
|
||||
state.lastFinalAt = Date.now();
|
||||
@@ -2736,6 +2931,10 @@ function reportStatus() {
|
||||
queue: queue.length,
|
||||
degradedFinals: state.degradedFinals,
|
||||
captionOnlyFinals: state.captionOnlyFinals,
|
||||
/* WHEN, not just how many — the verdict needs to know whether this is
|
||||
happening now or happened once an hour ago. */
|
||||
captionOnlyAt: state.captionOnlyAt || 0,
|
||||
degradedAt: state.degradedAt || 0,
|
||||
fragmentFinals: state.fragmentFinals || 0,
|
||||
cloud: state.cloudStats || null
|
||||
}
|
||||
@@ -2931,6 +3130,102 @@ $('optDynamics').addEventListener('change', async (e) => {
|
||||
: 'voice dynamics off — every sentence at the voice\u2019s own level', 'ok');
|
||||
});
|
||||
|
||||
/*
|
||||
* ─────────────────────────── updates from our own forge ───────────────────
|
||||
*
|
||||
* Asked for, never volunteered. A background check that pops "an update is
|
||||
* available" three minutes into a sermon is a hazard, not a feature, so this
|
||||
* runs only when the operator presses the button and downloads only when they
|
||||
* press the second one.
|
||||
*/
|
||||
function fmtMB(n) { return (Number(n || 0) / 1048576).toFixed(0) + ' MB'; }
|
||||
|
||||
let updFound = null;
|
||||
|
||||
async function checkForUpdate() {
|
||||
const btn = $('updCheck');
|
||||
btn.disabled = true;
|
||||
$('updState').textContent = 'Checking…';
|
||||
$('updFound').hidden = true;
|
||||
try {
|
||||
const r = await window.engineHost.updateCheck();
|
||||
const here = r.current ? (r.current.version + ' \u00b7 ' + r.current.build) : 'this build';
|
||||
if (!r.ok) { $('updState').textContent = r.error || 'Could not reach the update server.'; return; }
|
||||
if (!r.latest) { $('updState').textContent = r.note || 'Nothing published yet. Running ' + here + '.'; return; }
|
||||
const there = r.latest.tag;
|
||||
if (!r.newer) { $('updState').textContent = 'Up to date \u2014 running ' + here + ', newest is ' + there + '.'; return; }
|
||||
if (!r.latest.asset) {
|
||||
$('updState').textContent = there + ' is newer, but it has no installer attached.';
|
||||
return;
|
||||
}
|
||||
updFound = r.latest;
|
||||
$('updState').textContent = 'Update available.';
|
||||
$('updWhat').textContent = r.latest.title + ' \u2014 ' + fmtMB(r.latest.asset.size) +
|
||||
'. You are on ' + here + '.';
|
||||
$('updNotes').textContent = (r.latest.notes || '').trim() || 'No notes.';
|
||||
$('updNotesBox').hidden = !r.latest.notes;
|
||||
$('updFound').hidden = false;
|
||||
$('updProgress').textContent = '';
|
||||
$('updGet').disabled = false;
|
||||
} catch (e) {
|
||||
$('updState').textContent = 'Could not check: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadUpdate() {
|
||||
if (!updFound || !updFound.asset) return;
|
||||
const btn = $('updGet');
|
||||
btn.disabled = true;
|
||||
$('updProgress').textContent = 'Starting…';
|
||||
try {
|
||||
const r = await window.engineHost.updateDownload(updFound.asset.url, updFound.asset.name);
|
||||
if (!r.ok) { $('updProgress').textContent = r.error || 'Download failed.'; btn.disabled = false; return; }
|
||||
$('updProgress').textContent = 'Downloaded. Installing\u2026';
|
||||
log('update ' + updFound.tag + ' downloaded to ' + r.path, 'ok');
|
||||
|
||||
/*
|
||||
* Straight into the install. The app quits, a helper swaps the bundle and
|
||||
* reopens it — so from here the window closing IS the update working, and
|
||||
* saying so before it happens stops that looking like a crash.
|
||||
*/
|
||||
$('updWhat').textContent = 'Installing ' + updFound.tag + '. Church Translate will close and ' +
|
||||
'reopen by itself in a few seconds. Your settings, models and voices are untouched.';
|
||||
const ins = await window.engineHost.updateInstall(r.path);
|
||||
if (!ins || !ins.ok) {
|
||||
/* Still useful: the file is downloaded, so fall back to the manual route
|
||||
rather than leaving them with nothing. */
|
||||
$('updProgress').textContent = (ins && ins.error) || 'Could not install.';
|
||||
$('updWhat').textContent = 'Downloaded, but it could not install itself. Quit Church ' +
|
||||
'Translate, open the disk image in Downloads, and drag the app over the one in Applications.';
|
||||
try { await window.engineHost.updateReveal(r.path); } catch (_) {}
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
$('updProgress').textContent = 'Download failed: ' + e.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$('updCheck').addEventListener('click', checkForUpdate);
|
||||
$('updGet').addEventListener('click', downloadUpdate);
|
||||
if (window.engineHost.onUpdateProgress) {
|
||||
window.engineHost.onUpdateProgress((p) => {
|
||||
if (!p || !p.total) { $('updProgress').textContent = fmtMB(p && p.got); return; }
|
||||
$('updProgress').textContent = Math.round((p.got / p.total) * 100) + '% (' +
|
||||
fmtMB(p.got) + ' of ' + fmtMB(p.total) + ')';
|
||||
});
|
||||
}
|
||||
|
||||
/* The token is a credential, so it is saved on blur rather than per keystroke
|
||||
and never logged. */
|
||||
$('updToken').addEventListener('change', async () => {
|
||||
const v = $('updToken').value.trim();
|
||||
state.cfg = await window.engineHost.setConfig({ updateToken: v });
|
||||
$('updState').textContent = v ? 'Token saved.' : 'Token cleared.';
|
||||
});
|
||||
|
||||
$('revealCache').addEventListener('click', () => window.engineHost.revealCache());
|
||||
|
||||
/* ---- carrying a setup to another campus ------------------------------- */
|
||||
@@ -3291,14 +3586,50 @@ function shareChoice() {
|
||||
};
|
||||
}
|
||||
|
||||
function shareBase() {
|
||||
/* This Mac on the church network. The operator page lives here and NOWHERE
|
||||
else: it returns 404 to anything arriving through a proxy, by design. */
|
||||
function lanBase() {
|
||||
const c = shareChoice();
|
||||
if (!c || c.none) return '';
|
||||
return 'http://' + c.addr.address + ':' + state.local.port;
|
||||
}
|
||||
|
||||
/*
|
||||
* WHAT THE CONGREGATION OPENS.
|
||||
*
|
||||
* A church that has put this on a domain has said so in the operator page's
|
||||
* Public address, and from that moment the LAN address is the wrong thing to
|
||||
* print: it is not what is on the poster, not what the QR should encode, and
|
||||
* not what someone off the building's wifi can reach. The app window went on
|
||||
* showing the IP anyway, because this half of it never read that setting --
|
||||
* the operator page honoured it and the main window did not, so the two
|
||||
* disagreed about the one fact they both exist to display.
|
||||
*/
|
||||
function publicBaseConfigured() {
|
||||
const u = opConfig && typeof opConfig.publicUrl === 'string' ? opConfig.publicUrl.trim() : '';
|
||||
return u ? u.replace(/\/+$/, '') : '';
|
||||
}
|
||||
|
||||
function shareBase() {
|
||||
return publicBaseConfigured() || lanBase();
|
||||
}
|
||||
|
||||
function renderQrWarning() {
|
||||
const el = $('qrWarn');
|
||||
/*
|
||||
* On a domain the warnings below are about an address nobody is being
|
||||
* given any more. Saying "Wi-Fi is down, phones cannot reach the code"
|
||||
* while the code points at a proxy would send the desk after the wrong
|
||||
* thing entirely.
|
||||
*/
|
||||
const pub = publicBaseConfigured();
|
||||
if (pub) {
|
||||
el.hidden = false;
|
||||
el.textContent = 'The code points at ' + pub + ', set as the public address. ' +
|
||||
'This Mac still has to be reachable from there; its own address is ' +
|
||||
(lanBase() || 'unavailable') + '.';
|
||||
return;
|
||||
}
|
||||
const c = shareChoice();
|
||||
if (!c) { el.hidden = true; return; }
|
||||
|
||||
@@ -3349,6 +3680,26 @@ function renderShare() {
|
||||
const list = state.local.addresses || [];
|
||||
const box = $('addrList');
|
||||
box.textContent = '';
|
||||
/*
|
||||
* WHICH INTERFACE THE QR USES IS A QUESTION ONLY A LAN SETUP HAS.
|
||||
*
|
||||
* These rows let the operator pick the address the code points at. Once a
|
||||
* public address is configured the code points at the domain whatever they
|
||||
* pick, so the list is a row of numbers that do nothing — and the one
|
||||
* marked IN USE is not the address anyone is being given. Hide it.
|
||||
*/
|
||||
const addrBox = $('addrList');
|
||||
addrBox.hidden = !!publicBaseConfigured();
|
||||
if (addrBox.hidden) {
|
||||
/* Still draw the code itself — it is the only thing left in this panel. */
|
||||
const only = shareBase();
|
||||
$('qr').src = only
|
||||
? 'http://127.0.0.1:' + state.local.port + '/qr.svg?url=' + encodeURIComponent(only + '/')
|
||||
: '';
|
||||
renderQrWarning();
|
||||
renderOperatorUrl();
|
||||
return;
|
||||
}
|
||||
|
||||
/* Mark the row the QR is actually using, not the row that was saved — when
|
||||
a lease moves those are two different things and the saved one is gone. */
|
||||
@@ -3390,8 +3741,40 @@ $('copyAddr').addEventListener('click', () => {
|
||||
.catch((err) => log('could not copy: ' + err.message, 'err'));
|
||||
});
|
||||
|
||||
$('openPoster').addEventListener('click', () => {
|
||||
window.open('http://127.0.0.1:' + state.local.port + '/poster', '_blank');
|
||||
/*
|
||||
* The QR itself, saved as a file, because the poster people actually print
|
||||
* is made in their own design tool. The built-in poster page is still there
|
||||
* at /poster for anyone who wants it; it is just not what this button does.
|
||||
*/
|
||||
$('saveQr').addEventListener('click', async () => {
|
||||
const base = shareBase();
|
||||
if (!base) { log('no address to encode yet', 'err'); return; }
|
||||
/*
|
||||
* Fetch the code and hand the browser a BLOB rather than linking straight at
|
||||
* the server. This window is served from the asset port and the QR comes off
|
||||
* the congregation port, so a plain <a download> is CROSS-ORIGIN — the
|
||||
* download attribute is ignored and the window navigates to the image
|
||||
* instead. With no back button, no reload item and no address bar, that
|
||||
* strands the operator on a full-screen QR with no way out; build 123 did
|
||||
* exactly that to a booth Mac. A blob: URL is same-origin, so it downloads.
|
||||
*/
|
||||
const url = 'http://127.0.0.1:' + state.local.port + '/qr.svg?url=' +
|
||||
encodeURIComponent(base + '/');
|
||||
try {
|
||||
const r = await fetch(url, { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error('the server answered ' + r.status);
|
||||
const href = URL.createObjectURL(await r.blob());
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = 'church-translate-qr.svg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(href), 10000);
|
||||
log('saved the QR for ' + base + '/ to Downloads', 'ok');
|
||||
} catch (err) {
|
||||
log('could not save the QR: ' + err.message, 'err');
|
||||
}
|
||||
});
|
||||
$('openOperator').addEventListener('click', () => {
|
||||
window.open('http://127.0.0.1:' + state.local.port + '/operator', '_blank');
|
||||
@@ -3448,7 +3831,8 @@ function renderBuildTag() {
|
||||
}
|
||||
|
||||
function renderOperatorUrl() {
|
||||
const base = shareBase();
|
||||
/* Deliberately lanBase, not shareBase: over a proxy /operator is a 404. */
|
||||
const base = lanBase();
|
||||
$('opPageUrl').textContent = base ? base + '/operator' : 'no network';
|
||||
}
|
||||
|
||||
@@ -3583,6 +3967,7 @@ async function boot() {
|
||||
const savedSens = Number(state.cfg && state.cfg.sensitivity);
|
||||
state.sensitivity = Number.isFinite(savedSens) && savedSens > 0 ? savedSens : 3.5;
|
||||
applyThreshFromConfig();
|
||||
try { $('updToken').value = (state.cfg && state.cfg.updateToken) || ''; } catch (_) {}
|
||||
refreshCacheInfo();
|
||||
|
||||
/* Start the download first and do not wait for it. It is the slow part, it
|
||||
@@ -3750,6 +4135,7 @@ async function loadOperator() {
|
||||
try {
|
||||
const j = await opApi('/api/config');
|
||||
opConfig = j.config;
|
||||
renderShare(); /* publicUrl may have just arrived */
|
||||
clearTimeout(opLoadTimer); opLoadTimer = null; // it answered; stop retrying
|
||||
opRetryMs = 0;
|
||||
renderOpLanguages();
|
||||
@@ -3804,6 +4190,7 @@ async function opSave(patch, okMsg) {
|
||||
try {
|
||||
const j = await opApi('/api/config', { method: 'POST', body: JSON.stringify(patch) });
|
||||
opConfig = j.config;
|
||||
renderShare(); /* publicUrl may have just arrived */
|
||||
if (okMsg) log(okMsg, 'ok');
|
||||
return j;
|
||||
} catch (e) { log('could not save: ' + e.message, 'err'); throw e; }
|
||||
@@ -4281,6 +4668,40 @@ function renderVoiceList() {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* WHERE AN AUDITION PLAYS: never on the card that feeds the room.
|
||||
*
|
||||
* An <audio> element with no sink plays on the system default output, and on
|
||||
* the production Mini that IS the Dante Virtual Soundcard (system_profiler:
|
||||
* "Default Output Device: Yes") — the same card that carries Spanish on its
|
||||
* third channel (outputMap spa_Latn -> [2]). So ▶ there put the sample line
|
||||
* onto the Dante network, service or no service. The phones were never
|
||||
* exposed: an audition comes back to this page as bytes and never becomes a
|
||||
* clip the server can see.
|
||||
*
|
||||
* Which of the card's channels it reached is Chromium's mono up-mix and the
|
||||
* Dante routing, neither of which this page controls, so it does not try to
|
||||
* pick a "safe" channel. It plays on another output, or not at all, and says
|
||||
* why.
|
||||
*/
|
||||
async function auditionSink() {
|
||||
const routedId = ($('outDevice') && $('outDevice').value) || '';
|
||||
const routedLabel = (state.cfg && state.cfg.outputDeviceLabel) || '';
|
||||
if (!routedId && !routedLabel) return ''; // no hardware output in use
|
||||
const strip = (l) => String(l || '').toLowerCase().replace(/^default\s*-\s*/, '').trim();
|
||||
let outs = [];
|
||||
try {
|
||||
outs = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'audiooutput');
|
||||
} catch (_) { /* refused below rather than guessed */ }
|
||||
const card = strip(((outs.find((d) => d.deviceId === routedId) || {}).label) || routedLabel);
|
||||
const isCard = (d) => (!!routedId && d.deviceId === routedId) || (!!card && strip(d.label) === card);
|
||||
const def = outs.find((d) => d.deviceId === 'default');
|
||||
if (def && !isCard(def)) return ''; // the system default is somewhere else
|
||||
const other = outs.find((d) => d.deviceId !== 'default' && d.deviceId !== 'communications' && !isCard(d));
|
||||
if (other) return other.deviceId;
|
||||
throw new Error('the only output here is the one feeding the hardware channels — auditions stay off it');
|
||||
}
|
||||
|
||||
async function auditionVoice(lang, v, btn, msg) {
|
||||
if (auditionBusy) return;
|
||||
auditionBusy = true;
|
||||
@@ -4305,9 +4726,14 @@ async function auditionVoice(lang, v, btn, msg) {
|
||||
msg.className = 'vmsg';
|
||||
}
|
||||
if (instruct) msg.textContent = 'with your direction…';
|
||||
/* Where it will play is settled BEFORE the voice process builds anything:
|
||||
an audition that cannot play should cost the service nothing. */
|
||||
const sink = await auditionSink();
|
||||
const out = await window.engineHost.ttsAudition({ lang, voice: v, instruct });
|
||||
if (!auditionAudio) auditionAudio = new Audio();
|
||||
if (!out.wav) throw new Error('that voice produced no audio');
|
||||
if (typeof auditionAudio.setSinkId === 'function') await auditionAudio.setSinkId(sink);
|
||||
else if (sink) throw new Error('this build cannot keep auditions off the hardware output');
|
||||
auditionAudio.src = 'data:audio/wav;base64,' + out.wav;
|
||||
/* play() rejects on autoplay policy or a decode failure; either way the
|
||||
operator needs the reason, not a silent button. */
|
||||
|
||||
+25
-1
@@ -380,6 +380,30 @@
|
||||
|
||||
<hr>
|
||||
|
||||
<h3 class="subhead">Updates</h3>
|
||||
<p class="hint">Checks this church’s own server, not the internet — so it works at a venue
|
||||
with no route out. Nothing downloads on its own and nothing installs itself: this app is
|
||||
often live in front of a congregation.</p>
|
||||
<div class="row">
|
||||
<button class="btn" id="updCheck">Check for an update</button>
|
||||
<span class="hint" id="updState">—</span>
|
||||
</div>
|
||||
<div id="updFound" hidden>
|
||||
<p class="hint" id="updWhat"></p>
|
||||
<div class="row">
|
||||
<button class="btn" id="updGet">Download and install</button>
|
||||
<span class="hint" id="updProgress"></span>
|
||||
</div>
|
||||
<details id="updNotesBox"><summary class="hint">What changed</summary>
|
||||
<pre class="updnotes" id="updNotes"></pre></details>
|
||||
</div>
|
||||
<label for="updToken" style="margin-top:.75rem">Access token</label>
|
||||
<input id="updToken" type="password" placeholder="only while the repository is private"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<p class="hint">Stays on this Mac. It is not carried by <b>Save settings to a file</b>.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<label style="margin-top:.75rem">Models</label>
|
||||
<p class="hint" id="modelInfo">—</p>
|
||||
<div class="row">
|
||||
@@ -409,7 +433,7 @@
|
||||
<div id="addrList" class="addrlist"></div>
|
||||
<div class="railbtns">
|
||||
<button class="btn btn-primary" id="copyAddr">Copy address</button>
|
||||
<button class="btn" id="openPoster">Printable poster</button>
|
||||
<button class="btn" id="saveQr">Save QR image</button>
|
||||
</div>
|
||||
|
||||
<h3 class="subhead">Operator page</h3>
|
||||
|
||||
+302
@@ -232,6 +232,22 @@ const DEFAULT_CONFIG = {
|
||||
browser sandbox — see ensureTranslator() below for why. */
|
||||
mtDtype: 'fp32',
|
||||
autoConnect: true,
|
||||
/*
|
||||
* WHERE UPDATES COME FROM.
|
||||
*
|
||||
* The church's own Gitea, not the internet: a campus Mac may have no route
|
||||
* out, and an installer that cannot be fetched at the venue is worse than no
|
||||
* updater at all. Nothing here is checked automatically — this app is often
|
||||
* mid-service, and a download starting on its own is the last thing anyone
|
||||
* wants. The operator presses the button.
|
||||
*
|
||||
* `token` is only needed while the repository is private, and is deliberately
|
||||
* NOT in PORTABLE_APP: it is this machine's credential and must not ride along
|
||||
* in an exported settings file.
|
||||
*/
|
||||
updateForge: 'http://chronicles-ab.waterschurch.org:3000',
|
||||
updateRepo: 'christian/church-translate',
|
||||
updateToken: '',
|
||||
/* Setup-pane tuning that used to live only in the DOM. */
|
||||
sensitivity: 3.5,
|
||||
/*
|
||||
@@ -424,6 +440,11 @@ function startServer() {
|
||||
execPath: nodeBin,
|
||||
env: Object.assign({}, process.env, {
|
||||
CT_FFMPEG: ffmpegPathForChildren(),
|
||||
/* So /healthz can report the APP’s version rather than only an asset
|
||||
hash. A campus Mac’s build was otherwise unknowable from the network,
|
||||
which made confirming an update a matter of inference. */
|
||||
CT_APP_VERSION: appBuild().version,
|
||||
CT_APP_BUILD: appBuild().build,
|
||||
PORT: String(port),
|
||||
DATA_DIR: dataDir(),
|
||||
/* Where the spoken clips land, so the server can hand them to phones. */
|
||||
@@ -641,6 +662,27 @@ function createWindow() {
|
||||
: '/index.html';
|
||||
win.loadURL(assetOrigin + page);
|
||||
|
||||
/*
|
||||
* NOTHING replaces the app in this window. There is no back button, no
|
||||
* reload item and no address bar, so a window that leaves the app page is a
|
||||
* window the operator cannot get back from — which is precisely how build
|
||||
* 123 stranded a booth Mac on a full-screen QR image. Our own pages still
|
||||
* open as their OWN windows through setWindowOpenHandler below; this only
|
||||
* refuses to replace the app itself, and sends anything external to the
|
||||
* browser where it belongs.
|
||||
*/
|
||||
win.webContents.on('will-navigate', (e, url) => {
|
||||
if (url === assetOrigin + page) return; /* reloading ourselves is fine */
|
||||
e.preventDefault();
|
||||
console.error('[main] refused to navigate the app window to ' + url);
|
||||
let local = false;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
local = (u.hostname === '127.0.0.1' || u.hostname === 'localhost');
|
||||
} catch (_) { local = false; }
|
||||
if (!local) shell.openExternal(url).catch(() => {});
|
||||
});
|
||||
|
||||
win.webContents.on('did-finish-load', () => console.log('[main] page loaded'));
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) =>
|
||||
console.error('[main] page FAILED to load: ' + code + ' ' + desc + ' ' + url));
|
||||
@@ -2076,6 +2118,266 @@ app.on('before-quit', (e) => {
|
||||
if (ttsChild) { try { ttsChild.kill(); } catch (_) {} }
|
||||
});
|
||||
|
||||
/*
|
||||
* ─────────────────────────── updates from our own forge ───────────────────
|
||||
*
|
||||
* Gitea's release API, read directly. Deliberately NOT electron-updater: that
|
||||
* wants a static feed (latest-mac.yml) published beside the artifacts, and the
|
||||
* build here is ad-hoc signed with no Team ID, so a silent in-place swap would
|
||||
* trip Gatekeeper on a machine nobody is sitting at. Checking, downloading and
|
||||
* telling the operator where the file is does the useful 90% and cannot leave
|
||||
* a campus with a half-replaced app.
|
||||
*
|
||||
* Tags are `v<version>-<build>` — v3.23.2-112. The build number is what really
|
||||
* orders two releases of the same version, which is why it is compared second
|
||||
* and numerically.
|
||||
*/
|
||||
function parseTag(tag) {
|
||||
const m = String(tag || '').match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-.](\d+))?/);
|
||||
if (!m) return null;
|
||||
return { major: +m[1], minor: +m[2], patch: +m[3], build: m[4] === undefined ? 0 : +m[4] };
|
||||
}
|
||||
|
||||
/* Positive when a is newer than b. */
|
||||
function compareBuilds(a, b) {
|
||||
if (!a) return -1;
|
||||
if (!b) return 1;
|
||||
for (const k of ['major', 'minor', 'patch', 'build']) {
|
||||
if (a[k] !== b[k]) return a[k] > b[k] ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function forgeRequest(urlStr, token, cb) {
|
||||
let u;
|
||||
try { u = new URL(urlStr); } catch (e) { return cb(e); }
|
||||
const mod = u.protocol === 'https:' ? require('https') : http;
|
||||
const headers = { 'Accept': 'application/json', 'User-Agent': 'church-translate' };
|
||||
if (token) headers.Authorization = 'token ' + token;
|
||||
const req = mod.get(urlStr, { headers, timeout: 15000 }, (res) => {
|
||||
/* Gitea hands out asset downloads as a redirect to /attachments/<uuid>. */
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
return forgeRequest(new URL(res.headers.location, urlStr).toString(), token, cb);
|
||||
}
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (d) => { body += d; });
|
||||
res.on('end', () => cb(null, res.statusCode, body));
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(new Error('the forge did not answer within 15s')); });
|
||||
req.on('error', (e) => cb(e));
|
||||
}
|
||||
|
||||
ipcMain.handle('update:check', async () => {
|
||||
const cfg = loadConfig();
|
||||
const forge = String(cfg.updateForge || '').replace(/\/+$/, '');
|
||||
const repo = String(cfg.updateRepo || '');
|
||||
const here = appBuild();
|
||||
const current = parseTag('v' + here.version + '-' + here.build);
|
||||
if (!forge || !repo) return { ok: false, error: 'No update server is configured.', current: here };
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
forgeRequest(forge + '/api/v1/repos/' + repo + '/releases/latest', cfg.updateToken, (err, status, body) => {
|
||||
if (err) return resolve({ ok: false, error: err.message, current: here });
|
||||
/*
|
||||
* A PRIVATE REPOSITORY ANSWERS 404, NOT 401 — Gitea hides its existence
|
||||
* from a caller who cannot see it. Measured against this church's own
|
||||
* forge: with no token the releases endpoint returns 404, so reporting
|
||||
* "no releases published yet" told the operator the opposite of the
|
||||
* truth and pointed them away from the one thing that would fix it.
|
||||
* Without a token, 404 is ambiguous; say so and name the remedy.
|
||||
*/
|
||||
if (status === 404) {
|
||||
return resolve(cfg.updateToken
|
||||
? { ok: true, current: here, latest: null, newer: false, note: 'No releases published yet.' }
|
||||
: { ok: false, current: here,
|
||||
error: 'Nothing found there. If the repository is private, set an access token below.' });
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return resolve({ ok: false, current: here,
|
||||
error: 'The update server refused the request. If the repository is private, set an access token below.' });
|
||||
}
|
||||
if (status !== 200) return resolve({ ok: false, error: 'Update server returned ' + status, current: here });
|
||||
let rel;
|
||||
try { rel = JSON.parse(body); } catch (_) { return resolve({ ok: false, error: 'Unreadable answer from the update server.', current: here }); }
|
||||
const there = parseTag(rel.tag_name);
|
||||
const asset = (rel.assets || []).find((a) => /\.dmg$/i.test(a.name || ''));
|
||||
resolve({
|
||||
ok: true,
|
||||
current: here,
|
||||
latest: { tag: rel.tag_name, title: rel.title || rel.name || rel.tag_name,
|
||||
notes: rel.body || '', published: rel.published_at || '',
|
||||
asset: asset ? { name: asset.name, size: asset.size, url: asset.browser_download_url } : null },
|
||||
newer: compareBuilds(there, current) > 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* Download to the Downloads folder and reveal it. Not installed automatically:
|
||||
* replacing a running app from inside itself needs a helper process, and doing
|
||||
* that on a machine that might be live in front of a congregation is not a
|
||||
* trade worth making. The operator drags it across when the room is empty.
|
||||
*/
|
||||
ipcMain.handle('update:download', async (_e, url, name) => {
|
||||
const cfg = loadConfig();
|
||||
const dest = path.join(app.getPath('downloads'), String(name || 'church-translate.dmg').replace(/[^\w.\-]/g, '_'));
|
||||
return await new Promise((resolve) => {
|
||||
let u;
|
||||
try { u = new URL(String(url)); } catch (e) { return resolve({ ok: false, error: 'Bad download address.' }); }
|
||||
/*
|
||||
* PIN THE DOWNLOAD TO THE CONFIGURED FORGE, don't merely check it.
|
||||
*
|
||||
* Gitea answers with whatever address IT was reached on, which is not
|
||||
* necessarily the one configured here: this church's forge is reachable as
|
||||
* chronicles-ab.waterschurch.org and as 10.3.8.90, and the release API
|
||||
* hands back the IP form. A plain same-host check therefore REFUSED a
|
||||
* perfectly good installer — measured, on the very first real release.
|
||||
*
|
||||
* Rebuilding the URL on the configured origin keeps the property that
|
||||
* matters (this only ever fetches from the server the operator configured,
|
||||
* so a tampered API answer cannot redirect it elsewhere) while surviving a
|
||||
* forge that knows itself by more than one name.
|
||||
*/
|
||||
let base;
|
||||
try { base = new URL(cfg.updateForge); } catch (_) { return resolve({ ok: false, error: 'The update server address is not valid.' }); }
|
||||
u = new URL(u.pathname + u.search, base.origin);
|
||||
|
||||
const go = (target, hops) => {
|
||||
if (hops > 5) return resolve({ ok: false, error: 'Too many redirects.' });
|
||||
const mod = target.startsWith('https:') ? require('https') : http;
|
||||
const headers = { 'User-Agent': 'church-translate' };
|
||||
if (cfg.updateToken) headers.Authorization = 'token ' + cfg.updateToken;
|
||||
mod.get(target, { headers, timeout: 60000 }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
return go(new URL(res.headers.location, target).toString(), hops + 1);
|
||||
}
|
||||
if (res.statusCode !== 200) { res.resume(); return resolve({ ok: false, error: 'Download failed (' + res.statusCode + ').' }); }
|
||||
const total = Number(res.headers['content-length'] || 0);
|
||||
let got = 0, lastPush = 0;
|
||||
const out = fs.createWriteStream(dest);
|
||||
res.on('data', (d) => {
|
||||
got += d.length;
|
||||
const now = Date.now();
|
||||
if (now - lastPush > 400 && win && !win.isDestroyed()) {
|
||||
lastPush = now;
|
||||
try { win.webContents.send('update:progress', { got, total }); } catch (_) {}
|
||||
}
|
||||
});
|
||||
res.pipe(out);
|
||||
out.on('finish', () => out.close(() => {
|
||||
/* A truncated installer is worse than none: it looks present and
|
||||
fails at the venue. Refuse it rather than leave it lying there. */
|
||||
if (total && got !== total) {
|
||||
try { fs.unlinkSync(dest); } catch (_) {}
|
||||
return resolve({ ok: false, error: 'The download ended early and was discarded.' });
|
||||
}
|
||||
resolve({ ok: true, path: dest, bytes: got });
|
||||
}));
|
||||
out.on('error', (e) => resolve({ ok: false, error: e.message }));
|
||||
}).on('error', (e) => resolve({ ok: false, error: e.message }))
|
||||
.on('timeout', function () { this.destroy(new Error('the download stalled')); });
|
||||
};
|
||||
go(u.toString(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* ─────────────────────────── installing it, in place ──────────────────────
|
||||
*
|
||||
* An app cannot replace itself while it is running, so the swap is done by a
|
||||
* small script that outlives us: it waits for this process to exit, moves the
|
||||
* old bundle aside, copies the new one in, and relaunches.
|
||||
*
|
||||
* MOVED ASIDE, NOT DELETED. If the copy fails half way — a full disk, a bad
|
||||
* download, the volume going away — the old app is put straight back and
|
||||
* relaunched. The failure mode this is written against is a campus Mac with no
|
||||
* working app and a service in an hour, which is far worse than not updating.
|
||||
*
|
||||
* No sudo: /Applications is admin-writable on macOS and the bundle belongs to
|
||||
* the operator, so nothing here needs a password. If that is ever not true the
|
||||
* copy fails, the old app comes back, and the log says so.
|
||||
*/
|
||||
ipcMain.handle('update:install', async (_e, dmgPath) => {
|
||||
const dmg = String(dmgPath || '');
|
||||
if (!dmg || !fs.existsSync(dmg)) return { ok: false, error: 'That installer is no longer on disk.' };
|
||||
|
||||
/* Never mid-service. Quitting the app stops every listener's captions, and an
|
||||
update is never worth that — the operator stops translation first. */
|
||||
if (engineIsLive) {
|
||||
return { ok: false, error: 'Translation is live. Press Stop translating first, then install.' };
|
||||
}
|
||||
|
||||
const appPath = app.getPath('exe').replace(/\/Contents\/MacOS\/[^/]+$/, '');
|
||||
if (!/\.app$/.test(appPath)) {
|
||||
return { ok: false, error: 'This copy is not running from an .app bundle, so it cannot replace itself.' };
|
||||
}
|
||||
|
||||
const tmp = path.join(require('os').tmpdir(), 'ct-install-' + process.pid + '.sh');
|
||||
const mount = '/Volumes/ChurchTranslateUpdate';
|
||||
const aside = appPath + '.replacing-' + process.pid;
|
||||
const script = [
|
||||
'#!/bin/bash',
|
||||
'# Written by Church Translate to swap itself. Safe to delete.',
|
||||
'set -u',
|
||||
'DMG=' + JSON.stringify(dmg),
|
||||
'APP=' + JSON.stringify(appPath),
|
||||
'ASIDE=' + JSON.stringify(aside),
|
||||
'MNT=' + JSON.stringify(mount),
|
||||
'PID=' + process.pid,
|
||||
'LOG="$HOME/Library/Application Support/Church Translate/data/logs/install.log"',
|
||||
'mkdir -p "$(dirname "$LOG")"',
|
||||
'say(){ echo "$(date "+%Y-%m-%d %H:%M:%S") $*" >> "$LOG"; }',
|
||||
'say "installing $DMG into $APP"',
|
||||
'# wait for the app to actually be gone (up to 30s)',
|
||||
'for i in $(seq 1 60); do kill -0 "$PID" 2>/dev/null || break; sleep 0.5; done',
|
||||
'if kill -0 "$PID" 2>/dev/null; then say "app did not exit; aborting"; exit 1; fi',
|
||||
'hdiutil detach "$MNT" -quiet 2>/dev/null',
|
||||
'if ! hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MNT"; then say "could not mount"; open -a "$APP"; exit 1; fi',
|
||||
'NEW="$MNT/Church Translate.app"',
|
||||
'# refuse to touch the installed app unless the replacement is really there',
|
||||
'if [ ! -f "$NEW/Contents/Info.plist" ]; then say "disk image has no app"; hdiutil detach "$MNT" -quiet; open -a "$APP"; exit 1; fi',
|
||||
'rm -rf "$ASIDE"',
|
||||
'if ! mv "$APP" "$ASIDE"; then say "could not move the old app aside"; hdiutil detach "$MNT" -quiet; open -a "$APP"; exit 1; fi',
|
||||
'if cp -R "$NEW" "$APP"; then',
|
||||
' xattr -dr com.apple.quarantine "$APP" 2>/dev/null',
|
||||
' say "installed $( /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP/Contents/Info.plist" 2>/dev/null )"',
|
||||
' rm -rf "$ASIDE"',
|
||||
'else',
|
||||
' say "copy failed — putting the old app back"',
|
||||
' rm -rf "$APP"; mv "$ASIDE" "$APP"',
|
||||
'fi',
|
||||
'hdiutil detach "$MNT" -quiet 2>/dev/null',
|
||||
'open -a "$APP"',
|
||||
'say "relaunched"',
|
||||
'rm -f "$0"'
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp, script, { mode: 0o755 });
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'Could not prepare the installer: ' + e.message };
|
||||
}
|
||||
|
||||
/* Detached and unref'd, or it dies with us the moment we quit. */
|
||||
try {
|
||||
const child = spawn('/bin/bash', [tmp], { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'Could not start the installer: ' + e.message };
|
||||
}
|
||||
|
||||
/* Give the renderer a moment to show the message before we go. */
|
||||
forceQuit = true;
|
||||
setTimeout(() => { try { app.quit(); } catch (_) {} }, 600);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('update:reveal', (_e, p2) => { try { shell.showItemInFolder(String(p2)); return true; } catch (_) { return false; } });
|
||||
|
||||
ipcMain.handle('config:get', () => loadConfig());
|
||||
ipcMain.handle('config:set', (_e, patch) => {
|
||||
const merged = saveConfig(patch);
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
"name": "church-translate-engine",
|
||||
"productName": "Church Translate",
|
||||
"version": "3.23.2",
|
||||
"buildNumber": 112,
|
||||
"buildNumber": 127,
|
||||
"private": true,
|
||||
"description": "Live sermon translation — self-contained: captures audio, transcribes, translates, and serves the congregation page itself",
|
||||
"main": "main.js",
|
||||
@@ -50,6 +50,7 @@
|
||||
"vendor/ort-wasm-simd-threaded.jsep.mjs",
|
||||
"server/**/*",
|
||||
"tts-service.js",
|
||||
"voice-install.js",
|
||||
"kokoro-sidecar.js",
|
||||
"voices.js",
|
||||
"pt-variants.js",
|
||||
@@ -106,7 +107,7 @@
|
||||
]
|
||||
},
|
||||
"asar": false,
|
||||
"buildVersion": "112",
|
||||
"buildVersion": "127",
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
|
||||
@@ -21,6 +21,11 @@ contextBridge.exposeInMainWorld('engineHost', {
|
||||
|
||||
/* Translation lives in the main process; these are its only two verbs. */
|
||||
mtEnsure: () => ipcRenderer.invoke('mt:ensure'),
|
||||
updateCheck: () => ipcRenderer.invoke('update:check'),
|
||||
updateDownload: (u, n) => ipcRenderer.invoke('update:download', u, n),
|
||||
updateReveal: (p) => ipcRenderer.invoke('update:reveal', p),
|
||||
updateInstall: (p) => ipcRenderer.invoke('update:install', p),
|
||||
onUpdateProgress: (fn) => ipcRenderer.on('update:progress', (_e, p) => fn(p)),
|
||||
settingsExport: () => ipcRenderer.invoke('settings:export'),
|
||||
settingsImport: () => ipcRenderer.invoke('settings:import'),
|
||||
audioDevices: () => ipcRenderer.invoke('audio:devices'),
|
||||
|
||||
@@ -219,7 +219,21 @@ class LanguageStream {
|
||||
this.dropTimes = []; // when, for a "recently" view on the operator page
|
||||
this.building = []; // frames of the segment being filled
|
||||
this.segments = []; // { seq, body, frames }
|
||||
this.nextSeq = 1;
|
||||
/*
|
||||
* SEGMENT NUMBERS MUST NEVER REPEAT, and starting at 1 made them repeat.
|
||||
* A stream object is built when a language's first listener arrives and
|
||||
* dropped when the last one leaves, so within one service the same
|
||||
* language can get a fresh stream several times — and a phone that came
|
||||
* back from the lock screen, or a second listener, asked for
|
||||
* spa_Latn/1.aac again. The segments are served 'max-age=60, immutable',
|
||||
* which is the right header for a segment that is never reused and the
|
||||
* wrong one for a number that is: for up to a minute the browser answered
|
||||
* from its cache with the PREVIOUS stream's audio under the new number —
|
||||
* a sentence from earlier in the sermon, replayed. Numbering from the
|
||||
* clock makes every incarnation's numbers new; HLS only asks that they
|
||||
* increase by one within a playlist, which they still do.
|
||||
*/
|
||||
this.nextSeq = Date.now();
|
||||
this.timer = null;
|
||||
this.lastTick = 0;
|
||||
this.carry = 0; // fractional frames owed from the last tick
|
||||
|
||||
+113
-14
@@ -163,10 +163,21 @@
|
||||
function renderFeatured() {
|
||||
var wrap = $('featured');
|
||||
wrap.textContent = '';
|
||||
/*
|
||||
* THE OPERATOR'S LIST IS THE LIST. This used to unshift the source
|
||||
* language in unconditionally, on the reasoning that plenty of people want
|
||||
* the English captions for accessibility rather than translation — which
|
||||
* is true, and still available, but it is not this code's call to make.
|
||||
* The operator has a switch for every language including English, and for
|
||||
* the source language alone that switch did nothing: English was put back
|
||||
* on every render, so a desk that had deliberately turned it off still
|
||||
* offered it as the first button on the page. A control that silently
|
||||
* refuses is worse than no control.
|
||||
*
|
||||
* Wanting English captions is now one click in Settings, in the same list
|
||||
* as every other language, and it stays where it is put.
|
||||
*/
|
||||
var list = (app.cfg.featured || []).slice();
|
||||
/* The source language belongs in the list too — plenty of people want the
|
||||
English captions for accessibility, not translation. */
|
||||
if (list.indexOf(app.cfg.sourceLanguage) === -1) list.unshift(app.cfg.sourceLanguage);
|
||||
list.forEach(function (code) {
|
||||
var b = langButton(code);
|
||||
if (b) wrap.appendChild(b);
|
||||
@@ -464,6 +475,7 @@
|
||||
drift: null,
|
||||
watchdog: null, // catches engines that stall on HLS without erroring
|
||||
everPlayed: false, // this engine has PROVEN it can play HLS
|
||||
clipsForVoice: false, // on clips because of an alternate voice, not because HLS failed
|
||||
// clip-fallback state
|
||||
ctx: null,
|
||||
keepAlive: null,
|
||||
@@ -508,7 +520,16 @@
|
||||
/* After any interruption the player resumes where it left off and stays
|
||||
behind live forever — no error, no stall, just permanently late. Every
|
||||
one of these events is a chance to catch back up. */
|
||||
['playing', 'stalled', 'waiting', 'canplay', 'loadedmetadata'].forEach(function (ev) {
|
||||
/*
|
||||
* 'stalled' and 'waiting' are deliberately NOT in this list. A seek on a
|
||||
* live HLS stream makes the element re-buffer, which raises exactly those
|
||||
* two events — so seeking from them is a loop that feeds itself: seek,
|
||||
* stall, seek, stall. On Safari, where HLS is played natively and a seek
|
||||
* lands on a segment boundary, each turn of that loop replays or skips
|
||||
* part of a sentence. That is the "it repeats a bit two or three times
|
||||
* then moves on" reported from iPhones.
|
||||
*/
|
||||
['playing', 'canplay', 'loadedmetadata'].forEach(function (ev) {
|
||||
a.addEventListener(ev, function () { seekLive(); });
|
||||
});
|
||||
|
||||
@@ -574,9 +595,13 @@
|
||||
/* Give up on streaming and use per-sentence clips. The clip path was already
|
||||
unlocked inside the tap precisely so this is possible after the gesture has
|
||||
been spent — otherwise a browser that lies about HLS would end up silent. */
|
||||
function fallBackToClips() {
|
||||
function fallBackToClips(forVoice) {
|
||||
if (srv.mode === 'clips') return;
|
||||
srv.mode = 'clips';
|
||||
/* Remember WHY. Only a page that left the stream for an alternate voice
|
||||
returns to it when the listener goes back to the default — see
|
||||
applyVoiceMode(). */
|
||||
srv.clipsForVoice = !!forVoice;
|
||||
stopStream();
|
||||
if (srv.keepAlive) srv.keepAlive.play().catch(function () {});
|
||||
try { console.warn('live stream unsupported here — using per-sentence clips'); } catch (_) {}
|
||||
@@ -588,8 +613,28 @@
|
||||
* and 4s of tolerance plus a 5s check interval meant real drift could reach
|
||||
* nine seconds before anyone looked.
|
||||
*/
|
||||
var DRIFT_MAX = 1.5;
|
||||
var DRIFT_LAND = 0.6;
|
||||
/*
|
||||
* DRIFT_LAND WAS 0.6 S, AND ONE SEGMENT IS 0.697 S.
|
||||
*
|
||||
* So the target sat INSIDE the newest segment — a point the player very
|
||||
* often did not have yet. It would be dragged up to the edge, run out of
|
||||
* audio, stall, and the stall would trigger another correction. A phone
|
||||
* cannot hold a position closer to live than the segments arriving.
|
||||
*
|
||||
* Three segments back (~2.1 s) is what HLS players choose for themselves on
|
||||
* a live playlist, and it is the smallest target with enough buffered audio
|
||||
* to survive one late segment. The tolerance is wider than one segment too,
|
||||
* so an ordinary arrival wobble is not read as drift.
|
||||
*
|
||||
* It costs about a second and a half of extra delay. A listener a second
|
||||
* further behind is following the service; a listener whose audio keeps
|
||||
* jumping backwards is not.
|
||||
*/
|
||||
var DRIFT_MAX = 3.0;
|
||||
var DRIFT_LAND = 2.1;
|
||||
/* A correction needs time to settle before its own result is judged. */
|
||||
var SEEK_COOLDOWN_MS = 5000;
|
||||
var lastSeekAt = 0;
|
||||
|
||||
/*
|
||||
* How far behind the live edge this listener is SUPPOSED to be.
|
||||
@@ -616,6 +661,11 @@
|
||||
var want = driftTarget();
|
||||
var behind = (edge - a.currentTime) - want; // + = too late, - = too early
|
||||
if (!force && Math.abs(behind) <= DRIFT_MAX) return;
|
||||
/* One correction at a time. Without this, timeupdate (four times a
|
||||
second) can fire a fresh seek while the last one is still buffering,
|
||||
and the element never gets a chance to settle anywhere. */
|
||||
if (!force && Date.now() - lastSeekAt < SEEK_COOLDOWN_MS) return;
|
||||
lastSeekAt = Date.now();
|
||||
/*
|
||||
* Land just BEHIND the target, never on the edge. Seeking exactly to the
|
||||
* live edge leaves no buffered audio at all, so the phone stalls on the
|
||||
@@ -823,14 +873,27 @@
|
||||
*/
|
||||
function applyVoiceMode() {
|
||||
if (srv.voice) {
|
||||
fallBackToClips();
|
||||
fallBackToClips(true);
|
||||
return;
|
||||
}
|
||||
/* Back on the default: allow streaming again. fallBackToClips latches
|
||||
mode to 'clips' and maybeStartStream refuses to act on that, so the
|
||||
latch has to be released here or the listener stays on clips for the
|
||||
rest of the service. */
|
||||
if (srv.mode === 'clips' && hlsSupported() && srv.unlocked && srv.serverHello) {
|
||||
/*
|
||||
* Only when the alternate is what put us on clips. Every engine reconnect
|
||||
* sends every listener a 'voices' frame, and an engine that cannot play
|
||||
* HLS used to be handed back to the stream by it — eight seconds of
|
||||
* dropped clips until the watchdog demoted it again.
|
||||
*
|
||||
* And silence the alternate's clips that are still scheduled. Left to
|
||||
* play out they overlapped the shared stream as it came up: the voice
|
||||
* the listener left and the default together, for up to the six seconds
|
||||
* the clip queue holds.
|
||||
*/
|
||||
if (srv.mode === 'clips' && srv.clipsForVoice && hlsSupported() && srv.unlocked && srv.serverHello) {
|
||||
stopClips();
|
||||
srv.clipsForVoice = false;
|
||||
srv.mode = '';
|
||||
maybeStartStream();
|
||||
}
|
||||
@@ -864,22 +927,52 @@
|
||||
function switchAudioLanguage(lang) {
|
||||
stopClips(); // the old language stops NOW, mid-word
|
||||
if (srv.mode === 'stream' && srv.el) {
|
||||
/* Pause the old stream; the new one starts when the server's hello
|
||||
confirms the new language's stream exists. */
|
||||
try { srv.el.pause(); } catch (_) {}
|
||||
if (srv.retry) { clearTimeout(srv.retry); srv.retry = null; }
|
||||
/*
|
||||
* STOP the old stream, do not merely pause it; the new one starts when
|
||||
* the server's hello confirms the new language's stream exists.
|
||||
*
|
||||
* Paused, the element still pointed at the OLD playlist until that
|
||||
* hello, and three things restart a paused element without asking: the
|
||||
* 8 s watchdog's retry (startStream(srv.lang, true), and srv.lang was
|
||||
* still the old language), coming back from a locked screen
|
||||
* (visibilitychange plays it), and the lock-screen play button. Each
|
||||
* put the previous language's voice back in the listener's ear.
|
||||
* stopStream() is what the back button already does, and the hello's
|
||||
* maybeStartStream() brings the new language up from it.
|
||||
*/
|
||||
stopStream();
|
||||
}
|
||||
setMediaSession();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- per-clip fallback path
|
||||
|
||||
/*
|
||||
* Is this clip for the language AND voice this page is on right now?
|
||||
*
|
||||
* Asked twice — when the clip is announced and again when its bytes land —
|
||||
* because the fetch and the decode take a few hundred milliseconds, and
|
||||
* stopClips() can only silence what is already scheduled, not what is still
|
||||
* downloading. A listener who switched language in that window heard the
|
||||
* previous language's sentence AFTER the switch; for Spanish that is one
|
||||
* sentence of the Mexican voice in a Spaniard's ear, or the reverse. The
|
||||
* name carries the language (p<seq>-spa_ES.aac); the server also sends lang
|
||||
* and voice in the frame, and a frame without them is judged on the name.
|
||||
*/
|
||||
function clipIsOurs(frame) {
|
||||
var lang = frame.lang || (String(frame.name).match(/-([a-z]{3}_[A-Za-z]+)\.aac$/) || [])[1];
|
||||
if (lang !== app.lang) return false;
|
||||
if ('voice' in frame && String(frame.voice || '') !== String(srv.voice || '')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Fetch, decode and schedule one clip so it follows the previous one. Only
|
||||
used where native HLS is missing. */
|
||||
function playClip(frame) {
|
||||
var name = frame && frame.name;
|
||||
if (srv.mode === 'stream') return; // the stream already carries it
|
||||
if (!tts.on || !srv.ctx || !name) return;
|
||||
if (!clipIsOurs(frame)) return;
|
||||
|
||||
fetch('/audio/' + encodeURIComponent(name))
|
||||
.then(function (r) { return r.ok ? r.arrayBuffer() : Promise.reject(new Error('missing')); })
|
||||
@@ -891,6 +984,8 @@
|
||||
})
|
||||
.then(function (audio) {
|
||||
if (!tts.on || !srv.ctx) return;
|
||||
/* Landed after a switch of language, voice or mode: not ours any more. */
|
||||
if (srv.mode === 'stream' || !clipIsOurs(frame)) return;
|
||||
var src = srv.ctx.createBufferSource();
|
||||
src.buffer = audio;
|
||||
src.connect(srv.ctx.destination);
|
||||
@@ -1379,7 +1474,11 @@
|
||||
}
|
||||
/* The playlist is guaranteed to exist from this moment — the server
|
||||
creates the stream when this listener registers. */
|
||||
maybeStartStream();
|
||||
/* Not for a listener on an alternate: the shared stream is the
|
||||
operator's default voice, and starting it here played that voice
|
||||
until the server's 'voices' reply moved the page to clips. */
|
||||
if (srv.voice) fallBackToClips(true);
|
||||
else maybeStartStream();
|
||||
/* The picker was rendered before this socket existed, when we still had
|
||||
to assume the device's own voice. Now we know better — re-render, or
|
||||
a listener keeps staring at a voice chooser that controls nothing. */
|
||||
|
||||
+83
-11
@@ -500,16 +500,57 @@ const ENGINE_STALE_MS = 12000;
|
||||
* with one plain sentence does. Computed here so every operator surface —
|
||||
* in-app pane, browser page — shows the same verdict.
|
||||
*/
|
||||
/*
|
||||
* WHEN IT IS BEHIND, SAY WHAT IS LOADING IT.
|
||||
*
|
||||
* Translation cost is roughly linear in the number of active languages, through
|
||||
* ONE NLLB process — measured, five languages in 1.9 s. So the single most
|
||||
* useful fact about a machine that is falling behind is how many languages it
|
||||
* is carrying, and that number was computed one function away and never shown.
|
||||
* A campus reported "constantly behind, in the red" and the cause took an
|
||||
* afternoon to find; it was eight languages on hardware tuned against one.
|
||||
*/
|
||||
/* How long a stumble keeps colouring the verdict. Long enough that a real
|
||||
problem stays visible while someone walks to the desk, short enough that a
|
||||
one-off does not outlive the sentence after it. */
|
||||
const TROUBLE_WINDOW_MS = 120000;
|
||||
|
||||
function loadNote() {
|
||||
const n = activeLanguages().length;
|
||||
if (n < 2) return '';
|
||||
return ' Carrying ' + n + ' languages' +
|
||||
(config.allowAllLanguages ? ', and listeners may add more' : '') + '.';
|
||||
}
|
||||
|
||||
function healthVerdict(connected, stale) {
|
||||
const es = state.engineStatus || {};
|
||||
if (!connected) return { level: 'red', detail: 'The translation app is not connected.' };
|
||||
if (stale) return { level: 'red', detail: 'The translation app has stopped responding.' };
|
||||
if (!state.live) return { level: 'green', detail: 'Ready.' };
|
||||
if (!es.modelsReady) return { level: 'amber', detail: 'Models are still loading.' };
|
||||
if ((es.lagMs || 0) > 12000 || (es.captionOnlyFinals || 0) > 0)
|
||||
return { level: 'red', detail: 'Falling badly behind — some sentences are captions only.' };
|
||||
if ((es.lagMs || 0) > 6000 || (es.queue || 0) >= 2 || (es.degradedFinals || 0) > 0)
|
||||
return { level: 'amber', detail: 'Running behind — idle languages may be trimmed to catch up.' };
|
||||
/*
|
||||
* RECENTLY, not ever.
|
||||
*
|
||||
* captionOnlyFinals is a running total for the whole service, and testing it
|
||||
* for "more than zero" meant a single stale sentence pinned the desk's light
|
||||
* red until the next restart. MEASURED: a campus Mac took one 16 s stall at
|
||||
* 14:25 — caused by two Screen Sharing sessions competing for the machine —
|
||||
* recovered immediately to a 421 ms lag with an empty queue, and still showed
|
||||
* red half an hour later. An operator who looks up and sees red wants to know
|
||||
* what is wrong NOW; a light that cannot go back to green teaches them to
|
||||
* ignore it.
|
||||
*
|
||||
* So the totals still travel, for the record and for the operator page, but
|
||||
* the verdict asks when it last happened.
|
||||
*/
|
||||
const recentCaptionOnly = es.captionOnlyAt && (Date.now() - es.captionOnlyAt) < TROUBLE_WINDOW_MS;
|
||||
if ((es.lagMs || 0) > 12000 || recentCaptionOnly)
|
||||
return { level: 'red',
|
||||
detail: 'Falling badly behind — some sentences are captions only.' + loadNote() };
|
||||
const recentDegraded = es.degradedAt && (Date.now() - es.degradedAt) < TROUBLE_WINDOW_MS;
|
||||
if ((es.lagMs || 0) > 6000 || (es.queue || 0) >= 2 || recentDegraded)
|
||||
return { level: 'amber',
|
||||
detail: 'Running behind — idle languages may be trimmed to catch up.' + loadNote() };
|
||||
if (es.cloud && es.cloud.parked)
|
||||
return { level: 'amber', detail: 'Natural voices are down — the built-in voices have taken over.' };
|
||||
/* A fallback in the last minute means one language just changed voice
|
||||
@@ -1076,7 +1117,22 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
if (req.method === 'OPTIONS') { res.writeHead(origin ? 204 : 405).end(); return; }
|
||||
|
||||
if (p === '/healthz') { sendJSON(res, 200, { ok: true, live: state.live, build: BUILD }); return; }
|
||||
/*
|
||||
* The APP's version, plainly, to anyone who can reach this port.
|
||||
*
|
||||
* `build` here has always been a hash of the listener-page assets — a
|
||||
* cache-busting token, not a version — so the only way to tell what a campus
|
||||
* Mac was running was to install something and watch the hash move. With an
|
||||
* updater in the field that is the first question anyone asks, so answer it.
|
||||
*/
|
||||
if (p === '/healthz') {
|
||||
sendJSON(res, 200, {
|
||||
ok: true, live: state.live, build: BUILD,
|
||||
version: process.env.CT_APP_VERSION || '',
|
||||
appBuild: process.env.CT_APP_BUILD || ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Companion / Stream Deck control -------------------------------
|
||||
// GET or POST, token in ?key= or the X-Control-Token header.
|
||||
@@ -1707,12 +1763,20 @@ wss.on('connection', (ws, req) => {
|
||||
if (!m.voice) row.audio[m.lang] = m.name;
|
||||
|
||||
delayed((who) => {
|
||||
const frame = JSON.stringify({
|
||||
t: 'audio', id: row.id, name: m.name, seconds: m.seconds || 0
|
||||
});
|
||||
/* Which voice this clip IS. '' is the operator's default, which is
|
||||
what a listener who never opened the chooser is on. */
|
||||
const clipVoice = String(m.voice || '');
|
||||
/*
|
||||
* The frame says which language and voice it carries, and the page
|
||||
* checks both again when the clip's bytes land (clipIsOurs() in
|
||||
* listen.js). The routing below is decided at SEND time; a listener
|
||||
* who switches language or voice while a clip is still downloading
|
||||
* would otherwise hear it after the switch, in the voice they left.
|
||||
*/
|
||||
const frame = JSON.stringify({
|
||||
t: 'audio', id: row.id, name: m.name, seconds: m.seconds || 0,
|
||||
lang: m.lang, voice: clipVoice
|
||||
});
|
||||
for (const [ws, meta] of state.listeners) {
|
||||
if (ws.readyState !== 1) continue;
|
||||
if (!who(meta)) continue;
|
||||
@@ -1998,14 +2062,22 @@ wss.on('connection', (ws, req) => {
|
||||
if (m.t === 'lang') {
|
||||
const meta = state.listeners.get(ws);
|
||||
if (!meta) return;
|
||||
meta.lang = String(m.lang || config.sourceLanguage);
|
||||
const next = String(m.lang || config.sourceLanguage);
|
||||
/*
|
||||
* A voice belongs to a language. Carrying a Spanish speaker across to
|
||||
* Portuguese would ask the engine for a voice that language does not
|
||||
* have, so switching language always returns the listener to the
|
||||
* default and hands them the new language's options.
|
||||
*
|
||||
* Only a CHANGE of language, though. The page re-sends the language it
|
||||
* is already on to have its captions re-rendered (the original-text
|
||||
* toggle does exactly that), and resetting the voice there put a
|
||||
* listener on an alternate back on the default for a round trip —
|
||||
* until the page re-asserted its pick, they were sent the default
|
||||
* voice's clips: one voice, then the other, then theirs again.
|
||||
*/
|
||||
meta.voice = '';
|
||||
if (next !== meta.lang) meta.voice = '';
|
||||
meta.lang = next;
|
||||
pushLanguagesToEngine();
|
||||
/*
|
||||
* builtInVoice MUST be here, not just on the first hello. The page does
|
||||
@@ -2017,7 +2089,7 @@ wss.on('connection', (ws, req) => {
|
||||
*/
|
||||
send(ws, { t: 'hello', live: state.live, lang: meta.lang, serviceName: config.serviceName, build: BUILD,
|
||||
builtInVoice: Voices.hasVoice(meta.lang),
|
||||
voiceOptions: voiceOptionsFor(meta.lang), voice: '',
|
||||
voiceOptions: voiceOptionsFor(meta.lang), voice: meta.voice || '',
|
||||
scrollback: scrollbackFor(meta.lang, meta.proxied) });
|
||||
broadcastOperators();
|
||||
}
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Church Translate — one-shot diagnostic for "it is behind / in the red".
|
||||
# Read-only: reads the log and asks the running app for its status. Changes nothing.
|
||||
L="$HOME/Library/Application Support/Church Translate/data/logs/app.log"
|
||||
C="$HOME/Library/Application Support/Church Translate/data/config.json"
|
||||
|
||||
echo "=== MACHINE ==="
|
||||
system_profiler SPHardwareDataType 2>/dev/null | grep -E "Model Name|Chip|Total Number of Cores|Memory:" | sed 's/^ */ /'
|
||||
echo " macOS $(sw_vers -productVersion)"
|
||||
|
||||
echo
|
||||
echo "=== MEMORY PRESSURE / SWAP ==="
|
||||
memory_pressure 2>/dev/null | grep -Ei "free percentage|pageouts" | sed 's/^/ /'
|
||||
sysctl -n vm.swapusage 2>/dev/null | sed 's/^/ swap: /'
|
||||
|
||||
echo
|
||||
echo "=== WHAT THE APP CHOSE AT STARTUP ==="
|
||||
grep -E "GPU features \(speech\)|no GPU acceleration|no fp16|loading the speech model|loading the translation model|speech model ready|translation model ready|models could not load" "$L" 2>/dev/null | tail -8 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "=== THROUGHPUT — is it slower than people talk? ==="
|
||||
echo " slow-sentence events (>4s): $(grep -c 'slow sentence' "$L" 2>/dev/null)"
|
||||
grep "slow sentence" "$L" 2>/dev/null | tail -12 | sed 's/^/ /'
|
||||
grep -E "stepping down to a lighter" "$L" 2>/dev/null | tail -3 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "=== THE RED CONDITION ==="
|
||||
echo " caption-only events: $(grep -c 'caption only' "$L" 2>/dev/null)"
|
||||
grep -E "caption only|stale — caption only" "$L" 2>/dev/null | tail -6 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "=== LIVE STATUS ==="
|
||||
TOK=$(grep -o '"controlToken"[[:space:]]*:[[:space:]]*"[^"]*"' "$C" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
curl -s -m 5 "http://127.0.0.1:3100/api/control/status?key=$TOK" 2>/dev/null | head -c 1500
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "=== RECENT ERRORS ==="
|
||||
grep "\[app\] err" "$L" 2>/dev/null | grep -v "natural voice fell back" | tail -12 | sed 's/^/ /'
|
||||
+171
-82
@@ -42,9 +42,6 @@ let VOICE_DIR = process.platform === 'win32'
|
||||
: path.join(os.homedir(), 'Library/Application Support/Church Translate Voices');
|
||||
let CLIP_DIR = path.join(os.tmpdir(), 'church-translate-audio');
|
||||
|
||||
/* Loaded engines, one per language. Kept small: each costs ~330-400 MB of ONNX
|
||||
arena regardless of the 63 MB model, so we do not hold voices nobody is
|
||||
listening to. */
|
||||
/* What each voice says when auditioned — a real line of scripture, so the
|
||||
operator hears the voice on the material it will actually read. */
|
||||
const SAMPLE_TEXT = {
|
||||
@@ -65,8 +62,25 @@ const SAMPLE_TEXT = {
|
||||
hat_Latn: 'Senyè a se gadò mwen, mwen p ap manke anyen.'
|
||||
};
|
||||
|
||||
const loaded = new Map();
|
||||
const MAX_LOADED = 6;
|
||||
/*
|
||||
* Loaded engines, ONE PER BUNDLE, shared by every language, variant and
|
||||
* audition that speaks with it. Kept bounded, not small: MEASURED on an M2
|
||||
* Max, an engine costs ~75 MB at construction and ~165 MB once it has spoken
|
||||
* (8 engines, 1.3 GB after one sentence each), not the 330-400 MB this used
|
||||
* to assume. The cap is sized to the largest campus — eight languages — so a
|
||||
* live engine is never evicted to make room for another live one in normal
|
||||
* service; below the cap an eviction means a reload of ~0.3-0.5 s on this
|
||||
* process's only thread, in front of the next sentence, every time.
|
||||
*
|
||||
* `live` marks an engine a sentence or the preload asked for. Auditions keep
|
||||
* to MAX_AUDITION slots of their own, always go first, and can never push a
|
||||
* live engine out: with every slot speaking, an auditioned voice is built,
|
||||
* played once and dropped. See engineFor() for why this is keyed by bundle.
|
||||
*/
|
||||
const loaded = new Map(); // 'bundle/model' -> { tts, usedAt, live }
|
||||
const installing = new Map(); // bundle -> Promise: one download, however many ask
|
||||
const MAX_LOADED = 8;
|
||||
const MAX_AUDITION = 2;
|
||||
|
||||
/*
|
||||
* The operator's per-language voice choices, made by ear in the Voices
|
||||
@@ -142,81 +156,120 @@ function isInstalled(v) {
|
||||
* 359 files, same total bytes — and it is now the same code on both platforms.
|
||||
*/
|
||||
function installVoice(v) {
|
||||
/*
|
||||
* OFF THIS THREAD. The fetch, the bzip2 and the unpack all ran here, on the
|
||||
* voice process's only thread, from inside engineFor() — so the first
|
||||
* sentence of a language whose voice was not installed yet stalled every
|
||||
* OTHER language's sentences for as long as the install took. MEASURED on a
|
||||
* campus Mac, 2026-10-15 15:11: a listener tapped Chinese, the 121 MB zh
|
||||
* model was fetched and unpacked three times at once on this thread, every
|
||||
* stream stalled, 26 sentences were dropped and the catch-up rushed the
|
||||
* Spanish voices to their +16% cap. The work now happens in a worker thread
|
||||
* (voice-install.js); this thread only relays its progress and keeps
|
||||
* speaking. The worker unpacks into a staging folder and renames it into
|
||||
* place whole, so isInstalled() never sees a half-written bundle.
|
||||
*/
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(VOICE_DIR, { recursive: true });
|
||||
const url = voices.bundleUrl(v.bundle);
|
||||
|
||||
const fail = (why) => reject(new Error('could not install ' + v.bundle + ': ' + why));
|
||||
reply({ type: 'voiceProgress', bundle: v.bundle, status: 'downloading' });
|
||||
|
||||
(async () => {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
/* A proxy or a captive portal returns an HTML error page with HTTP 200;
|
||||
the bzip2 magic is the cheapest way to notice before unpacking it. */
|
||||
if (body.length < 1024 || body.toString('latin1', 0, 3) !== 'BZh') {
|
||||
throw new Error('that download was not a voice bundle');
|
||||
}
|
||||
reply({ type: 'voiceProgress', bundle: v.bundle, status: 'installing' });
|
||||
|
||||
const bunzip = require('unbzip2-stream');
|
||||
const tar = require('tar-stream');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
await new Promise((res2, rej2) => {
|
||||
const extract = tar.extract();
|
||||
extract.on('entry', (header, stream, next) => {
|
||||
/* Never write outside VOICE_DIR, whatever the archive claims. */
|
||||
const dest = path.resolve(VOICE_DIR, header.name);
|
||||
if (dest !== VOICE_DIR && !dest.startsWith(VOICE_DIR + path.sep)) {
|
||||
stream.resume(); return next();
|
||||
}
|
||||
if (header.type === 'directory') {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
stream.resume(); return next();
|
||||
}
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
stream.pipe(fs.createWriteStream(dest)).on('finish', next).on('error', rej2);
|
||||
let w;
|
||||
try {
|
||||
const { Worker } = require('worker_threads');
|
||||
w = new Worker(path.join(__dirname, 'voice-install.js'), {
|
||||
workerData: { url: voices.bundleUrl(v.bundle), voiceDir: VOICE_DIR, bundle: v.bundle }
|
||||
});
|
||||
extract.on('finish', res2);
|
||||
extract.on('error', rej2);
|
||||
Readable.from(body).pipe(bunzip()).on('error', rej2).pipe(extract);
|
||||
} catch (e) { return fail(e.message); }
|
||||
let settled = false;
|
||||
const done = (err) => { if (settled) return; settled = true; err ? fail(err) : resolve(); };
|
||||
w.on('message', (m) => {
|
||||
if (!m || !m.status) return;
|
||||
if (m.status === 'error') { done(m.message || 'the installer failed'); return; }
|
||||
reply({ type: 'voiceProgress', bundle: v.bundle, status: m.status });
|
||||
if (m.status === 'ready') done();
|
||||
});
|
||||
|
||||
reply({ type: 'voiceProgress', bundle: v.bundle, status: 'ready' });
|
||||
})().then(resolve, (err) => reject(new Error('could not install ' + v.bundle + ': ' + err.message)));
|
||||
w.on('error', (e) => done((e && e.message) || String(e)));
|
||||
w.on('exit', (code) => { if (!settled) done('the installer exited (' + code + ')'); });
|
||||
});
|
||||
}
|
||||
|
||||
async function engineFor(lang, explicit) {
|
||||
/* Auditions key the cache by bundle so trying five voices for one language
|
||||
does not evict the voice that language is actually using. */
|
||||
const v = explicit || builtinVoiceFor(lang);
|
||||
const key = explicit ? "audition:" + v.bundle : lang;
|
||||
if (loaded.has(key)) {
|
||||
const e = loaded.get(key);
|
||||
/*
|
||||
* The engine for one voice, built at most once however many ask for it.
|
||||
*
|
||||
* KEYED BY BUNDLE — and the old keying is what put batches of four
|
||||
* "audition:" loads in the production log with nobody at the Voices card.
|
||||
* Engines were keyed two ways: by language ('spa_Latn') for the preload, and
|
||||
* 'audition:<bundle>' whenever a voice was passed in, which speak() did for
|
||||
* EVERY ordinary sentence because the resolved default always has a bundle.
|
||||
* So the live path was filed as an audition, every bundle was built at least
|
||||
* twice (five languages asked for nine engines against a cap of six), the
|
||||
* preload warmed copies no sentence ever read, and the first sentence of
|
||||
* every service built the real ones on the spot. MEASURED on the Mini,
|
||||
* 2026-10-15: the 14:26 service's first sentence was said at 14:54:26.291
|
||||
* and its four engines were ready at 26.995, 27.309, 27.833 and 28.344 —
|
||||
* ~0.3-0.5 s each on this process's only thread — while what got evicted
|
||||
* was the startup preload's unused copies.
|
||||
*
|
||||
* The engine's config is a function of the bundle alone (voices.ttsConfig;
|
||||
* speed, gain and sid are applied per sentence), so sharing is exact:
|
||||
* spa_Latn and spa_MX read one es_MX-ald engine, and auditioning a voice that
|
||||
* is already speaking reads the live engine instead of building a second.
|
||||
*/
|
||||
async function engineFor(v, live, who) {
|
||||
if (!v || !v.bundle) throw new Error('no built-in voice' + (who ? ' for ' + who : ''));
|
||||
const key = v.bundle + '/' + (v.model || '');
|
||||
|
||||
/*
|
||||
* One download per bundle. A language joining mid-service is asked for by
|
||||
* the preload AND by its first sentences at once, and each used to fetch
|
||||
* and unpack the whole bundle itself — MEASURED on the Mini: pt_BR-jeff
|
||||
* three times between 11:39:05 and 11:39:50, zh-ll three times between
|
||||
* 15:11:17 and 15:12:16, two 'voice timed out' each time. The unpack is staged and
|
||||
* renamed into place whole (voice-install.js), so isInstalled() stays false
|
||||
* until the bundle is complete — but a second asker must still wait on the
|
||||
* first install, not start its own.
|
||||
*/
|
||||
if (!loaded.has(key) && (installing.has(v.bundle) || !isInstalled(v))) {
|
||||
if (!installing.has(v.bundle)) {
|
||||
installing.set(v.bundle, installVoice(v).finally(() => installing.delete(v.bundle)));
|
||||
}
|
||||
await installing.get(v.bundle);
|
||||
}
|
||||
|
||||
/* Looked up again: whoever finished waiting first has built it. Nothing can
|
||||
interleave from here to the set — construction is synchronous. */
|
||||
let e = loaded.get(key);
|
||||
if (!e) {
|
||||
if (!sherpa) sherpa = require('sherpa-onnx-node');
|
||||
e = { tts: new sherpa.OfflineTts(voices.ttsConfig(voicePath(v), v)), usedAt: 0, live: !!live };
|
||||
if (makeRoom(!!live)) loaded.set(key, e);
|
||||
log('voice ready: ' + v.bundle + (who ? ' for ' + who : '') +
|
||||
(live ? '' : loaded.get(key) === e ? ' (audition)' : ' (audition, not kept: every slot is speaking)'));
|
||||
}
|
||||
e.usedAt = Date.now();
|
||||
/* An audition never demotes a live engine; a sentence promotes an auditioned one. */
|
||||
if (live) e.live = true;
|
||||
return e.tts;
|
||||
}
|
||||
|
||||
if (!v) throw new Error('no built-in voice for ' + lang);
|
||||
if (!isInstalled(v)) await installVoice(v);
|
||||
|
||||
if (!sherpa) sherpa = require('sherpa-onnx-node');
|
||||
|
||||
const dir = voicePath(v);
|
||||
const tts = new sherpa.OfflineTts(voices.ttsConfig(dir, v));
|
||||
|
||||
/* Evict the least recently used rather than growing without bound. */
|
||||
if (loaded.size >= MAX_LOADED) {
|
||||
let oldest = null;
|
||||
for (const [k, e] of loaded) if (!oldest || e.usedAt < loaded.get(oldest).usedAt) oldest = k;
|
||||
if (oldest) { loaded.delete(oldest); log('evicted voice ' + oldest); }
|
||||
}
|
||||
|
||||
loaded.set(key, { tts, usedAt: Date.now() });
|
||||
log('voice ready: ' + key + ' (' + v.bundle + ')');
|
||||
return tts;
|
||||
/*
|
||||
* Room for one more engine, or false when an audition should not take one.
|
||||
* Least recently used goes first, as before — auditioned engines before live
|
||||
* ones, and a live engine only ever gives way to another live one.
|
||||
*/
|
||||
function makeRoom(live) {
|
||||
const stalest = (wantLive) => {
|
||||
let k = null;
|
||||
for (const [key, x] of loaded) if (x.live === wantLive && (!k || x.usedAt < loaded.get(k).usedAt)) k = key;
|
||||
return k;
|
||||
};
|
||||
const evict = (k) => { log('evicted voice ' + k + (loaded.get(k).live ? '' : ' (audition)')); loaded.delete(k); };
|
||||
const auditions = Array.from(loaded.values()).filter((x) => !x.live).length;
|
||||
if (!live && auditions >= MAX_AUDITION) evict(stalest(false));
|
||||
if (loaded.size < MAX_LOADED) return true;
|
||||
const k = stalest(false) || (live ? stalest(true) : null);
|
||||
if (!k) return false;
|
||||
evict(k);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- cloud voices
|
||||
@@ -928,6 +981,9 @@ async function speak(job) {
|
||||
text, Math.round((Number(job.gainDb) || 0) * 2) / 2, pinRate
|
||||
].join('\u0001');
|
||||
|
||||
/* Set when the leader's result was a Piper fallback in ANOTHER variant's
|
||||
voice — this job then speaks its own fallback, straight to Piper. */
|
||||
let piperOnly = false;
|
||||
if (key) {
|
||||
const hit = synthCache.get(key);
|
||||
if (hit) {
|
||||
@@ -941,8 +997,23 @@ async function speak(job) {
|
||||
counted as one; the key is deleted below so the NEXT utterance
|
||||
tries the natural voice again. */
|
||||
if (premiumPick && done.source !== premiumPick.provider) { cloudStats.fallback++; fallbackAt = Date.now(); }
|
||||
/*
|
||||
* ...but only a fallback in THIS language's own Piper voice. The key
|
||||
* names the PREMIUM voice, and spa_Latn, spa_ES and spa_MX share it
|
||||
* (KOKORO_LANG maps all three to 'e', both Portugueses to 'p'). Their
|
||||
* Piper fallbacks they do not share: spa_ES falls back to davefx,
|
||||
* spa_Latn to ald, por_PT to miro, por_Latn to jeff. Cloning the
|
||||
* leader's fallback put the Mexican voice in the Castilian stream for
|
||||
* that sentence — measured in a harness against this file: spa_ES's
|
||||
* clip was the byte-identical ald file and davefx never even loaded.
|
||||
* So the follower still skips the sidecar that just missed (the
|
||||
* variants still switch together), but in its own voice.
|
||||
*/
|
||||
if (done.source !== 'piper' || done.bundle === (voice.bundle || '')) {
|
||||
return cloneClip(done, job.lang, started);
|
||||
}
|
||||
piperOnly = true;
|
||||
}
|
||||
} catch (_) { /* the first caller failed — make our own */ }
|
||||
}
|
||||
}
|
||||
@@ -956,7 +1027,7 @@ async function speak(job) {
|
||||
* alternate skips the premium path entirely — otherwise cloudSynth would
|
||||
* fall back to the operator's own pick and the page would lie.
|
||||
*/
|
||||
const cloudOut = (alt && alt.engine === 'builtin')
|
||||
const cloudOut = (piperOnly || (alt && alt.engine === 'builtin'))
|
||||
? null
|
||||
: await cloudSynth(job.lang, text, (alt && alt.engine !== 'builtin') ? alt : null, { speed: kokoroSpeed, deadline });
|
||||
if (cloudOut) {
|
||||
@@ -984,13 +1055,16 @@ async function speak(job) {
|
||||
source: cloudOut.provider
|
||||
};
|
||||
}
|
||||
if (!(alt && alt.engine === 'builtin') && cloudVoiceFor(job.lang)) {
|
||||
/* A piperOnly job's fallback was already counted at the cache hit. */
|
||||
if (!piperOnly && !(alt && alt.engine === 'builtin') && cloudVoiceFor(job.lang)) {
|
||||
cloudStats.fallback++;
|
||||
fallbackAt = Date.now();
|
||||
if (cloudStats.fallback === 1 || cloudStats.fallback % 10 === 0) pushStats('fallback');
|
||||
}
|
||||
|
||||
const tts = await engineFor(job.lang, voice.bundle ? voice : null);
|
||||
/* Live: a sentence is speaking with this voice, so no audition may take
|
||||
its engine. A listener's built-in alternate counts — someone is on it. */
|
||||
const tts = await engineFor(voice.bundle ? voice : null, true, job.lang);
|
||||
if (!sherpa) sherpa = require('sherpa-onnx-node');
|
||||
|
||||
/*
|
||||
@@ -1067,6 +1141,9 @@ async function speak(job) {
|
||||
const seconds = secs;
|
||||
return {
|
||||
source: 'piper',
|
||||
/* WHICH Piper voice spoke it. A follower may share a fallback only when
|
||||
it is its own voice — see the cache hit above. */
|
||||
bundle: voice.bundle || '',
|
||||
/* What the sentence ended on — the stream gives a full stop a full breath
|
||||
and a mid-thought fragment only a beat. */
|
||||
terminal: text.slice(-1),
|
||||
@@ -1080,8 +1157,12 @@ async function speak(job) {
|
||||
};
|
||||
};
|
||||
|
||||
/* A piperOnly job is a fallback by construction. It must not stand in the
|
||||
cache as a leader, or the next identical line would inherit the miss
|
||||
without trying the natural voice — the chorus case below. */
|
||||
const cacheKey = piperOnly ? null : key;
|
||||
const promise = produce();
|
||||
if (key) synthCache.set(key, { promise, at: Date.now() });
|
||||
if (cacheKey) synthCache.set(cacheKey, { promise, at: Date.now() });
|
||||
try {
|
||||
const out = await promise;
|
||||
/*
|
||||
@@ -1091,10 +1172,10 @@ async function speak(job) {
|
||||
* miss would have re-created the very voice switch this file exists to
|
||||
* prevent (measured in the harness before shipping, not after).
|
||||
*/
|
||||
if (key && premiumPick && out && out.source !== premiumPick.provider) synthCache.delete(key);
|
||||
if (cacheKey && premiumPick && out && out.source !== premiumPick.provider) synthCache.delete(cacheKey);
|
||||
return out;
|
||||
} catch (e) {
|
||||
if (key) synthCache.delete(key);
|
||||
if (cacheKey) synthCache.delete(cacheKey);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -1187,11 +1268,15 @@ process.on('message', async (msg) => {
|
||||
if (msg.type === 'voices') {
|
||||
/* The operator changed a per-language voice choice. */
|
||||
voiceChoice = msg.choices || {};
|
||||
/* Drop cached engines for languages whose choice changed, so the next
|
||||
sentence uses the new voice rather than the one already resident. */
|
||||
for (const k of Array.from(loaded.keys())) {
|
||||
if (!String(k).startsWith("audition:")) loaded.delete(k);
|
||||
}
|
||||
/*
|
||||
* Nothing to drop. Engines are keyed by bundle, so a changed choice
|
||||
* resolves to a different engine on the next sentence by itself, and
|
||||
* the one it replaced ages out like any other. The loop that was here
|
||||
* only ever deleted the preload's per-language copies (the live path was
|
||||
* filed under 'audition:'); under bundle keys it would delete the live
|
||||
* engines too, and every "Use it" would make the next sentence rebuild
|
||||
* every language's voice.
|
||||
*/
|
||||
reply({ type: 'voicesOk', count: Object.keys(voiceChoice).length });
|
||||
return;
|
||||
}
|
||||
@@ -1222,7 +1307,9 @@ process.on('message', async (msg) => {
|
||||
} finally { voiceChoice = saved; }
|
||||
} else {
|
||||
if (!sherpa) sherpa = require('sherpa-onnx-node');
|
||||
const tts = await engineFor(msg.lang, spec.bundle ? spec : null);
|
||||
/* Not live: shares the engine when this voice is already speaking,
|
||||
otherwise takes an audition slot — never a live one. */
|
||||
const tts = await engineFor(spec.bundle ? spec : builtinVoiceFor(msg.lang), false, 'audition');
|
||||
const audio = tts.generate({ text, sid: spec.sid || 0, speed: spec.speed || 1.0 });
|
||||
if (spec.gain && spec.gain !== 1) {
|
||||
for (let i = 0; i < audio.samples.length; i++) {
|
||||
@@ -1256,7 +1343,9 @@ process.on('message', async (msg) => {
|
||||
/* Warm the languages people are actually listening to, so the first
|
||||
sentence of a service is not the one that pays for the download. */
|
||||
for (const lang of msg.langs || []) {
|
||||
try { await engineFor(lang); } catch (e) { log('preload failed for ' + lang + ': ' + e.message); }
|
||||
/* Keyed exactly as speak() keys it, so this warms the engine the first
|
||||
sentence will read, not a copy beside it. */
|
||||
try { await engineFor(builtinVoiceFor(lang), true, lang); } catch (e) { log('preload failed for ' + lang + ': ' + e.message); }
|
||||
}
|
||||
reply({ type: 'preloaded', langs: msg.langs || [] });
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* voice-install.js — download and unpack ONE voice bundle, in a worker thread.
|
||||
*
|
||||
* This work used to run on the voice process's own event loop, from inside
|
||||
* engineFor(), the moment a sentence arrived for a language whose voice was
|
||||
* not installed. MEASURED on a campus Mac, 2026-10-15 15:11: a listener tapped
|
||||
* Chinese, three parallel downloads and unpacks of the 121 MB zh model ran at
|
||||
* once on the thread that has to answer every sentence, every other language
|
||||
* stalled for about a minute, 26 sentences were dropped, and the catch-up
|
||||
* pushed the Spanish voices to their +16% rush cap — reported from the pews
|
||||
* as the voice "changing". bzip2 in pure JS is CPU work; it has no business on
|
||||
* that thread.
|
||||
*
|
||||
* So the whole fetch-check-unpack now happens here, off the loop, and
|
||||
* tts-service.js de-duplicates it per bundle. The archive is unpacked into a
|
||||
* staging folder and renamed into place only when complete, so a half-written
|
||||
* bundle can never pass isInstalled() and be handed to sherpa.
|
||||
*/
|
||||
'use strict';
|
||||
const { parentPort, workerData } = require('worker_threads');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { url, voiceDir, bundle } = workerData;
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(voiceDir, { recursive: true });
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
/* A proxy or a captive portal returns an HTML error page with HTTP 200;
|
||||
the bzip2 magic is the cheapest way to notice before unpacking it. */
|
||||
if (body.length < 1024 || body.toString('latin1', 0, 3) !== 'BZh') {
|
||||
throw new Error('that download was not a voice bundle');
|
||||
}
|
||||
parentPort.postMessage({ status: 'installing' });
|
||||
|
||||
const bunzip = require('unbzip2-stream');
|
||||
const tar = require('tar-stream');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
const staging = path.join(voiceDir, '.installing-' + bundle + '-' + process.pid);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const extract = tar.extract();
|
||||
extract.on('entry', (header, stream, next) => {
|
||||
/* Never write outside the staging folder, whatever the archive claims. */
|
||||
const dest = path.resolve(staging, header.name);
|
||||
if (dest !== staging && !dest.startsWith(staging + path.sep)) {
|
||||
stream.resume(); return next();
|
||||
}
|
||||
if (header.type === 'directory') {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
stream.resume(); return next();
|
||||
}
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
stream.pipe(fs.createWriteStream(dest)).on('finish', next).on('error', reject);
|
||||
});
|
||||
extract.on('finish', resolve);
|
||||
extract.on('error', reject);
|
||||
Readable.from(body).pipe(bunzip()).on('error', reject).pipe(extract);
|
||||
});
|
||||
|
||||
/* The archive's top-level folder is the bundle name — that is what
|
||||
voicePath() opens. Swap it into place in one rename. */
|
||||
const unpacked = path.join(staging, bundle);
|
||||
if (!fs.existsSync(unpacked)) throw new Error('the archive did not contain ' + bundle);
|
||||
const final = path.join(voiceDir, bundle);
|
||||
fs.rmSync(final, { recursive: true, force: true });
|
||||
fs.renameSync(unpacked, final);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
|
||||
parentPort.postMessage({ status: 'ready' });
|
||||
})().catch((err) => {
|
||||
parentPort.postMessage({ status: 'error', message: (err && err.message) || String(err) });
|
||||
});
|
||||
+59
-15
@@ -57,16 +57,45 @@ For a second campus, repeat with its own name — `translate-north`,
|
||||
|
||||
---
|
||||
|
||||
## The other way: your existing nginx proxy
|
||||
## The other way: a name on your existing reverse proxy
|
||||
|
||||
You already run a reverse proxy at `10.1.1.4` fronting `check.watersmedia.org`,
|
||||
with split-horizon DNS. It can front this too, though it means the traffic goes
|
||||
through the NAS network to reach the booth Mac.
|
||||
You already run a reverse proxy on the NAS at `10.1.1.4` (Boba-Fett, Synology
|
||||
DSM) fronting `check.watersmedia.org`. It can front this too, though it means
|
||||
the traffic goes through the NAS network to reach the booth Mac.
|
||||
|
||||
Add a DNS record for `translate.watersmedia.org` — public to your WAN address,
|
||||
internal to `10.1.1.4` — and a server block. Copy the one that already serves
|
||||
the checklist app, change the name, and point it at the **booth Mac**, not the
|
||||
NAS:
|
||||
This is what North Attleboro runs as of 2026-10-15, in the **LAN-only** form:
|
||||
`http://translate.watersmedia.org` works on the building's WiFi at both the
|
||||
Apollo Beach and North Attleboro sites and nowhere else. Nothing was opened to
|
||||
the WAN. The pieces, with the exact scripts and a step-by-step runbook under
|
||||
[`examples/waters-church-na/`](examples/waters-church-na/README.md):
|
||||
|
||||
| Piece | Where | What |
|
||||
| --- | --- | --- |
|
||||
| Local DNS | UniFi consoles at AB and NA (Settings → Policy Table → DNS) | A record `translate.watersmedia.org` → `10.1.1.4`, TTL 300, created with `unifi-dns-record.py` and a per-console Network API key |
|
||||
| Proxy rule | DSM → Control Panel → Login Portal → Advanced → Reverse Proxy | HTTP `translate.watersmedia.org:80` → HTTP `10.1.7.39:3100`, WebSocket headers, `X-Forwarded-Host: $http_host`, read timeout 3600 s |
|
||||
| HTTPS | `/usr/local/etc/nginx/sites-enabled/translate-redirect.conf` on the NAS | `https://` answers a 301 to `http://`; the name has no certificate, so browsers warn before the redirect |
|
||||
|
||||
Both NAS pieces come from `boba-fett-translate-proxy.sh`, run as
|
||||
`ssh -t boba-fett sudo bash translate-proxy.sh` because DSM's reverse-proxy API
|
||||
is root-only. Going through DSM rather than a hand-edited nginx file means the
|
||||
rule shows up in the DSM UI and survives DSM updates. DSM adds `Host`,
|
||||
`X-Real-IP`, `X-Forwarded-For` and `X-Forwarded-Proto` on its own.
|
||||
|
||||
Then: Operator page → **Public address** → `http://translate.watersmedia.org`
|
||||
(no trailing slash), so the QR code and poster use the name instead of the
|
||||
IP and port.
|
||||
|
||||
To let people off-site use the same name later: add a public DNS record for
|
||||
`translate.watersmedia.org` pointing at the NA WAN address, get a certificate
|
||||
in DSM (Control Panel → Security → Certificate → Let's Encrypt), replace the
|
||||
redirect with an HTTPS rule to the same destination, and switch Public address
|
||||
to `https://`. The app behaves the same either way, including the `/operator`
|
||||
404 through a proxy.
|
||||
|
||||
### If the proxy is plain nginx instead of DSM
|
||||
|
||||
DSM writes most of this for you. On a hand-managed nginx the server block
|
||||
needs all of it:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -77,7 +106,7 @@ server {
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://10.6.3.222:3100; # the booth Mac
|
||||
proxy_pass http://10.1.7.39:3100; # the booth Mac
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -95,21 +124,36 @@ server {
|
||||
# the room reconnects at once.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
|
||||
# Live audio: do not hold HLS segments back.
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This has one real drawback the tunnel does not: the booth Mac needs a fixed
|
||||
address on the network, or the proxy breaks the next time DHCP moves it.
|
||||
Either way the booth Mac needs a fixed address or a DHCP reservation, or the
|
||||
proxy breaks the next time DHCP moves it.
|
||||
|
||||
---
|
||||
|
||||
## Checking it worked
|
||||
|
||||
```bash
|
||||
curl -s https://translate.watersmedia.org/healthz
|
||||
curl -s http://translate.watersmedia.org/healthz # https:// once published
|
||||
```
|
||||
|
||||
Should return `{"ok":true,…}`. Then open the page on a phone **with WiFi turned
|
||||
off** — that is the real test. If the page loads but captions never appear, the
|
||||
WebSocket headers are missing.
|
||||
Should return `{"ok":true,…}`, the same as `curl -s http://10.1.7.39:3100/healthz`.
|
||||
A proxy that passes the WebSocket answers `HTTP/1.1 101 Switching Protocols` to:
|
||||
|
||||
```bash
|
||||
curl -si -N -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
|
||||
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
|
||||
http://translate.watersmedia.org/ws | head -1
|
||||
```
|
||||
|
||||
Then open the page on a phone: for the LAN-only setup on the guest WiFi, for a
|
||||
published one **with WiFi turned off**. That is the real test. If the page loads
|
||||
but captions never appear, the WebSocket headers are missing. If phones on
|
||||
guest WiFi cannot connect at all, look at client isolation on that network
|
||||
before anything else. `/operator` returning 404 through the proxy is by design;
|
||||
staff use `http://10.1.7.39:3100/operator` on the LAN.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# translate.watersmedia.org — local DNS and reverse proxy
|
||||
|
||||
Puts the Church Translate congregation page on a church hostname inside the
|
||||
Apollo Beach and North Attleboro networks. LAN only, plain HTTP, no WAN access.
|
||||
The app's own requirements are in [PUBLIC-ACCESS.md](../../PUBLIC-ACCESS.md). This
|
||||
was done on 2026-10-15 against app 3.23.2 build 121.
|
||||
|
||||
| What | Value |
|
||||
| --- | --- |
|
||||
| App host | Translate Mac, North Attleboro LAN `10.1.7.39` |
|
||||
| App port | TCP 3100, plain HTTP + WebSocket on `/ws` (the only port to expose) |
|
||||
| Reverse proxy | Boba-Fett (Synology DSM 7.0.1), `10.1.1.4`, `ssh boba-fett` (port 202, user Michael) |
|
||||
| Hostname | `translate.watersmedia.org` |
|
||||
| Local DNS | A record on the AB and NA UDM Pro consoles → `10.1.1.4`, TTL 300 |
|
||||
| HTTPS | no certificate; `https://translate.watersmedia.org` answers 301 to `http://` |
|
||||
|
||||
`curl -s http://10.1.7.39:3100/healthz` should return `{"ok":true,"live":true,...}`
|
||||
before anything below is tested.
|
||||
|
||||
## 1. Local DNS on UniFi (AB and NA) — done 2026-10-15
|
||||
|
||||
Each console has its own Network API key saved as
|
||||
`~/.config/unifi/<site>-network-api-key` (created in Settings → Control Plane →
|
||||
Integrations → Create API Key; all five sites are saved on Michael's Mac). The
|
||||
record was created with:
|
||||
|
||||
```bash
|
||||
python3 docs/examples/waters-church-na/unifi-dns-record.py translate.watersmedia.org 10.1.1.4 --sites apollo-beach,north-attleboro
|
||||
```
|
||||
|
||||
Resulting policy ids: AB `379f868d-a956-4ec1-acef-13557153ff3f`, NA
|
||||
`16bf7109-ec68-41f9-9c16-fa6a62471430`. Both gateways answer `10.1.1.4`.
|
||||
`--list` shows every DNS policy per console; `--dry-run` previews.
|
||||
|
||||
Why Boba-Fett and not the Mac: the proxy owns the hostname so the port never
|
||||
appears in the URL, and AB clients reach `10.1.1.4` over the site-to-site link
|
||||
exactly as they already do for `chronicles-na.waterschurch.org`.
|
||||
|
||||
## 2. Reverse proxy on Boba-Fett — done 2026-10-15
|
||||
|
||||
[boba-fett-translate-proxy.sh](boba-fett-translate-proxy.sh) does the whole
|
||||
job and is idempotent. It was run on 2026-10-15; afterwards `/healthz`,
|
||||
`/api/languages`, the `/ws` upgrade (101) and the HTTPS 301 all checked out
|
||||
from both the AB and NA LANs, and `/operator` returned 404 as designed. A copy lives at `~/translate-proxy.sh` on Boba-Fett
|
||||
(Michael's home). DSM's reverse-proxy API and nginx reload both need root, and
|
||||
sudo there asks for Michael's password, so run it interactively:
|
||||
|
||||
```bash
|
||||
ssh -t boba-fett sudo bash translate-proxy.sh
|
||||
```
|
||||
|
||||
What it creates:
|
||||
|
||||
| Piece | Where | Content |
|
||||
| --- | --- | --- |
|
||||
| DSM reverse proxy rule | Control Panel → Login Portal → Advanced → Reverse Proxy, "translate.watersmedia.org" | HTTP `translate.watersmedia.org:80` → HTTP `10.1.7.39:3100`; custom headers `Upgrade: $http_upgrade`, `Connection: $connection_upgrade` (WebSocket), `X-Forwarded-Host: $http_host`; read timeout 3600 s; HTTP/1.1 |
|
||||
| HTTPS redirect | `/usr/local/etc/nginx/sites-enabled/translate-redirect.conf` | `server { listen 443 ssl; server_name translate.watersmedia.org; return 301 http://translate.watersmedia.org$request_uri; }` |
|
||||
|
||||
DSM already sends `Host`, `X-Real-IP`, `X-Forwarded-For` and
|
||||
`X-Forwarded-Proto` on every rule. The whole host lands on `/`, which the app
|
||||
requires. `/operator` returning 404 through the proxy is intended; staff use
|
||||
`http://10.1.7.39:3100/operator` on the LAN.
|
||||
|
||||
To undo: delete the rule in the DSM Reverse Proxy list, remove the redirect
|
||||
file, and `sudo synosystemctl reload nginx`.
|
||||
|
||||
Known limits:
|
||||
|
||||
- The HTTPS redirect only runs after a TLS handshake with Boba-Fett's default
|
||||
certificate, so a browser shows a certificate warning first. A trusted
|
||||
certificate for this name would need DNS-01 validation against the
|
||||
watersmedia.org zone (cPanel), which is not set up.
|
||||
- DSM's reverse proxy cannot turn `proxy_buffering` off. HLS segments under
|
||||
`/live/` are small discrete files and the WebSocket is a tunnel after upgrade,
|
||||
so the effect is minor; if audio lag is reported, that is the first thing to
|
||||
revisit with a hand-written nginx include.
|
||||
|
||||
## 3. After the proxy answers
|
||||
|
||||
1. On the NA LAN open `http://10.1.7.39:3100/operator`, enter the operator PIN,
|
||||
and set **Public address** to `http://translate.watersmedia.org` (no trailing
|
||||
slash). Otherwise the QR code and poster keep advertising `10.1.7.39:3100`.
|
||||
2. Verify from a machine at AB and one at NA:
|
||||
|
||||
```bash
|
||||
curl -s http://translate.watersmedia.org/healthz
|
||||
```
|
||||
|
||||
It must return the same JSON as the direct `10.1.7.39:3100` check.
|
||||
3. Open the page on a phone on guest wifi and confirm captions appear
|
||||
(WebSocket) and spoken audio plays (`/live/`). If phones cannot connect at
|
||||
all, look at client isolation on the guest network and the guest → `10.1.1.4`
|
||||
firewall rule before anything else.
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# translate.watersmedia.org on Boba-Fett (Synology DSM 7.0.1).
|
||||
#
|
||||
# 1. Creates the DSM reverse-proxy rule http://translate.watersmedia.org:80
|
||||
# -> http://10.1.7.39:3100 (Church Translate) with WebSocket headers,
|
||||
# X-Forwarded-Host, and a 3600 s read timeout, through DSM's own API so it
|
||||
# shows up in Control Panel -> Login Portal -> Advanced -> Reverse Proxy.
|
||||
# 2. Adds an nginx server block that answers https://translate.watersmedia.org
|
||||
# with a 301 to the http:// address (the name is LAN-only and has no
|
||||
# certificate, so browsers warn before the redirect; that is expected).
|
||||
# 3. Tests the nginx config, reloads it, and prints a local check.
|
||||
#
|
||||
# Idempotent: re-running skips the rule if it exists and rewrites the redirect.
|
||||
# Run on Boba-Fett as root: sudo bash ~/translate-proxy.sh
|
||||
set -euo pipefail
|
||||
|
||||
NAME=translate.watersmedia.org
|
||||
BACKEND_IP=10.1.7.39
|
||||
BACKEND_PORT=3100
|
||||
RP_JSON=/usr/syno/etc/www/ReverseProxy.json
|
||||
REDIRECT_CONF=/usr/local/etc/nginx/sites-enabled/translate-redirect.conf
|
||||
|
||||
if [ "$(id -u)" != 0 ]; then
|
||||
echo "Run with sudo: sudo bash $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== 1. DSM reverse proxy rule for $NAME"
|
||||
if grep -q "\"fqdn\" : \"$NAME\"" "$RP_JSON"; then
|
||||
echo " rule already present in $RP_JSON, skipping create"
|
||||
else
|
||||
# $http_upgrade etc. are nginx variables and must reach DSM literally.
|
||||
ENTRY='{"description":"translate.watersmedia.org","frontend":{"acl":null,"fqdn":"translate.watersmedia.org","https":{"hsts":false},"port":80,"protocol":0},"backend":{"fqdn":"10.1.7.39","port":3100,"protocol":0},"customize_headers":[{"name":"Upgrade","value":"$http_upgrade"},{"name":"Connection","value":"$connection_upgrade"},{"name":"X-Forwarded-Host","value":"$http_host"}],"proxy_connect_timeout":60,"proxy_read_timeout":3600,"proxy_send_timeout":60,"proxy_http_version":1,"proxy_intercept_errors":false}'
|
||||
OUT=$(/usr/syno/bin/synowebapi --exec api=SYNO.Core.AppPortal.ReverseProxy method=create version=1 entry="$ENTRY" 2>&1 || true)
|
||||
echo " $OUT"
|
||||
if ! echo "$OUT" | grep -q '"success" *: *true'; then
|
||||
echo " synowebapi did not report success; stopping before the redirect." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "== 2. HTTPS -> HTTP redirect block"
|
||||
cat > "$REDIRECT_CONF" <<'EOF'
|
||||
# translate.watersmedia.org is served over plain HTTP on the LAN (no certificate).
|
||||
# Anyone who types https:// is sent to the http:// address. Added 2026-10-15.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name translate.watersmedia.org;
|
||||
return 301 http://translate.watersmedia.org$request_uri;
|
||||
}
|
||||
EOF
|
||||
echo " wrote $REDIRECT_CONF"
|
||||
|
||||
echo "== 3. nginx test + reload"
|
||||
/usr/bin/nginx -t
|
||||
/usr/syno/bin/synosystemctl reload nginx 2>/dev/null || /usr/bin/nginx -s reload
|
||||
echo " reloaded"
|
||||
|
||||
echo "== 4. Local checks from Boba-Fett"
|
||||
echo -n " http /healthz : "
|
||||
curl -s -m 5 --resolve "$NAME:80:127.0.0.1" "http://$NAME/healthz"; echo
|
||||
echo -n " https / : "
|
||||
curl -sk -m 5 -o /dev/null -w '%{http_code} -> %{redirect_url}\n' --resolve "$NAME:443:127.0.0.1" "https://$NAME/"
|
||||
echo "done"
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create or update a local DNS A record on every Waters Church UniFi console.
|
||||
|
||||
Usage:
|
||||
docs/examples/waters-church-na/unifi-dns-record.py repo.watersmedia.org 10.3.8.90
|
||||
docs/examples/waters-church-na/unifi-dns-record.py repo.watersmedia.org 10.3.8.90 --sites apollo-beach,norwood
|
||||
docs/examples/waters-church-na/unifi-dns-record.py repo.watersmedia.org 10.3.8.90 --dry-run
|
||||
docs/examples/waters-church-na/unifi-dns-record.py --list # show every DNS policy per site
|
||||
|
||||
Each console needs its own Network API key (Site Manager keys from api.ui.com are
|
||||
rejected locally). Create one per console in the UniFi UI:
|
||||
Settings -> Control Plane -> Integrations -> Create API Key
|
||||
and save it, one line, mode 600, as:
|
||||
~/.config/unifi/<site>-network-api-key
|
||||
where <site> is one of the names in SITES below.
|
||||
|
||||
The script talks to the console's Network Integration API
|
||||
(/proxy/network/integration/v1/sites/{siteId}/dns/policies) over the LAN /
|
||||
site-to-site links, so it must run from inside the church network. Gateways use
|
||||
self-signed certificates, so certificate verification is disabled for them.
|
||||
Keys are read from disk and sent only in the X-API-KEY header; they are never
|
||||
printed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
# Console name -> gateway LAN address (UDM Pro at each site, 2026-10).
|
||||
SITES = {
|
||||
"apollo-beach": "10.3.0.1",
|
||||
"north-attleboro": "10.1.0.1",
|
||||
"woonsocket": "10.2.0.1",
|
||||
"norwood": "10.4.0.1",
|
||||
"southcoast": "10.5.0.1",
|
||||
}
|
||||
|
||||
KEY_DIR = os.path.expanduser("~/.config/unifi")
|
||||
TIMEOUT = 15
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Console:
|
||||
def __init__(self, name: str, gateway: str):
|
||||
self.name = name
|
||||
self.gateway = gateway
|
||||
self.base = f"https://{gateway}/proxy/network/integration/v1"
|
||||
self.key_path = os.path.join(KEY_DIR, f"{name}-network-api-key")
|
||||
self.key = self._load_key()
|
||||
self.ctx = ssl.create_default_context()
|
||||
self.ctx.check_hostname = False
|
||||
self.ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
def _load_key(self) -> str | None:
|
||||
try:
|
||||
with open(self.key_path) as fh:
|
||||
key = fh.read().strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
return key or None
|
||||
|
||||
def request(self, method: str, path: str, body: dict | None = None, query: dict | None = None):
|
||||
url = self.base + path
|
||||
if query:
|
||||
url += "?" + urllib.parse.urlencode(query)
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("X-API-KEY", self.key or "")
|
||||
req.add_header("Accept", "application/json")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT, context=self.ctx) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode(errors="replace")[:300]
|
||||
raise ApiError(f"{method} {path} -> HTTP {e.code}: {detail}") from None
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
raise ApiError(f"{method} {path} -> {e}") from None
|
||||
|
||||
def site_id(self) -> str:
|
||||
sites = self.request("GET", "/sites").get("data", [])
|
||||
if not sites:
|
||||
raise ApiError("console reports no sites")
|
||||
for s in sites:
|
||||
if s.get("internalReference") == "default" or s.get("name") == "default":
|
||||
return s["id"]
|
||||
return sites[0]["id"]
|
||||
|
||||
def dns_policies(self, site_id: str) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
offset, limit = 0, 100
|
||||
while True:
|
||||
page = self.request("GET", f"/sites/{site_id}/dns/policies", query={"offset": offset, "limit": limit})
|
||||
data = page.get("data", [])
|
||||
out.extend(data)
|
||||
total = page.get("totalCount")
|
||||
offset += len(data)
|
||||
if not data or (total is not None and offset >= total) or len(data) < limit:
|
||||
return out
|
||||
|
||||
def ensure_a_record(self, site_id: str, fqdn: str, ipv4: str, ttl: int | None, dry_run: bool) -> str:
|
||||
existing = [p for p in self.dns_policies(site_id) if p.get("domain", "").lower() == fqdn.lower()]
|
||||
a_records = [p for p in existing if p.get("type") == "A_RECORD"]
|
||||
other = [p for p in existing if p.get("type") != "A_RECORD"]
|
||||
if other:
|
||||
kinds = ", ".join(sorted({p.get("type", "?") for p in other}))
|
||||
return f"SKIPPED: {fqdn} already has a non-A policy here ({kinds}); fix by hand"
|
||||
if len(a_records) > 1:
|
||||
return f"SKIPPED: {len(a_records)} A records already exist for {fqdn}; fix by hand"
|
||||
|
||||
body = {"type": "A_RECORD", "enabled": True, "domain": fqdn, "ipv4Address": ipv4}
|
||||
if ttl:
|
||||
body["ttlSeconds"] = ttl
|
||||
|
||||
if a_records:
|
||||
cur = a_records[0]
|
||||
same = cur.get("ipv4Address") == ipv4 and cur.get("enabled", True) and (ttl is None or cur.get("ttlSeconds") == ttl)
|
||||
if same:
|
||||
return f"unchanged: {fqdn} -> {ipv4} already present (id {cur.get('id')})"
|
||||
if dry_run:
|
||||
return f"would UPDATE {fqdn}: {cur.get('ipv4Address')} -> {ipv4} (id {cur.get('id')})"
|
||||
self.request("PUT", f"/sites/{site_id}/dns/policies/{cur['id']}", body=body)
|
||||
return f"UPDATED {fqdn}: {cur.get('ipv4Address')} -> {ipv4}"
|
||||
|
||||
if dry_run:
|
||||
return f"would CREATE {fqdn} -> {ipv4}"
|
||||
created = self.request("POST", f"/sites/{site_id}/dns/policies", body=body)
|
||||
return f"CREATED {fqdn} -> {ipv4} (id {created.get('id') if isinstance(created, dict) else '?'})"
|
||||
|
||||
|
||||
def resolve_via(gateway: str, fqdn: str) -> str:
|
||||
"""Ask the gateway's resolver directly; returns the answer or a short status."""
|
||||
dig = shutil.which("dig")
|
||||
if not dig:
|
||||
return "(dig not installed)"
|
||||
try:
|
||||
res = subprocess.run([dig, "+short", "+time=3", "+tries=1", f"@{gateway}", fqdn, "A"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
return "(timeout)"
|
||||
answer = res.stdout.strip().replace("\n", " ")
|
||||
return answer or "(no answer)"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("fqdn", nargs="?", help="host name, e.g. repo.watersmedia.org")
|
||||
ap.add_argument("ipv4", nargs="?", help="IPv4 address the name should resolve to")
|
||||
ap.add_argument("--sites", default=",".join(SITES), help="comma-separated subset of: " + ", ".join(SITES))
|
||||
ap.add_argument("--ttl", type=int, default=300, help="TTL in seconds (default 300; the API rejects a null TTL)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="show what would change without writing")
|
||||
ap.add_argument("--list", action="store_true", help="print every DNS policy on each console and exit")
|
||||
ap.add_argument("--no-verify", action="store_true", help="skip the dig check after writing")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.list and (not args.fqdn or not args.ipv4):
|
||||
ap.error("fqdn and ipv4 are required unless --list is given")
|
||||
|
||||
names = [s.strip() for s in args.sites.split(",") if s.strip()]
|
||||
unknown = [n for n in names if n not in SITES]
|
||||
if unknown:
|
||||
ap.error(f"unknown site(s): {', '.join(unknown)}")
|
||||
|
||||
failures = 0
|
||||
for name in names:
|
||||
console = Console(name, SITES[name])
|
||||
label = f"{name:16s} {console.gateway:10s}"
|
||||
if not console.key:
|
||||
print(f"{label} NO KEY: save the console's API key to {console.key_path}")
|
||||
failures += 1
|
||||
continue
|
||||
try:
|
||||
site_id = console.site_id()
|
||||
if args.list:
|
||||
policies = console.dns_policies(site_id)
|
||||
print(f"{label} {len(policies)} DNS policies")
|
||||
for p in sorted(policies, key=lambda p: p.get("domain", "")):
|
||||
target = p.get("ipv4Address") or p.get("ipv6Address") or p.get("targetDomain") or p.get("text") or ""
|
||||
flag = "" if p.get("enabled", True) else " (disabled)"
|
||||
print(f" {p.get('type', '?'):12s} {p.get('domain', ''):40s} -> {target}{flag}")
|
||||
continue
|
||||
result = console.ensure_a_record(site_id, args.fqdn, args.ipv4, args.ttl, args.dry_run)
|
||||
line = f"{label} {result}"
|
||||
if not args.dry_run and not args.no_verify and not result.startswith("SKIPPED"):
|
||||
line += f" | dig @{console.gateway}: {resolve_via(console.gateway, args.fqdn)}"
|
||||
print(line)
|
||||
if result.startswith("SKIPPED"):
|
||||
failures += 1
|
||||
except ApiError as e:
|
||||
print(f"{label} FAILED: {e}")
|
||||
failures += 1
|
||||
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user