7 Commits
Author SHA1 Message Date
christianandClaude Opus 5 95d1a7b9ec Stop the stream seeking against itself, and stop the desk scrolling itself
Build 126. Two reports, unrelated to each other.

IPHONES REPEATING AND SKIPPING PARTS OF EVERY SENTENCE.

DRIFT_LAND was 0.6 s and one segment is 0.697 s, so the position the player
was being held at sat INSIDE the newest segment — a point it very often did
not have yet. It got dragged to the edge, ran out of audio and stalled. Then
'stalled' and 'waiting' were both wired to seekLive(), and a seek on a live
HLS stream makes the element re-buffer, which raises those same two events. So
the correction caused the stall that caused the correction. On Safari, where
HLS is native and a seek lands on a segment boundary, each turn of that loop
replays or drops a piece of a sentence — which is exactly what was described.

Three changes, all of them about not fighting the player: those two events no
longer seek, the target is three segments back (~2.1 s, what an HLS player
picks for itself on a live playlist), and a correction now has 5 s to settle
before another may run. Costs about 1.5 s of extra delay. A listener a second
further behind is following the service; one whose audio keeps jumping
backwards is not.

NOT YET CONFIRMED ON A REAL IPHONE — the mechanism is read off the code and
the measured segment length, and the next service is the test.

CANNOT SCROLL THE MONITOR WHILE LIVE.

Four boxes ended their update with box.scrollTop = box.scrollHeight,
unconditionally, so every sentence yanked the view back to the live edge and
reading back was impossible. They now follow the bottom only while the
operator is already at the bottom, and resume when they return — nothing to
press either way. Trimming the top is held off while they are scrolled away
too, since dropping old lines moves the text being read; the cap comes back
the moment they return, with a ceiling so nothing grows all service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 15:25:42 -04:00
christianandClaude Opus 5 acfca90836 The English switch did nothing, so English could not be turned off
Build 125. Reported from the desk: every language on the listener page can be
switched off except English.

Exactly right, and it was not subtle. renderFeatured() unshifted the SOURCE
language into the list on every render, whatever the operator had chosen:

    if (list.indexOf(app.cfg.sourceLanguage) === -1)
      list.unshift(app.cfg.sourceLanguage);

So English was not merely on, it was always FIRST — the leading button on the
page — on a desk whose saved list was ["spa_Latn","por_Latn","por_PT","spa_ES"]
with English deliberately off and allowAll false. The operator had turned it
off, the config recorded that it was off, and the page put it back anyway.

The reasoning behind it was sound: people do want the English captions for
accessibility rather than translation. But that is a choice for the desk, not
for this function, and the switch to make it is already there in Settings next
to every other language. English is in the catalogue (languages.js:19) and the
operator window reads that same file, so turning it back on is one click.

A control that silently refuses is worse than no control. The operator's list
is now the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 15:17:15 -04:00
christianandClaude Opus 5 7291ff48f7 Save QR image stranded the window on a picture of itself
Build 124. Reported from the booth: the button did not download anything and
there was no way back off the QR.

Both halves are the same fault. The window is served from the asset port and
the code comes off the congregation port, so the link the button built was
CROSS-ORIGIN — and a cross-origin <a download> has its download attribute
ignored and NAVIGATES instead. So the file never saved, and the window went to
the image. The app has no back button, no reload item and no address bar, so
that is a one-way trip: a headless booth Mac showing a full-screen QR with the
operator locked out of the desk. It fetches the code and downloads a blob now,
which is same-origin and actually saves.

The second change is the one that matters more. Nothing was stopping the app
window being replaced by any navigation at all, and the only reason this had
never bitten before is that nothing had tried. will-navigate now refuses to
replace the app page, logs what it refused, and sends anything external to the
browser. Our own pages are unaffected — the operator console and the poster
open as their own windows through setWindowOpenHandler, which is a different
path entirely.

