Author SHA1 Message Date
michaelandClaude Opus 5.5 825a25b626 Document opening Waters Guest WiFi at North Attleboro to translate
The guest network blocks private ranges twice: a zone default on the
gateway, and the guest hotspot's Post-Authorization Restrictions, which
the access points enforce on their own. With only a firewall allow, a
phone on Waters Guest worked for about two minutes and then timed out.

docs/examples/waters-church-na/ gains the three scripts used to fix it:
zone-allow-policy.py (allow Hotspot -> 10.1.1.4 on 80/443),
guest-restrictions.py (drop 10.0.0.0/8 from the restriction list via
the legacy guest_access setting, since system policies cannot be edited
through the Integration API) and wifi-broadcast-devices.py (put the
Waters Guest SSID on the Auditorium, Main Lobby and Offices APs). The
runbook gets a section on all three, and PUBLIC-ACCESS.md points at it
from the troubleshooting notes.

Confirmed on site: translate.watersmedia.org loads on Waters Guest and
stays up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:15:46 -04:00
christianandClaude Fable 5.1 81da657094 One voice per listener, and the voice process stops stalling itself
Build 127. Reported from a live test: part of the translation came out in a
different voice on an Android phone, and iPhones were skipping and repeating.
Three investigations against the campus Mac's own log and transcript, one
test harness driving the voice process directly, and the answer is not what
it sounded like.

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

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

WHAT WAS WRONG, AND IS NOW FIXED.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 16:05:15 -04:00
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
Waters ChurchandClaude Opus 5 a06cf35cbb A stumble should not colour the desk red for the rest of the service
Build 121. Three things a single 16 s stall did that it should not have.

captionOnlyFinals is a running total for the whole service, and the verdict
tested it for more than zero — so one stale sentence pinned the light red until
the next restart. MEASURED: a campus Mac stalled once at 14:25, recovered
immediately to a 421 ms lag with an empty queue, and still showed red half an
hour later, with the operator quite reasonably reporting it as behind. The
totals still travel for the record; the verdict now asks when it last happened,
inside a two-minute window. A light that cannot go back to green teaches the
desk to ignore it.

That sentence also had its translations skipped. Dropping them is how the app
catches up — the listener has moved on and the time is better spent on what is
behind it. But that reasoning needs a backlog, and the queue was EMPTY: the room
lost a line of the sermon to a catch-up that had nothing to catch up on. It now
skips only when something is actually waiting, and says plainly when it chooses
to translate a late sentence anyway.

The stall itself was not the app. Two Screen Sharing sessions were taking as
much of the machine as the app was, with WindowServer on top — watching a campus
Mac during a service measurably degrades it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:32:25 -04:00
Waters ChurchandClaude Opus 5 39dea80433 Do not step down twice for the same backlog
Build 120.

Swapping the speech model did nothing about the queue that caused the swap. The
first of those chunks went straight to a model that had just loaded, took the
full 30 s worker timeout, and tripped the absolute rule — so the ladder fell
again. MEASURED on a campus Mac: large-v3-turbo to small to base in 34 seconds,
landing on a model measurably SLOWER than the one it started from, which is the
opposite of what stepping down is for.

Two changes. A successful step-down now discards the queued chunks: they are
already past the caption-only cutoff, so they cannot be translated in time and
are worth nothing to the room, and it says how many it dropped. And no further
step-down is allowed for twenty seconds, so the new model is judged on sentences
it was actually given rather than on the wreckage of the old one.

Twenty seconds covers a cold first inference while still letting a genuinely
hopeless model fall through the ladder within a sentence or two. Checked against
the real cascade from the log, including that a hung model long after a swap is
still caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:54:26 -04:00
Waters ChurchandClaude Opus 5 6f91bc1357 Step down on landing late, not on a deep queue
Build 118. Build 117 got the first half right and the second half wrong.

117 compared the time taken against the audio the chunk held, which was correct,
and then required a queue five deep as proof the machine was losing. Watching a
campus M4 Pro go red disproved that: 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, and the backlog never gets deep enough to notice.

The second test is now how far behind the speaker the sentence actually landed,
which is what the operator is complaining about in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:32:37 -04:00
Waters ChurchandClaude Opus 5 92e86e415f Step down when losing ground, not when slower than a constant
Build 117.

A campus M4 Pro sat in the red with nobody listening and no language being
translated — so transcription alone. Measured over SSH: sentences 4.2-4.7 s
against roughly 4-5 s of audio, queue 46 deep, lag 152 s and still climbing.

The mechanism built to catch exactly this could not fire. It triggers past 8 s,
on the reasoning that 8 s means slower than people talk. That held when a chunk
could be 8 s long; chunks are capped at HARD_CUT_MAX, 5.5 s. A 4.4 s sentence is
already losing and never comes near 8 s, so the ladder never stepped down and
the backlog grew without bound.

It now compares against the audio the chunk actually held. Two details are
measured rather than chosen: the margin is 0.85, because the Mini's 4.4 s
against 4.5 s was NOMINALLY faster than real time while still losing — the work
outside that timer (segmenting, the preview model sharing the GPU, the
scheduler) loses on its own. And it requires a backlog of five, so one slow
sentence on a healthy machine touches nothing. Checked against eight cases
including the Mini's real numbers and a healthy M2 Max.

The old absolute rule stays for a model that is genuinely hung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:24:05 -04:00
Waters ChurchandClaude Opus 5 73ec7f1436 Report the app version on /healthz
Build 116.

/healthz has always carried a 'build' field, but it is a hash of the listener
page's assets — a cache-busting token, not a version. So the only way to tell
what a campus Mac was actually running was to install something and watch the
hash move, which is how the last three updates were confirmed. With an updater
in the field, what version a machine is on is the first question anyone asks.

It now answers plainly: version and buildNumber, passed to the server child from
the running bundle. Checking a campus is a single unauthenticated request.

It found something immediately: this development Mac reported appBuild 112 while
the source tree was at 115, because deploying loose files over an installed
bundle leaves its package.json behind. A packaged install reports correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:04:46 -04:00
Waters ChurchandClaude Opus 5 4aa30699c9 Install the update itself, instead of asking for a drag
Build 115. Downloading and then telling the operator to quit, mount and drag
was the friction the updater was supposed to remove.

An app cannot replace itself while running, so the swap is done by a small
script that outlives the process: it waits for us to exit, moves the old bundle
aside, copies the new one in, clears the quarantine flag and relaunches.

Moved aside, not deleted. If the copy fails half way — a full disk, a truncated
download, the volume going away — the old app goes straight back and is
reopened. The failure 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. Tested in a
sandbox across all three paths: a clean swap, a copy that fails mid-way (old app
restored, no leftovers), and a disk image with no app in it (aborts before
touching the installed one).

It refuses while translation is live: quitting stops every listener's captions
and no update is worth that. Stop translating first.

No sudo — /Applications is admin-writable and the bundle belongs to the
operator. If that is ever untrue the copy fails and the rollback runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:50:05 -04:00
Waters ChurchandClaude Opus 5 6753a94e1c Say what is loading the machine when it falls behind
Build 114.

Translation cost is roughly linear in the number of active languages, through
one NLLB process — measured at five languages in 1.9 s. So when a machine is
running behind, the most useful single fact is how many languages it is
carrying, and that number sat one function away from the health verdict and was
never shown.