Found because the build number went to the wrong field: buildNumber is the
source of truth and build.buildVersion is synced from it by predist, which
caught it and rebuilt correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 14:14:45 -04:00
Waters ChurchandClaude Opus 5 826c471ed7 On a domain, show the code and nothing else
Build 123. 122 taught the banner and the QR about the public address but left
the interface picker underneath them untouched, so the panel still listed
10.6.5.55 and 10.1.7.39 with one marked IN USE — numbers that no longer point
anywhere anybody is being sent, directly under a banner naming the domain.

Those rows exist to choose which interface the code encodes, which is a
question only a LAN setup has: with a public address set the code points at the
domain whichever row is picked. They are hidden now, and a LAN-only campus is
unaffected.

Printable poster is replaced by Save QR image. The poster people actually print
is made in their own design tool, and what they need from the app is the code
as a file — a 512px SVG, so it scales to any size without going soft. The
built-in poster page is untouched at /poster for anyone who wants it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 13:48:18 -04:00
Waters ChurchandClaude Opus 5 817fb743de Show the domain, not the LAN address, once one is set
Build 122. The operator page honoured Public address and the main app window
did not, so the two disagreed about the one fact they both exist to display:
translate.watersmedia.org was configured and working through nginx, while the
window, the QR code, the poster and Copy address all still handed out
http://10.1.7.39:3100.

engine.js had no mention of publicUrl at all. It now reads it from the server
config it was already fetching, and prefers it everywhere the congregation is
pointed.

The operator link deliberately does not follow. /operator returns 404 to
anything carrying X-Forwarded-For, so over a proxy the domain is exactly where
that page cannot be reached; it stays on the LAN address, which is the whole
point of being able to run the desk from a phone.

The QR warnings are suppressed when a public address is set. They talk about
Wi-Fi being down and leases moving, which are facts about an address nobody is
being given any more; the panel now names the public address and reminds the
desk that this Mac still has to be reachable from the proxy.