A campus reported being constantly in the red. The cause was eight languages
with 'let listeners pick any' on, against a pipeline tuned and measured against
one, and finding that took an afternoon of reading logs. The operator page now
says it in the sentence that reports the problem.

Quiet when it cannot help: nothing is added below two languages, and the
'listeners may add more' clause only appears when that setting is actually on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:36:30 -04:00
Waters ChurchandClaude Opus 5 bf8a8cc2d3 Build 113
Carries the in-app updater, so this is the last build that has to be installed
by hand: from here a campus Mac can fetch the next one itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:19:14 -04:00
Waters ChurchandClaude Opus 5 070c0dfb82 Pull updates from the church's own forge, from inside the app
Settings gains an Updates block: it asks the church Gitea for the newest
release, compares the tag against the running build, and if there is a newer
one shows what changed and offers to download the installer.

Deliberately not electron-updater. That wants a static feed published beside
the artifacts, and this build is ad-hoc signed with no Team ID, so a silent
in-place swap would meet Gatekeeper on a machine nobody is sitting at. And
nothing here happens on its own: a background check that announces itself three
minutes into a sermon is a hazard, so the operator presses the button, and
presses a second one to download. The file lands in Downloads and is revealed;
installing is a drag across when the room is empty.

Tags are v<version>-<build>, compared numerically field by field so 3.9.0 does
not read as newer than 3.23.2 and build 113 beats build 112.

Two things only real testing found. Gitea answers with whatever address it was
reached on, so the release API returned asset URLs on 10.3.8.90 while the forge
is configured by hostname — the same-host guard refused a perfectly good
installer. The URL is now rebuilt on the configured origin, which keeps the
property that matters (this only ever fetches from the server the operator
configured) and survives a forge that knows itself by more than one name. And a
private repository answers 404 rather than 401, so 'no releases published yet'
told the operator the opposite of the truth; without a token that case now names
the remedy instead.

The token is this machine's credential: it stays out of PORTABLE_APP, so it does
not ride along in an exported settings file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:09:08 -04:00
Waters ChurchandClaude Opus 5 5d8d3e3527 Add a one-shot diagnostic for the falling-behind case
The operator page turns red on two things — the app going quiet, or lagMs past
12 s with captionOnlyFinals above zero, which is the pipeline running slower
than people talk. Neither says WHY from the outside, and the machine that is
struggling is usually not the machine anyone is sitting at.