The share panel also re-renders when the server config arrives. It is fetched
asynchronously and lost a race at startup, so the first paint had no publicUrl
to read even once this was wired up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 13:40:48 -04:00
michael d289d33aab Merge pull request 'Document the LAN-only translate.watersmedia.org setup at North Attleboro' (#1) from docs/waters-na-lan-proxy into main 2026-10-15 13:21:35 -04:00
michaelandClaude Fable 5.1 d785ebf979 Document the LAN-only translate.watersmedia.org setup at North Attleboro
PUBLIC-ACCESS.md now describes what actually runs: a local UniFi DNS
record at Apollo Beach and North Attleboro pointing the name at the
Boba-Fett NAS, a DSM reverse proxy rule from port 80 to the booth Mac at
10.1.7.39:3100 with WebSocket headers, X-Forwarded-Host and a 3600 s
read timeout, and an nginx block that redirects https:// to http://.
The hand-written nginx example is kept for non-DSM proxies, with the
booth Mac address corrected and proxy_buffering off for live audio.

docs/examples/waters-church-na/ holds the runbook, the script run on
Boba-Fett, and the UniFi DNS record script used to create the records.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 13:14:57 -04:00
9 changed files with 657 additions and 35 deletions
+155 -11
View File
@@ -195,6 +195,59 @@ const state = {
degradedAt: 0 degradedAt: 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 // ────────────────────────────────────────────────────────── logging
function log(msg, kind) { function log(msg, kind) {
@@ -208,8 +261,8 @@ function log(msg, kind) {
if (kind) line.className = kind; if (kind) line.className = kind;
line.textContent = new Date().toLocaleTimeString() + ' ' + msg; line.textContent = new Date().toLocaleTimeString() + ' ' + msg;
el.appendChild(line); el.appendChild(line);
while (el.children.length > 200) el.removeChild(el.firstChild); trimTop(el, 200);
el.scrollTop = el.scrollHeight; followBottom(el);
} }
function hero(stateName, title, body) { function hero(stateName, title, body) {
@@ -2471,8 +2524,8 @@ function monitorFinal(cid, text) {
monRows.set(cid, el); monRows.set(cid, el);
/* Neither the map nor the box may grow for the length of a service. */ /* 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 (monRows.size > 60) monRows.delete(monRows.keys().next().value);
while (box.children.length > 24) box.removeChild(box.firstChild); trimTop(box, 24);
box.scrollTop = box.scrollHeight; followBottom(box);
} }
/* /*
@@ -2514,7 +2567,7 @@ function monitorTranslation(cid, lang, text) {
row.classList.add('dropped'); row.classList.add('dropped');
} }
const box = $('monitor'); const box = $('monitor');
if (box) box.scrollTop = box.scrollHeight; followBottom(box);
} }
/* /*
@@ -2581,9 +2634,9 @@ function showMonitor(text, isPartial, langCount) {
sub.textContent = langCount + ' language' + (langCount === 1 ? '' : 's') + ' · ' + state.lagMs + ' ms'; sub.textContent = langCount + ' language' + (langCount === 1 ? '' : 's') + ' · ' + state.lagMs + ' ms';
el.appendChild(sub); el.appendChild(sub);
box.appendChild(el); box.appendChild(el);
while (box.children.length > 30) box.removeChild(box.firstChild); trimTop(box, 30);
} }
box.scrollTop = box.scrollHeight; followBottom(box);
} }
// ────────────────────────────────────────────────────────── church server // ────────────────────────────────────────────────────────── church server
@@ -3508,14 +3561,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(); const c = shareChoice();
if (!c || c.none) return ''; if (!c || c.none) return '';
return 'http://' + c.addr.address + ':' + state.local.port; 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() { function renderQrWarning() {
const el = $('qrWarn'); 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(); const c = shareChoice();
if (!c) { el.hidden = true; return; } if (!c) { el.hidden = true; return; }
@@ -3566,6 +3655,26 @@ function renderShare() {
const list = state.local.addresses || []; const list = state.local.addresses || [];
const box = $('addrList'); const box = $('addrList');
box.textContent = ''; 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 /* 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. */ a lease moves those are two different things and the saved one is gone. */
@@ -3607,8 +3716,40 @@ $('copyAddr').addEventListener('click', () => {
.catch((err) => log('could not copy: ' + err.message, 'err')); .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', () => { $('openOperator').addEventListener('click', () => {
window.open('http://127.0.0.1:' + state.local.port + '/operator', '_blank'); window.open('http://127.0.0.1:' + state.local.port + '/operator', '_blank');
@@ -3665,7 +3806,8 @@ function renderBuildTag() {
} }
function renderOperatorUrl() { 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'; $('opPageUrl').textContent = base ? base + '/operator' : 'no network';
} }
@@ -3968,6 +4110,7 @@ async function loadOperator() {
try { try {
const j = await opApi('/api/config'); const j = await opApi('/api/config');
opConfig = j.config; opConfig = j.config;
renderShare(); /* publicUrl may have just arrived */
clearTimeout(opLoadTimer); opLoadTimer = null; // it answered; stop retrying clearTimeout(opLoadTimer); opLoadTimer = null; // it answered; stop retrying
opRetryMs = 0; opRetryMs = 0;
renderOpLanguages(); renderOpLanguages();
@@ -4022,6 +4165,7 @@ async function opSave(patch, okMsg) {
try { try {
const j = await opApi('/api/config', { method: 'POST', body: JSON.stringify(patch) }); const j = await opApi('/api/config', { method: 'POST', body: JSON.stringify(patch) });
opConfig = j.config; opConfig = j.config;
renderShare(); /* publicUrl may have just arrived */
if (okMsg) log(okMsg, 'ok'); if (okMsg) log(okMsg, 'ok');
return j; return j;
} catch (e) { log('could not save: ' + e.message, 'err'); throw e; } } catch (e) { log('could not save: ' + e.message, 'err'); throw e; }
+1 -1
View File
@@ -433,7 +433,7 @@
<div id="addrList" class="addrlist"></div> <div id="addrList" class="addrlist"></div>
<div class="railbtns"> <div class="railbtns">
<button class="btn btn-primary" id="copyAddr">Copy address</button> <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> </div>
<h3 class="subhead">Operator page</h3> <h3 class="subhead">Operator page</h3>
+21
View File
@@ -662,6 +662,27 @@ function createWindow() {
: '/index.html'; : '/index.html';
win.loadURL(assetOrigin + page); 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-finish-load', () => console.log('[main] page loaded'));
win.webContents.on('did-fail-load', (_e, code, desc, url) => win.webContents.on('did-fail-load', (_e, code, desc, url) =>
console.error('[main] page FAILED to load: ' + code + ' ' + desc + ' ' + url)); console.error('[main] page FAILED to load: ' + code + ' ' + desc + ' ' + url));
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "church-translate-engine", "name": "church-translate-engine",
"productName": "Church Translate", "productName": "Church Translate",
"version": "3.23.2", "version": "3.23.2",
"buildNumber": 121, "buildNumber": 126,
"private": true, "private": true,
"description": "Live sermon translation — self-contained: captures audio, transcribes, translates, and serves the congregation page itself", "description": "Live sermon translation — self-contained: captures audio, transcribes, translates, and serves the congregation page itself",
"main": "main.js", "main": "main.js",
@@ -106,7 +106,7 @@
] ]
}, },
"asar": false, "asar": false,
"buildVersion": "121", "buildVersion": "126",
"win": { "win": {
"target": [ "target": [
{ {
+51 -6
View File
@@ -163,10 +163,21 @@
function renderFeatured() { function renderFeatured() {
var wrap = $('featured'); var wrap = $('featured');
wrap.textContent = ''; 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(); 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) { list.forEach(function (code) {
var b = langButton(code); var b = langButton(code);
if (b) wrap.appendChild(b); if (b) wrap.appendChild(b);
@@ -508,7 +519,16 @@
/* After any interruption the player resumes where it left off and stays /* After any interruption the player resumes where it left off and stays
behind live forever — no error, no stall, just permanently late. Every behind live forever — no error, no stall, just permanently late. Every
one of these events is a chance to catch back up. */ 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(); }); a.addEventListener(ev, function () { seekLive(); });
}); });
@@ -588,8 +608,28 @@
* and 4s of tolerance plus a 5s check interval meant real drift could reach * and 4s of tolerance plus a 5s check interval meant real drift could reach
* nine seconds before anyone looked. * 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. * How far behind the live edge this listener is SUPPOSED to be.
@@ -616,6 +656,11 @@
var want = driftTarget(); var want = driftTarget();
var behind = (edge - a.currentTime) - want; // + = too late, - = too early var behind = (edge - a.currentTime) - want; // + = too late, - = too early
if (!force && Math.abs(behind) <= DRIFT_MAX) return; 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 * 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 * live edge leaves no buffered audio at all, so the phone stalls on the
+59 -15
View File
@@ -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`, You already run a reverse proxy on the NAS at `10.1.1.4` (Boba-Fett, Synology
with split-horizon DNS. It can front this too, though it means the traffic goes DSM) fronting `check.watersmedia.org`. It can front this too, though it means
through the NAS network to reach the booth Mac. 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, This is what North Attleboro runs as of 2026-10-15, in the **LAN-only** form:
internal to `10.1.1.4` — and a server block. Copy the one that already serves `http://translate.watersmedia.org` works on the building's WiFi at both the
the checklist app, change the name, and point it at the **booth Mac**, not the Apollo Beach and North Attleboro sites and nowhere else. Nothing was opened to
NAS: 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 ```nginx
server { server {
@@ -77,7 +106,7 @@ server {
ssl_certificate_key /path/to/privkey.pem; ssl_certificate_key /path/to/privkey.pem;
location / { 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@@ -95,21 +124,36 @@ server {
# the room reconnects at once. # the room reconnects at once.
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
proxy_send_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 Either way the booth Mac needs a fixed address or a DHCP reservation, or the
address on the network, or the proxy breaks the next time DHCP moves it. proxy breaks the next time DHCP moves it.
--- ---
## Checking it worked ## Checking it worked
```bash ```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 Should return `{"ok":true,…}`, the same as `curl -s http://10.1.7.39:3100/healthz`.
off** — that is the real test. If the page loads but captions never appear, the A proxy that passes the WebSocket answers `HTTP/1.1 101 Switching Protocols` to:
WebSocket headers are missing.
```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.
+93
View File
@@ -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
View File
@@ -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())