tools/diag.sh reads the log and asks the running app for its status, and prints
the four things that separate the causes: what hardware it is, whether it is
swapping, which models it actually chose at startup (a GPU with no fp16 or no
WebGPU at all changes that silently), and how long sentences are really taking.
Read-only throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 11:43:39 -04:00
19 changed files with 2205 additions and 155 deletions
+10
View File
@@ -700,3 +700,13 @@ textarea:focus { border-color: var(--accent); }
.langfly { display: none; } .langfly { display: none; }
.langrow.touched { animation: 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
View File
@@ -190,9 +190,81 @@ const state = {
/* health counters, reported to the operator page */ /* health counters, reported to the operator page */
degradedFinals: 0, // finals that shipped with fewer languages (stale) 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 // ────────────────────────────────────────────────────────── logging
function log(msg, kind) { function log(msg, kind) {
@@ -206,8 +278,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) {
@@ -891,12 +963,54 @@ async function respawnWorker(worker) {
log('the speech model is too heavy for this machine — switching to ' + log('the speech model is too heavy for this machine — switching to ' +
lighter.split('/').pop(), 'err'); 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.asrWorker = await loadWorkerWithFallback('speech', 'automatic-speech-recognition',
state.asrModelInUse || cfg.asrModel, state.asrModelInUse || cfg.asrModel,
resolveDtype(cfg.asrDtype, { encoder_model: 'fp16', decoder_model_merged: 'q4' }), stepDtype,
{ encoder_model: 'q4', decoder_model_merged: 'q4' }); { encoder_model: 'q4', decoder_model_merged: 'q4' });
log('speech model restarted' + (lighter ? ' on ' + lighter.split('/').pop() : ''), 'ok'); log('speech model restarted' + (lighter ? ' on ' + lighter.split('/').pop() : ''), 'ok');
state.slowRun = 0; 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) { } catch (err) {
log('model restart failed: ' + err.message, 'err'); log('model restart failed: ' + err.message, 'err');
@@ -973,10 +1087,15 @@ function preloadNewVoices() {
const want = state.targets.filter((l) => const want = state.targets.filter((l) =>
state.spokenLanguages.indexOf(l) !== -1 && !preloadedVoices.has(l)); state.spokenLanguages.indexOf(l) !== -1 && !preloadedVoices.has(l));
if (!want.length) return; 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 /* One at a time in the child anyway; cap the request so a burst of joins
cannot queue up a re-download marathon. */ cannot queue up a re-download marathon.
window.engineHost.ttsPreload(want.slice(0, 4)); 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. */ /* 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 || {}; state.spokenDefaults = info.defaults || state.spokenDefaults || {};
log('built-in voices ready — ' + state.spokenLanguages.length + ' languages', 'ok'); log('built-in voices ready — ' + state.spokenLanguages.length + ' languages', 'ok');
/* Warm whatever people are already reading, so the first sentence of a /* 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(); preloadNewVoices();
}); });
window.engineHost.onTtsProgress((p) => log('voice ' + p.bundle + ': ' + p.status)); window.engineHost.onTtsProgress((p) => log('voice ' + p.bundle + ': ' + p.status));
@@ -1681,9 +1803,9 @@ async function pumpPartial() {
* still gets a preview. * still gets a preview.
*/ */
const heard = (state.byLanguage || []) 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)) .sort((a, b) => (b.n || 0) - (a.n || 0))
.map((r) => r.lang) .map(rowLang)
.filter((l) => state.targets.indexOf(l) !== -1); .filter((l) => state.targets.indexOf(l) !== -1);
const want = (heard.length ? heard : orderedTargets()).slice(0, PARTIAL_MAX_LANGS); const want = (heard.length ? heard : orderedTargets()).slice(0, PARTIAL_MAX_LANGS);
/* Never queue behind a finished sentence — a final in flight is worth more /* Never queue behind a finished sentence — a final in flight is worth more
@@ -1709,7 +1831,7 @@ async function pumpPartial() {
*/ */
function orderedTargets() { function orderedTargets() {
const counts = {}; 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 return state.targets
.filter((t) => t !== state.source) .filter((t) => t !== state.source)
.sort((a, b) => (counts[b] || 0) - (counts[a] || 0)); .sort((a, b) => (counts[b] || 0) - (counts[a] || 0));
@@ -1794,6 +1916,7 @@ async function pump() {
state.busy = true; state.busy = true;
const started = Date.now(); const started = Date.now();
let jobAudioMs = 0;
try { try {
/* job.at is when the audio ENDED. Live translation of stale audio is /* 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 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 /* Near-silence telemetry (log-only this release): a chunk whose average
level barely clears the noise floor is hallucination fuel. */ level barely clears the noise floor is hallucination fuel. */
const audio = flatten(job.frames); 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; let sum = 0;
for (let i = 0; i < audio.length; i += 4) sum += audio[i] * audio[i]; for (let i = 0; i < audio.length; i += 4) sum += audio[i] * audio[i];
const meanRms = Math.sqrt(sum / Math.ceil(audio.length / 4)); const meanRms = Math.sqrt(sum / Math.ceil(audio.length / 4));
@@ -1845,15 +1971,33 @@ async function pump() {
const sentences = collapseLoops(splitSentences(text)); const sentences = collapseLoops(splitSentences(text));
let targets = orderedTargets(); 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 = []; targets = [];
state.captionOnlyFinals++; 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) { } 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; const before = targets.length;
targets = targets.filter((t) => listening.has(t)); 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 /* 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 * 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. * 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; state.slowRun = (state.slowRun || 0) + 1;
if (state.slowRun >= 3 && !respawning && lighterAsr(state.asrModelInUse || state.cfg.asrModel)) { if (state.slowRun >= 3 && !respawning && lighterAsr(state.asrModelInUse || state.cfg.asrModel)) {
state.slowRun = 0; 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); respawnWorker(state.asrWorker);
} }
} else state.slowRun = 0; } else state.slowRun = 0;
@@ -2005,7 +2194,7 @@ setInterval(() => {
}, 30000); }, 30000);
function translateSentence(cid, sentence, targets, gainDb, silent) { 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) { for (const lang of targets) {
if ((mtDepth[lang] || 0) > 6 && !listening.has(lang)) { if ((mtDepth[lang] || 0) > 6 && !listening.has(lang)) {
state.degradedFinals++; state.degradedFinals++;
@@ -2360,8 +2549,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);
} }
/* /*
@@ -2403,7 +2592,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);
} }
/* /*
@@ -2423,6 +2612,11 @@ function monitorTranslation(cid, lang, text) {
* It says it once per episode, and names both numbers so the operator can act * It says it once per episode, and names both numbers so the operator can act
* on it rather than go looking. * 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; const DEAF_AFTER_MS = 90000;
setInterval(() => { setInterval(() => {
try { try {
@@ -2465,9 +2659,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
@@ -2662,6 +2856,7 @@ function goLive(on) {
state.live = on; state.live = on;
if (on) { if (on) {
state.degradedFinals = 0; state.captionOnlyFinals = 0; state.fragmentFinals = 0; 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 /* The watchdog measures from the start of the service, not from the last
sentence of the previous one. */ sentence of the previous one. */
state.lastFinalAt = Date.now(); state.lastFinalAt = Date.now();
@@ -2736,6 +2931,10 @@ function reportStatus() {
queue: queue.length, queue: queue.length,
degradedFinals: state.degradedFinals, degradedFinals: state.degradedFinals,
captionOnlyFinals: state.captionOnlyFinals, 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, fragmentFinals: state.fragmentFinals || 0,
cloud: state.cloudStats || null 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'); : '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()); $('revealCache').addEventListener('click', () => window.engineHost.revealCache());
/* ---- carrying a setup to another campus ------------------------------- */ /* ---- 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(); 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; }
@@ -3349,6 +3680,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. */
@@ -3390,8 +3741,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');
@@ -3448,7 +3831,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';
} }
@@ -3583,6 +3967,7 @@ async function boot() {
const savedSens = Number(state.cfg && state.cfg.sensitivity); const savedSens = Number(state.cfg && state.cfg.sensitivity);
state.sensitivity = Number.isFinite(savedSens) && savedSens > 0 ? savedSens : 3.5; state.sensitivity = Number.isFinite(savedSens) && savedSens > 0 ? savedSens : 3.5;
applyThreshFromConfig(); applyThreshFromConfig();
try { $('updToken').value = (state.cfg && state.cfg.updateToken) || ''; } catch (_) {}
refreshCacheInfo(); refreshCacheInfo();
/* Start the download first and do not wait for it. It is the slow part, it /* Start the download first and do not wait for it. It is the slow part, it
@@ -3750,6 +4135,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();
@@ -3804,6 +4190,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; }
@@ -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) { async function auditionVoice(lang, v, btn, msg) {
if (auditionBusy) return; if (auditionBusy) return;
auditionBusy = true; auditionBusy = true;
@@ -4305,9 +4726,14 @@ async function auditionVoice(lang, v, btn, msg) {
msg.className = 'vmsg'; msg.className = 'vmsg';
} }
if (instruct) msg.textContent = 'with your direction…'; 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 }); const out = await window.engineHost.ttsAudition({ lang, voice: v, instruct });
if (!auditionAudio) auditionAudio = new Audio(); if (!auditionAudio) auditionAudio = new Audio();
if (!out.wav) throw new Error('that voice produced no 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; auditionAudio.src = 'data:audio/wav;base64,' + out.wav;
/* play() rejects on autoplay policy or a decode failure; either way the /* play() rejects on autoplay policy or a decode failure; either way the
operator needs the reason, not a silent button. */ operator needs the reason, not a silent button. */
+25 -1
View File
@@ -380,6 +380,30 @@
<hr> <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> <label style="margin-top:.75rem">Models</label>
<p class="hint" id="modelInfo">—</p> <p class="hint" id="modelInfo">—</p>
<div class="row"> <div class="row">
@@ -409,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>
+302
View File
@@ -232,6 +232,22 @@ const DEFAULT_CONFIG = {
browser sandbox — see ensureTranslator() below for why. */ browser sandbox — see ensureTranslator() below for why. */
mtDtype: 'fp32', mtDtype: 'fp32',
autoConnect: true, 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. */ /* Setup-pane tuning that used to live only in the DOM. */
sensitivity: 3.5, sensitivity: 3.5,
/* /*
@@ -424,6 +440,11 @@ function startServer() {
execPath: nodeBin, execPath: nodeBin,
env: Object.assign({}, process.env, { env: Object.assign({}, process.env, {
CT_FFMPEG: ffmpegPathForChildren(), 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), PORT: String(port),
DATA_DIR: dataDir(), DATA_DIR: dataDir(),
/* Where the spoken clips land, so the server can hand them to phones. */ /* Where the spoken clips land, so the server can hand them to phones. */
@@ -641,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));
@@ -2076,6 +2118,266 @@ app.on('before-quit', (e) => {
if (ttsChild) { try { ttsChild.kill(); } catch (_) {} } 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:get', () => loadConfig());
ipcMain.handle('config:set', (_e, patch) => { ipcMain.handle('config:set', (_e, patch) => {
const merged = saveConfig(patch); const merged = saveConfig(patch);
+3 -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": 112, "buildNumber": 127,
"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",
@@ -50,6 +50,7 @@
"vendor/ort-wasm-simd-threaded.jsep.mjs", "vendor/ort-wasm-simd-threaded.jsep.mjs",
"server/**/*", "server/**/*",
"tts-service.js", "tts-service.js",
"voice-install.js",
"kokoro-sidecar.js", "kokoro-sidecar.js",
"voices.js", "voices.js",
"pt-variants.js", "pt-variants.js",
@@ -106,7 +107,7 @@
] ]
}, },
"asar": false, "asar": false,
"buildVersion": "112", "buildVersion": "127",
"win": { "win": {
"target": [ "target": [
{ {
+5
View File
@@ -21,6 +21,11 @@ contextBridge.exposeInMainWorld('engineHost', {
/* Translation lives in the main process; these are its only two verbs. */ /* Translation lives in the main process; these are its only two verbs. */
mtEnsure: () => ipcRenderer.invoke('mt:ensure'), 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'), settingsExport: () => ipcRenderer.invoke('settings:export'),
settingsImport: () => ipcRenderer.invoke('settings:import'), settingsImport: () => ipcRenderer.invoke('settings:import'),
audioDevices: () => ipcRenderer.invoke('audio:devices'), audioDevices: () => ipcRenderer.invoke('audio:devices'),
+15 -1
View File
@@ -219,7 +219,21 @@ class LanguageStream {
this.dropTimes = []; // when, for a "recently" view on the operator page this.dropTimes = []; // when, for a "recently" view on the operator page
this.building = []; // frames of the segment being filled this.building = []; // frames of the segment being filled
this.segments = []; // { seq, body, frames } 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.timer = null;
this.lastTick = 0; this.lastTick = 0;
this.carry = 0; // fractional frames owed from the last tick this.carry = 0; // fractional frames owed from the last tick
+113 -14
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);
@@ -464,6 +475,7 @@
drift: null, drift: null,
watchdog: null, // catches engines that stall on HLS without erroring watchdog: null, // catches engines that stall on HLS without erroring
everPlayed: false, // this engine has PROVEN it can play HLS 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 // clip-fallback state
ctx: null, ctx: null,
keepAlive: null, keepAlive: null,
@@ -508,7 +520,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(); });
}); });
@@ -574,9 +595,13 @@
/* Give up on streaming and use per-sentence clips. The clip path was already /* 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 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. */ been spent — otherwise a browser that lies about HLS would end up silent. */
function fallBackToClips() { function fallBackToClips(forVoice) {
if (srv.mode === 'clips') return; if (srv.mode === 'clips') return;
srv.mode = 'clips'; 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(); stopStream();
if (srv.keepAlive) srv.keepAlive.play().catch(function () {}); if (srv.keepAlive) srv.keepAlive.play().catch(function () {});
try { console.warn('live stream unsupported here — using per-sentence clips'); } catch (_) {} 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 * 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 +661,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
@@ -823,14 +873,27 @@
*/ */
function applyVoiceMode() { function applyVoiceMode() {
if (srv.voice) { if (srv.voice) {
fallBackToClips(); fallBackToClips(true);
return; return;
} }
/* Back on the default: allow streaming again. fallBackToClips latches /* Back on the default: allow streaming again. fallBackToClips latches
mode to 'clips' and maybeStartStream refuses to act on that, so the 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 latch has to be released here or the listener stays on clips for the
rest of the service. */ 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 = ''; srv.mode = '';
maybeStartStream(); maybeStartStream();
} }
@@ -864,22 +927,52 @@
function switchAudioLanguage(lang) { function switchAudioLanguage(lang) {
stopClips(); // the old language stops NOW, mid-word stopClips(); // the old language stops NOW, mid-word
if (srv.mode === 'stream' && srv.el) { 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. */ * STOP the old stream, do not merely pause it; the new one starts when
try { srv.el.pause(); } catch (_) {} * the server's hello confirms the new language's stream exists.
if (srv.retry) { clearTimeout(srv.retry); srv.retry = null; } *
* 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(); setMediaSession();
} }
// ------------------------------------------------- per-clip fallback path // ------------------------------------------------- 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 /* Fetch, decode and schedule one clip so it follows the previous one. Only
used where native HLS is missing. */ used where native HLS is missing. */
function playClip(frame) { function playClip(frame) {
var name = frame && frame.name; var name = frame && frame.name;
if (srv.mode === 'stream') return; // the stream already carries it if (srv.mode === 'stream') return; // the stream already carries it
if (!tts.on || !srv.ctx || !name) return; if (!tts.on || !srv.ctx || !name) return;
if (!clipIsOurs(frame)) return;
fetch('/audio/' + encodeURIComponent(name)) fetch('/audio/' + encodeURIComponent(name))
.then(function (r) { return r.ok ? r.arrayBuffer() : Promise.reject(new Error('missing')); }) .then(function (r) { return r.ok ? r.arrayBuffer() : Promise.reject(new Error('missing')); })
@@ -891,6 +984,8 @@
}) })
.then(function (audio) { .then(function (audio) {
if (!tts.on || !srv.ctx) return; 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(); var src = srv.ctx.createBufferSource();
src.buffer = audio; src.buffer = audio;
src.connect(srv.ctx.destination); src.connect(srv.ctx.destination);
@@ -1379,7 +1474,11 @@
} }
/* The playlist is guaranteed to exist from this moment — the server /* The playlist is guaranteed to exist from this moment — the server
creates the stream when this listener registers. */ 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 /* 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 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. */ a listener keeps staring at a voice chooser that controls nothing. */
+83 -11
View File
@@ -500,16 +500,57 @@ const ENGINE_STALE_MS = 12000;
* with one plain sentence does. Computed here so every operator surface — * with one plain sentence does. Computed here so every operator surface —
* in-app pane, browser page — shows the same verdict. * 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) { function healthVerdict(connected, stale) {
const es = state.engineStatus || {}; const es = state.engineStatus || {};
if (!connected) return { level: 'red', detail: 'The translation app is not connected.' }; 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 (stale) return { level: 'red', detail: 'The translation app has stopped responding.' };
if (!state.live) return { level: 'green', detail: 'Ready.' }; if (!state.live) return { level: 'green', detail: 'Ready.' };
if (!es.modelsReady) return { level: 'amber', detail: 'Models are still loading.' }; 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.' }; * RECENTLY, not ever.
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.' }; * 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) if (es.cloud && es.cloud.parked)
return { level: 'amber', detail: 'Natural voices are down — the built-in voices have taken over.' }; 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 /* 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 (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 ------------------------------- // --- Companion / Stream Deck control -------------------------------
// GET or POST, token in ?key= or the X-Control-Token header. // 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; if (!m.voice) row.audio[m.lang] = m.name;
delayed((who) => { 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 /* Which voice this clip IS. '' is the operator's default, which is
what a listener who never opened the chooser is on. */ what a listener who never opened the chooser is on. */
const clipVoice = String(m.voice || ''); 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) { for (const [ws, meta] of state.listeners) {
if (ws.readyState !== 1) continue; if (ws.readyState !== 1) continue;
if (!who(meta)) continue; if (!who(meta)) continue;
@@ -1998,14 +2062,22 @@ wss.on('connection', (ws, req) => {
if (m.t === 'lang') { if (m.t === 'lang') {
const meta = state.listeners.get(ws); const meta = state.listeners.get(ws);
if (!meta) return; 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 * A voice belongs to a language. Carrying a Spanish speaker across to
* Portuguese would ask the engine for a voice that language does not * Portuguese would ask the engine for a voice that language does not
* have, so switching language always returns the listener to the * have, so switching language always returns the listener to the
* default and hands them the new language's options. * 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(); pushLanguagesToEngine();
/* /*
* builtInVoice MUST be here, not just on the first hello. The page does * 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, send(ws, { t: 'hello', live: state.live, lang: meta.lang, serviceName: config.serviceName, build: BUILD,
builtInVoice: Voices.hasVoice(meta.lang), builtInVoice: Voices.hasVoice(meta.lang),
voiceOptions: voiceOptionsFor(meta.lang), voice: '', voiceOptions: voiceOptionsFor(meta.lang), voice: meta.voice || '',
scrollback: scrollbackFor(meta.lang, meta.proxied) }); scrollback: scrollbackFor(meta.lang, meta.proxied) });
broadcastOperators(); broadcastOperators();
} }
+39
View File
@@ -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
View File
@@ -42,9 +42,6 @@ let VOICE_DIR = process.platform === 'win32'
: path.join(os.homedir(), 'Library/Application Support/Church Translate Voices'); : path.join(os.homedir(), 'Library/Application Support/Church Translate Voices');
let CLIP_DIR = path.join(os.tmpdir(), 'church-translate-audio'); 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 /* What each voice says when auditioned — a real line of scripture, so the
operator hears the voice on the material it will actually read. */ operator hears the voice on the material it will actually read. */
const SAMPLE_TEXT = { const SAMPLE_TEXT = {
@@ -65,8 +62,25 @@ const SAMPLE_TEXT = {
hat_Latn: 'Senyè a se gadò mwen, mwen p ap manke anyen.' 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 * 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. * 359 files, same total bytes — and it is now the same code on both platforms.
*/ */
function installVoice(v) { 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) => { return new Promise((resolve, reject) => {
fs.mkdirSync(VOICE_DIR, { recursive: true }); const fail = (why) => reject(new Error('could not install ' + v.bundle + ': ' + why));
const url = voices.bundleUrl(v.bundle);
reply({ type: 'voiceProgress', bundle: v.bundle, status: 'downloading' }); reply({ type: 'voiceProgress', bundle: v.bundle, status: 'downloading' });
let w;
(async () => { try {
const res = await fetch(url, { redirect: 'follow' }); const { Worker } = require('worker_threads');
if (!res.ok) throw new Error('HTTP ' + res.status); w = new Worker(path.join(__dirname, 'voice-install.js'), {
const body = Buffer.from(await res.arrayBuffer()); workerData: { url: voices.bundleUrl(v.bundle), voiceDir: VOICE_DIR, bundle: v.bundle }
/* 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);
}); });
extract.on('finish', res2); } catch (e) { return fail(e.message); }
extract.on('error', rej2); let settled = false;
Readable.from(body).pipe(bunzip()).on('error', rej2).pipe(extract); 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();
}); });
w.on('error', (e) => done((e && e.message) || String(e)));
reply({ type: 'voiceProgress', bundle: v.bundle, status: 'ready' }); w.on('exit', (code) => { if (!settled) done('the installer exited (' + code + ')'); });
})().then(resolve, (err) => reject(new Error('could not install ' + v.bundle + ': ' + err.message)));
}); });
} }
async function engineFor(lang, explicit) { /*
/* Auditions key the cache by bundle so trying five voices for one language * The engine for one voice, built at most once however many ask for it.
does not evict the voice that language is actually using. */ *
const v = explicit || builtinVoiceFor(lang); * KEYED BY BUNDLE — and the old keying is what put batches of four
const key = explicit ? "audition:" + v.bundle : lang; * "audition:" loads in the production log with nobody at the Voices card.
if (loaded.has(key)) { * Engines were keyed two ways: by language ('spa_Latn') for the preload, and
const e = loaded.get(key); * '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(); 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; return e.tts;
} }
if (!v) throw new Error('no built-in voice for ' + lang); /*
if (!isInstalled(v)) await installVoice(v); * 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
if (!sherpa) sherpa = require('sherpa-onnx-node'); * ones, and a live engine only ever gives way to another live one.
*/
const dir = voicePath(v); function makeRoom(live) {
const tts = new sherpa.OfflineTts(voices.ttsConfig(dir, v)); const stalest = (wantLive) => {
let k = null;
/* Evict the least recently used rather than growing without bound. */ for (const [key, x] of loaded) if (x.live === wantLive && (!k || x.usedAt < loaded.get(k).usedAt)) k = key;
if (loaded.size >= MAX_LOADED) { return k;
let oldest = null; };
for (const [k, e] of loaded) if (!oldest || e.usedAt < loaded.get(oldest).usedAt) oldest = k; const evict = (k) => { log('evicted voice ' + k + (loaded.get(k).live ? '' : ' (audition)')); loaded.delete(k); };
if (oldest) { loaded.delete(oldest); log('evicted voice ' + oldest); } 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;
loaded.set(key, { tts, usedAt: Date.now() }); const k = stalest(false) || (live ? stalest(true) : null);
log('voice ready: ' + key + ' (' + v.bundle + ')'); if (!k) return false;
return tts; evict(k);
return true;
} }
// ------------------------------------------------------------- cloud voices // ------------------------------------------------------------- cloud voices
@@ -928,6 +981,9 @@ async function speak(job) {
text, Math.round((Number(job.gainDb) || 0) * 2) / 2, pinRate text, Math.round((Number(job.gainDb) || 0) * 2) / 2, pinRate
].join('\u0001'); ].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) { if (key) {
const hit = synthCache.get(key); const hit = synthCache.get(key);
if (hit) { if (hit) {
@@ -941,8 +997,23 @@ async function speak(job) {
counted as one; the key is deleted below so the NEXT utterance counted as one; the key is deleted below so the NEXT utterance
tries the natural voice again. */ tries the natural voice again. */
if (premiumPick && done.source !== premiumPick.provider) { cloudStats.fallback++; fallbackAt = Date.now(); } 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); return cloneClip(done, job.lang, started);
} }
piperOnly = true;
}
} catch (_) { /* the first caller failed — make our own */ } } 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 * alternate skips the premium path entirely — otherwise cloudSynth would
* fall back to the operator's own pick and the page would lie. * 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 ? null
: await cloudSynth(job.lang, text, (alt && alt.engine !== 'builtin') ? alt : null, { speed: kokoroSpeed, deadline }); : await cloudSynth(job.lang, text, (alt && alt.engine !== 'builtin') ? alt : null, { speed: kokoroSpeed, deadline });
if (cloudOut) { if (cloudOut) {
@@ -984,13 +1055,16 @@ async function speak(job) {
source: cloudOut.provider 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++; cloudStats.fallback++;
fallbackAt = Date.now(); fallbackAt = Date.now();
if (cloudStats.fallback === 1 || cloudStats.fallback % 10 === 0) pushStats('fallback'); 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'); if (!sherpa) sherpa = require('sherpa-onnx-node');
/* /*
@@ -1067,6 +1141,9 @@ async function speak(job) {
const seconds = secs; const seconds = secs;
return { return {
source: 'piper', 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 /* What the sentence ended on — the stream gives a full stop a full breath
and a mid-thought fragment only a beat. */ and a mid-thought fragment only a beat. */
terminal: text.slice(-1), 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(); const promise = produce();
if (key) synthCache.set(key, { promise, at: Date.now() }); if (cacheKey) synthCache.set(cacheKey, { promise, at: Date.now() });
try { try {
const out = await promise; 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 * miss would have re-created the very voice switch this file exists to
* prevent (measured in the harness before shipping, not after). * 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; return out;
} catch (e) { } catch (e) {
if (key) synthCache.delete(key); if (cacheKey) synthCache.delete(cacheKey);
throw e; throw e;
} }
} }
@@ -1187,11 +1268,15 @@ process.on('message', async (msg) => {
if (msg.type === 'voices') { if (msg.type === 'voices') {
/* The operator changed a per-language voice choice. */ /* The operator changed a per-language voice choice. */
voiceChoice = msg.choices || {}; 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. */ * Nothing to drop. Engines are keyed by bundle, so a changed choice
for (const k of Array.from(loaded.keys())) { * resolves to a different engine on the next sentence by itself, and
if (!String(k).startsWith("audition:")) loaded.delete(k); * 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 }); reply({ type: 'voicesOk', count: Object.keys(voiceChoice).length });
return; return;
} }
@@ -1222,7 +1307,9 @@ process.on('message', async (msg) => {
} finally { voiceChoice = saved; } } finally { voiceChoice = saved; }
} else { } else {
if (!sherpa) sherpa = require('sherpa-onnx-node'); 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 }); const audio = tts.generate({ text, sid: spec.sid || 0, speed: spec.speed || 1.0 });
if (spec.gain && spec.gain !== 1) { if (spec.gain && spec.gain !== 1) {
for (let i = 0; i < audio.samples.length; i++) { 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 /* Warm the languages people are actually listening to, so the first
sentence of a service is not the one that pays for the download. */ sentence of a service is not the one that pays for the download. */
for (const lang of msg.langs || []) { 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 || [] }); reply({ type: 'preloaded', langs: msg.langs || [] });
return; return;
+78
View File
@@ -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) });
});
+67 -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,44 @@ 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. `/operator`
returning 404 through the proxy is by design; staff use
`http://10.1.7.39:3100/operator` on the LAN.
**Guest WiFi on UniFi.** Guest networks block every private range by default,
so the proxy is unreachable from them until two things change: a firewall
policy that allows the guest zone to reach the proxy on 80/443, and the
proxy's range removed from the guest hotspot's *Post-Authorization
Restrictions*. The second one is easy to miss: the access points enforce that
list themselves, so with only the firewall policy a phone works for a minute or
two and then times out. North Attleboro's fix, with scripts, is in
[`examples/waters-church-na/`](examples/waters-church-na/README.md) section 3.
+119
View File
@@ -0,0 +1,119 @@
# 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. Waters Guest WiFi at North Attleboro — done 2026-10-15
The Guest network (VLAN 25, 192.168.16.0/24) is in the UniFi **Hotspot** zone.
Two things stood between a phone on Waters Guest and `10.1.1.4`:
| Blocker | Fix |
| --- | --- |
| Zone default "Block All Traffic" Hotspot → Internal | User policy "Guest to translate.watersmedia.org (Boba-Fett web)", ALLOW Hotspot → Internal, destination `10.1.1.4` TCP 80 and 443, index 10000 (evaluated first). Created with [`zone-allow-policy.py`](zone-allow-policy.py). |
| Guest "Post-Authorization Restrictions" listed 10.0.0.0/8. The list is enforced twice: as system policies on the gateway (index 30001, which the allow rule sits above) **and on the access points**, which no gateway policy can override. Symptom: a fresh guest client worked for about two minutes, then timed out even to `http://10.1.1.4/`. | Removed 10.0.0.0/8 from the list with [`guest-restrictions.py`](guest-restrictions.py). The Integration API refuses to edit the system policies; the script edits the legacy `guest_access` setting (`restricted_subnet_1..3`) and the four gateway policies regenerate from it. 192.168.0.0/16 and 172.16.0.0/12 stay. Guests are still cut off from every other 10.x host by the zone default block. |
| The Waters Guest SSID only broadcast from the AP "The Vault" | Added Auditorium Left, Auditorium Right, Main Lobby Entrance, Admin Offices - U7 and Creative Offices - U7 with [`wifi-broadcast-devices.py`](wifi-broadcast-devices.py). |
Guest clients get DNS from the gateway (DHCP hands out no custom DNS), so the
local record resolves for them. The SSID keeps client isolation on, which only
separates guest devices from each other, not from the gateway.
```bash
python3 docs/examples/waters-church-na/zone-allow-policy.py north-attleboro Hotspot 10.1.1.4 80,443 --name "Guest to translate.watersmedia.org (Boba-Fett web)" --apply
python3 docs/examples/waters-church-na/guest-restrictions.py north-attleboro --remove 10.0.0.0/8 --apply
python3 docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" --list
```
To undo: delete the allow policy in Settings → Policy Engine (or Firewall) on
the NA console, put 10.0.0.0/8 back under the guest hotspot's
Post-Authorization Restrictions, and remove the APs with
`wifi-broadcast-devices.py --remove`.
## 4. 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"
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Remove a subnet from the guest "Post-Authorization Restrictions" on one UniFi console.
UniFi blocks guest (Hotspot zone) clients from the private ranges listed in the
guest hotspot's Post-Authorization Restrictions. That list is enforced in two
places: as system firewall policies on the gateway (one per zone pair, which a
user policy can sit above) and on the access points themselves, which a gateway
policy cannot override. To let guests reach one internal host you therefore
remove its range from the restriction list and rely on the zone firewall's
default Hotspot -> Internal block plus an explicit allow (zone-allow-policy.py).
Usage:
docs/examples/waters-church-na/guest-restrictions.py north-attleboro --show
docs/examples/waters-church-na/guest-restrictions.py north-attleboro --remove 10.0.0.0/8 # dry run
docs/examples/waters-church-na/guest-restrictions.py north-attleboro --remove 10.0.0.0/8 --apply
It updates every "Post-Authorization Restrictions" policy whose source is the
Hotspot zone through the Network Integration API, then the legacy
guest_access setting (restricted_subnet_1..3) that the access points read.
Needs the per-console Network API key at ~/.config/unifi/<site>-network-api-key
(see unifi-dns-record.py). The key is only ever sent in the X-API-KEY header.
"""
from __future__ import annotations
import argparse
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
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 = 20
POLICY_NAME = "Post-Authorization Restrictions"
class Console:
def __init__(self, name: str):
self.name = name
self.gateway = SITES[name]
with open(os.path.join(KEY_DIR, f"{name}-network-api-key")) as f:
self.key = f.read().strip()
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
def request(self, method: str, path: str, body: dict | None = None):
url = f"https://{self.gateway}/proxy/network{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"X-API-KEY": self.key, "Accept": "application/json",
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT, context=self.ctx) as r:
raw = r.read().decode()
except urllib.error.HTTPError as e:
raise SystemExit(f"{method} {path} -> HTTP {e.code}: {e.read().decode(errors='replace')[:600]}")
return json.loads(raw) if raw else None
def site_id(self) -> str:
return self.request("GET", "/integration/v1/sites")["data"][0]["id"]
def zones(self, sid: str) -> dict[str, str]:
return {z["id"]: z["name"] for z in self.request("GET", f"/integration/v1/sites/{sid}/firewall/zones?limit=200")["data"]}
def restriction_policies(self, sid: str, zones: dict[str, str]) -> list[dict]:
pols = self.request("GET", f"/integration/v1/sites/{sid}/firewall/policies?limit=500")["data"]
return [p for p in pols if p.get("name") == POLICY_NAME and zones.get(p["source"]["zoneId"]) == "Hotspot"]
def guest_access(self) -> dict:
for s in self.request("GET", "/api/s/default/get/setting")["data"]:
if s.get("key") == "guest_access":
return s
raise SystemExit("no guest_access setting found")
def items_of(p: dict) -> list[str]:
f = (p.get("destination") or {}).get("trafficFilter") or {}
return [i["value"] for i in (f.get("ipAddressFilter") or {}).get("items", [])]
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("site", choices=sorted(SITES))
ap.add_argument("--show", action="store_true", help="print the current restrictions and exit")
ap.add_argument("--remove", help="subnet to remove, e.g. 10.0.0.0/8")
ap.add_argument("--apply", action="store_true", help="actually write the change")
a = ap.parse_args()
c = Console(a.site)
sid = c.site_id()
zones = c.zones(sid)
pols = c.restriction_policies(sid, zones)
ga = c.guest_access()
legacy = {k: v for k, v in ga.items() if k.startswith("restricted_subnet_")}
print(f"{a.site} ({c.gateway}) guest Post-Authorization Restrictions:")
for p in pols:
print(f" gateway policy Hotspot -> {zones.get(p['destination']['zoneId']):8} index {p['index']}: {', '.join(items_of(p))}")
print(f" legacy guest_access (access points): {json.dumps(legacy)}")
if a.show or not a.remove:
return 0
targets = [p for p in pols if a.remove in items_of(p)]
legacy_hits = {k: v for k, v in legacy.items() if v == a.remove}
if not targets and not legacy_hits:
print(f"\n{a.remove} is not in any restriction list; nothing to do")
return 0
print(f"\nwould remove {a.remove} from legacy field(s) {list(legacy_hits)}; the {len(targets)} gateway policy(ies) listing it regenerate from that setting")
if not a.apply:
print("DRY RUN, add --apply to write it")
return 0
# The gateway policies are SYSTEM_DEFINED and the Integration API refuses to
# edit them (api.firewall.policy.operation.system-defined-firewall-policy-update-forbidden).
# They are generated from the legacy guest_access restricted_subnet_* fields, so
# that setting is the one lever; the policies and the APs follow it.
if not legacy_hits:
print(" no legacy restricted_subnet field holds it; the gateway policies cannot be edited directly")
return 1
# Re-pack the remaining subnets into slots 1..n and blank the rest.
remaining = [v for k, v in sorted(legacy.items()) if v and v != a.remove]
payload = {f"restricted_subnet_{i}": (remaining[i - 1] if i <= len(remaining) else "") for i in range(1, 4)}
print(f" POST /api/s/default/set/setting/guest_access {json.dumps(payload)}")
c.request("POST", "/api/s/default/set/setting/guest_access", payload)
ga2 = c.guest_access()
print(" legacy guest_access now:", json.dumps({k: v for k, v in ga2.items() if k.startswith("restricted_subnet_")}))
for p in c.restriction_policies(sid, zones):
print(f" gateway policy Hotspot -> {zones.get(p['destination']['zoneId']):8} now: {', '.join(items_of(p))}")
print("done; access points re-provision within a minute, guests should reconnect")
return 0
if __name__ == "__main__":
sys.exit(main())
+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())
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Change which access points broadcast a WiFi network (SSID) on one UniFi console.
Usage:
docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" --list
docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" \
--add "Auditorium Left,Auditorium Right,Main Lobby Entrance" # dry run
docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" \
--add "Auditorium Left,Auditorium Right,Main Lobby Entrance" --apply # write it
docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" --remove "The Vault" --apply
docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" --all --apply # every AP
Names are matched exactly against the console's device names (--list prints
them). Without --apply nothing is written: the script prints the APs the SSID
is on now and the list it would send. The whole broadcast object is PUT back
with only broadcastingDeviceFilter changed.
Needs the per-console Network API key at ~/.config/unifi/<site>-network-api-key
(see unifi-dns-record.py). The key is only ever sent in the X-API-KEY header.
"""
from __future__ import annotations
import argparse
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
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 = 20
class Console:
def __init__(self, name: str):
self.name = name
self.gateway = SITES[name]
with open(os.path.join(KEY_DIR, f"{name}-network-api-key")) as f:
self.key = f.read().strip()
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
def request(self, method: str, path: str, body: dict | None = None):
url = f"https://{self.gateway}/proxy/network/integration/v1{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"X-API-KEY": self.key, "Accept": "application/json",
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT, context=self.ctx) as r:
raw = r.read().decode()
except urllib.error.HTTPError as e:
raise SystemExit(f"{method} {path} -> HTTP {e.code}: {e.read().decode(errors='replace')[:600]}")
return json.loads(raw) if raw else None
def site_id(self) -> str:
return self.request("GET", "/sites")["data"][0]["id"]
def access_points(self, sid: str) -> dict[str, str]:
devs = self.request("GET", f"/sites/{sid}/devices?limit=200")["data"]
return {d["name"]: d["id"] for d in devs if "accessPoint" in (d.get("features") or [])}
def broadcast(self, sid: str, ssid: str) -> dict:
for b in self.request("GET", f"/sites/{sid}/wifi/broadcasts?limit=200")["data"]:
if b.get("name") == ssid:
return b
raise SystemExit(f"no WiFi network named {ssid!r} on {self.name}")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("site", choices=sorted(SITES))
ap.add_argument("ssid", help="WiFi network name as shown in the console")
ap.add_argument("--add", default="", help="comma-separated AP names to add")
ap.add_argument("--remove", default="", help="comma-separated AP names to remove")
ap.add_argument("--all", action="store_true", help="broadcast on every AP (clears the device filter)")
ap.add_argument("--list", action="store_true", help="print the APs and where the SSID broadcasts, then exit")
ap.add_argument("--apply", action="store_true", help="actually write the change")
a = ap.parse_args()
c = Console(a.site)
sid = c.site_id()
aps = c.access_points(sid)
by_id = {v: k for k, v in aps.items()}
b = c.broadcast(sid, a.ssid)
filt = b.get("broadcastingDeviceFilter") or {}
current = list(filt.get("deviceIds", [])) if filt.get("type") == "DEVICES" else None
def names(ids):
return ", ".join(by_id.get(i, i) for i in ids)
print(f"{a.site}: {a.ssid!r} broadcasts on " + ("ALL access points" if current is None else f"{len(current)} AP(s): {names(current)}"))
if a.list:
print("\nAccess points on this console:")
for n in sorted(aps):
print(f" {'*' if current is None or aps[n] in current else ' '} {n}")
return 0
add = [s.strip() for s in a.add.split(",") if s.strip()]
rem = [s.strip() for s in a.remove.split(",") if s.strip()]
unknown = [n for n in add + rem if n not in aps]
if unknown:
raise SystemExit(f"unknown AP name(s): {', '.join(unknown)}; use --list")
if not (add or rem or a.all):
ap.error("nothing to change: give --add, --remove or --all")
new_b = {k: v for k, v in b.items() if k not in ("id", "metadata")}
if a.all:
new_b.pop("broadcastingDeviceFilter", None)
summary = "ALL access points"
else:
ids = list(current or [])
for n in add:
if aps[n] not in ids:
ids.append(aps[n])
ids = [i for i in ids if i not in {aps[n] for n in rem}]
new_b["broadcastingDeviceFilter"] = {"type": "DEVICES", "deviceIds": ids}
summary = f"{len(ids)} AP(s): {names(ids)}"
print(f"would broadcast on {summary}")
if not a.apply:
print("DRY RUN, add --apply to write it")
return 0
out = c.request("PUT", f"/sites/{sid}/wifi/broadcasts/{b['id']}", new_b)
f2 = (out or {}).get("broadcastingDeviceFilter") or {}
print("UPDATED: now on " + ("ALL access points" if f2.get("type") != "DEVICES" else names(f2.get("deviceIds", []))))
return 0
if __name__ == "__main__":
sys.exit(main())
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Create a zone-based firewall policy on one UniFi console that allows TCP from a
source zone to one destination IP on given ports (e.g. guest WiFi -> a LAN web host).
Usage:
docs/examples/waters-church-na/zone-allow-policy.py north-attleboro Hotspot 10.1.1.4 80,443 \
--name "Guest to translate.watersmedia.org (Boba-Fett web)" # dry run
docs/examples/waters-church-na/zone-allow-policy.py north-attleboro Hotspot 10.1.1.4 80,443 \
--name "Guest to translate.watersmedia.org (Boba-Fett web)" --apply # create it
docs/examples/waters-church-na/zone-allow-policy.py north-attleboro --list-zones
Without --apply nothing is written: the script prints the zones it resolved, any
existing policy with the same name, and the exact JSON it would POST. The
destination zone is found from the console's networks (the one whose subnet
contains the IP). User-defined policies are evaluated before the system ones
(index >= 30000), so the allow lands above "Post-Authorization Restrictions".
Needs the per-console Network API key at ~/.config/unifi/<site>-network-api-key
(see unifi-dns-record.py). The key is only ever sent in the X-API-KEY header.
"""
from __future__ import annotations
import argparse
import ipaddress
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
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 = 20
class Console:
def __init__(self, name: str):
self.name = name
self.gateway = SITES[name]
with open(os.path.join(KEY_DIR, f"{name}-network-api-key")) as f:
self.key = f.read().strip()
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
def request(self, method: str, path: str, body: dict | None = None):
url = f"https://{self.gateway}/proxy/network{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"X-API-KEY": self.key, "Accept": "application/json",
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT, context=self.ctx) as r:
raw = r.read().decode()
except urllib.error.HTTPError as e:
raise SystemExit(f"{method} {path} -> HTTP {e.code}: {e.read().decode(errors='replace')[:600]}")
return json.loads(raw) if raw else None
def site_id(self) -> str:
return self.request("GET", "/integration/v1/sites")["data"][0]["id"]
def zones(self, sid: str) -> dict[str, str]:
data = self.request("GET", f"/integration/v1/sites/{sid}/firewall/zones?limit=200")["data"]
return {z["name"]: z["id"] for z in data}
def zone_for_ip(self, sid: str, ip: str) -> tuple[str, str]:
"""Zone id and network name whose subnet contains ip (via the legacy networkconf)."""
nets = {n["id"]: n for n in self.request("GET", f"/integration/v1/sites/{sid}/networks?limit=200")["data"]}
addr = ipaddress.ip_address(ip)
for n in self.request("GET", "/api/s/default/rest/networkconf")["data"]:
subnet = n.get("ip_subnet")
if not subnet:
continue
if addr in ipaddress.ip_interface(subnet).network:
zone_id = nets.get(n.get("external_id", ""), {}).get("zoneId")
if zone_id:
return zone_id, n["name"]
raise SystemExit(f"no gateway network on {self.name} contains {ip}")
def policies(self, sid: str) -> list[dict]:
return self.request("GET", f"/integration/v1/sites/{sid}/firewall/policies?limit=500")["data"]
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("site", choices=sorted(SITES))
ap.add_argument("source_zone", nargs="?", help="source zone name, e.g. Hotspot")
ap.add_argument("dest_ip", nargs="?", help="destination IPv4 address")
ap.add_argument("ports", nargs="?", help="comma-separated TCP ports, e.g. 80,443")
ap.add_argument("--name", help="policy name (default is generated)")
ap.add_argument("--apply", action="store_true", help="actually create the policy")
ap.add_argument("--list-zones", action="store_true", help="print the console's zones and exit")
a = ap.parse_args()
c = Console(a.site)
sid = c.site_id()
zones = c.zones(sid)
if a.list_zones:
for name, zid in sorted(zones.items()):
print(f"{name:12} {zid}")
return 0
if not (a.source_zone and a.dest_ip and a.ports):
ap.error("source_zone, dest_ip and ports are required unless --list-zones")
if a.source_zone not in zones:
raise SystemExit(f"zone {a.source_zone!r} not on {a.site}; have: {', '.join(sorted(zones))}")
ports = [int(p) for p in a.ports.split(",")]
dest_zone, dest_net = c.zone_for_ip(sid, a.dest_ip)
dest_zone_name = next(n for n, z in zones.items() if z == dest_zone)
name = a.name or f"{a.source_zone} to {a.dest_ip} tcp {a.ports}"
body = {
"name": name,
"enabled": True,
"action": {"type": "ALLOW", "allowReturnTraffic": True},
"source": {"zoneId": zones[a.source_zone]},
"destination": {
"zoneId": dest_zone,
"trafficFilter": {
"type": "IP_ADDRESS",
"ipAddressFilter": {"type": "IP_ADDRESSES", "matchOpposite": False,
"items": [{"type": "IP_ADDRESS", "value": a.dest_ip}]},
"portFilter": {"type": "PORTS", "matchOpposite": False,
"items": [{"type": "PORT_NUMBER", "value": p} for p in ports]},
},
},
"ipProtocolScope": {"ipVersion": "IPV4",
"protocolFilter": {"type": "NAMED_PROTOCOL", "protocol": {"name": "TCP"},
"matchOpposite": False}},
"loggingEnabled": False,
}
print(f"{a.site} ({c.gateway}): {a.source_zone} -> {dest_zone_name} ({dest_net}) {a.dest_ip} tcp {a.ports}")
existing = [p for p in c.policies(sid) if p.get("name") == name]
if existing:
print(f"a policy named {name!r} already exists (id {existing[0]['id']}, index {existing[0].get('index')}); nothing to do")
return 0
if not a.apply:
print("DRY RUN, would POST /integration/v1/sites/<site>/firewall/policies:")
print(json.dumps(body, indent=2))
return 0
created = c.request("POST", f"/integration/v1/sites/{sid}/firewall/policies", body)
print(f"CREATED id {created.get('id')} index {created.get('index')} enabled {created.get('enabled')}")
blockers = [p for p in c.policies(sid) if p.get("name") == "Post-Authorization Restrictions"
and p["source"]["zoneId"] == zones[a.source_zone] and p["destination"]["zoneId"] == dest_zone]
for b in blockers:
order = "above" if created.get("index", 0) < b.get("index", 0) else "BELOW"
print(f"new policy sits {order} 'Post-Authorization Restrictions' (index {b.get('index')})")
return 0
if __name__ == "__main__":
sys.exit(main())