Files
church-translate/app/index.html
T
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

461 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="dark">
<!--
Model weights come from Hugging Face on first run. The congregation server is
this app's own child process on another localhost port, which is why img-src
admits 127.0.0.1 — that is where the QR code is rendered. Everything
executable stays 'self' plus what the wasm ONNX Runtime needs.
media-src carries data:/blob: because voice previews are synthesized inside
this app and handed straight to an <audio> element. NOTE: the policy below is
a plain directive list — CSP has no comment syntax, and a /* */ comment placed
inside it silently swallows the next directive (that is exactly how media-src
went missing once, and every voice preview failed with "no supported source").
-->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'wasm-unsafe-eval' blob:;
worker-src 'self' blob:;
connect-src 'self' https: ws: wss: data: blob: http://127.0.0.1:*;
img-src 'self' data: blob: http://127.0.0.1:*;
media-src 'self' data: blob: http://127.0.0.1:*;
style-src 'self' 'unsafe-inline';
font-src 'self' data:;
">
<title>Church Translate</title>
<link rel="stylesheet" href="./engine.css">
</head>
<body>
<header class="head">
<div class="brand">
<span class="mark"></span>
<span>Church&nbsp;Translate</span>
<span class="build" id="buildTag" title="Version and build number">—</span>
</div>
<div class="head-right">
<span id="srvDot" class="pip" title="Church server"></span>
<span id="srvText" class="pip-label">Not connected</span>
</div>
</header>
<main class="page">
<!-- Always first, always full width. Nothing above it, nothing beside it. -->
<section class="hero" id="hero" data-state="setup">
<div class="hero-icon" id="heroIcon"></div>
<h1 id="heroTitle">Getting ready…</h1>
<p id="heroBody">Checking your setup.</p>
<button class="hero-go" id="heroGo" hidden></button>
<button class="btn hold-btn" id="heroHold" hidden></button>
<p class="holdbar" id="holdBar" hidden>
<b>ON HOLD</b> — translation is running and you can see every word, but
nothing is reaching the congregation.</p>
<p class="op-health" id="opHealth" hidden></p>
<p class="voice-warn" id="voiceWarn" hidden></p>
<div class="stats" id="opStats" hidden>
<div class="stat"><b id="stListeners">0</b><span>listening</span></div>
<div class="stat"><b id="stLangs">0</b><span>languages</span></div>
<div class="stat"><b id="stElapsed">—</b><span>elapsed</span></div>
</div>
<div class="listenwho" id="opWho"></div>
<div class="bar" id="progWrap" hidden><div class="bar-fill" id="progFill"></div></div>
<p class="prog-note" id="progNote" hidden></p>
<button class="btn btn-primary" id="heroBtn" hidden></button>
</section>
<!-- ============================ centre column ============================ -->
<div class="col-main">
<!-- 1. Input ---------------------------------------------------------- -->
<section class="group" id="grpInput" data-open="true">
<button class="group-head" type="button" aria-expanded="true" aria-controls="grpInputBody">
<span class="chev" aria-hidden="true"></span>
<h2>Input</h2>
<span class="group-sub" id="inputSummary"></span>
</button>
<div class="group-body" id="grpInputBody">
<div class="group-inner">
<label for="device">Input device</label>
<div class="row">
<select id="device"></select>
<button class="btn" id="refreshDevices" title="Rescan inputs">Rescan</button>
</div>
<div class="chanbox" id="channelBox" hidden>
<div class="chanhead">
<label style="margin:0">Channels</label>
<span class="hint" id="chanNote" style="margin:0"></span>
<button class="btn" id="chanMixAll" type="button">Mix all</button>
</div>
<div class="chanlist" id="chanList"></div>
<p class="hint">Pick the channel the speaker is on — watch for the bar that moves.
Pick more than one only if two people talk on separate channels.</p>
<p class="qrwarn" id="chanTruth" hidden></p>
</div>
<div class="meter-row">
<div class="meter"><div class="meter-fill" id="meterFill"></div>
<div class="meter-thresh" id="meterThresh"></div>
</div>
<span class="meter-label" id="meterLabel">—</span>
</div>
<p class="hint" id="audioHint">Green = speech. Voice should cross the notch; room noise should not.</p>
<div class="threshrow">
<label class="oprow" style="margin:0">
<input type="checkbox" id="threshManual">
<span>Set the notch myself</span>
</label>
<input type="range" class="outtrim" id="threshSlider" min="-60" max="-20" step="1" disabled>
<span class="outdb" id="threshDb">auto</span>
</div>
<p class="hint" id="threshHint">Following the room: the notch sits above whatever the input
is hearing when nobody is talking. Right on a console or Dante feed. On a preamp with
real hiss it can settle above a quiet talker, and the mic goes dead while the bar still
moves — set it by hand and put the notch just above the room, under the voice.</p>
<h3 class="subhead">What it is hearing</h3>
<div class="monitor" id="monitor"><div class="mon-empty">Nothing yet.</div></div>
</div>
</div>
</section>
<!-- 1b. Output routing -------------------------------------------------- -->
<section class="group" id="grpOut">
<button class="group-head" type="button" aria-expanded="false" aria-controls="grpOutBody">
<span class="chev" aria-hidden="true"></span>
<h2>Output routing</h2>
<span class="group-sub" id="outSummary"></span>
</button>
<div class="group-body" id="grpOutBody">
<div class="group-inner">
<p class="hint">Send each language's voice to its own physical channels — English on
1+2, Spanish on 3, and so on — into Dante, Waves SoundGrid, a console over USB,
anything the Mac can play to. Phones keep working exactly as they do now; this is
an extra output, not a replacement.</p>
<label for="outDevice">Output device</label>
<div class="row">
<select id="outDevice"></select>
<button class="btn" id="outRefresh" title="Rescan outputs">Rescan</button>
</div>
<p class="hint" id="outState"></p>
<div id="outMapBox" hidden>
<label style="margin-top:.75rem">Channels</label>
<p class="hint">Click a number to send that language there. Two channels side by
side join into a stereo pair — the voice is mono, so both carry the same signal,
which is what fills a stereo pair on a console. A language with nothing selected is
not sent to hardware at all.</p>
<p class="hint">The source language carries the <b>live room microphone</b>, not a
translation — there is nothing to translate English into during an English service.
Give it channels and it feeds an assistive-listening transmitter. Keep those
channels off whatever feeds the app's input, or you will build a loop.</p>
<div id="outMap" class="outmap"></div>
</div>
</div>
</div>
</section>
<!-- 2. Languages and voices ------------------------------------------- -->
<section class="group" id="grpLang">
<button class="group-head" type="button" aria-expanded="false" aria-controls="grpLangBody">
<span class="chev" aria-hidden="true"></span>
<h2>Languages and voices</h2>
<span class="group-sub" id="langSummary"></span>
</button>
<div class="group-body" id="grpLangBody">
<div class="group-inner">
<h3 class="subhead">Languages on the front page</h3>
<p class="hint" id="opLangCount"></p>
<div class="row">
<input id="opLangFilter" type="search" placeholder="Filter languages…" autocomplete="off">
<label class="oprow"><input type="checkbox" id="opLangVoiceOnly"><span>With a voice only</span></label>
<button class="btn" id="opLangNone">Turn all off</button>
</div>
<div class="langcols">
<div class="langcol">
<div class="langcolhead">On <b id="opLangNOn">0</b></div>
<div id="opLangOn" class="langlist" role="group" aria-label="Languages on the front page"></div>
</div>
<div class="langcol">
<div class="langcolhead">Off <b id="opLangNOff">0</b></div>
<div id="opLangOff" class="langlist" role="group" aria-label="Languages not on the front page"></div>
</div>
</div>
<label class="oprow">
<input type="checkbox" id="opAllowAll">
<span>Let listeners pick any language</span>
</label>
<p class="hint" id="opAllowAllHint"></p>
<h3 class="subhead">Always translate</h3>
<p class="hint">Pinned languages are translated before anyone arrives.</p>
<div id="opPinned" class="chips"></div>
<input id="opPinSearch" type="search" placeholder="Pin a language…" autocomplete="off">
<div id="opPinResults" class="opresults"></div>
<hr>
<h3 class="subhead">Corrections</h3>
<p class="hint">One per line: <code>what it heard =&gt; what it should say</code></p>
<textarea id="opFixes" rows="8" spellcheck="false"></textarea>
<div class="row">
<button class="btn" id="opSaveFixes">Save corrections</button>
<span class="hint" id="opFixHint"></span>
</div>
<hr>
<h3 class="subhead">Voices</h3>
<p class="hint">Pick a language, press ▶ to hear a voice read scripture, then Use it.
Built-in voices download the first time you play them.</p>
<div class="row">
<select id="voiceLang"></select>
<span class="hint" id="voiceNow"></span>
</div>
<p class="hint" id="voiceCoverage"></p>
<label for="voiceInstruct" style="margin-top:.5rem">Delivery direction <span class="hint" style="display:inline">— local voices only</span></label>
<div class="row">
<input id="voiceInstruct" type="text" autocomplete="off" spellcheck="false"
placeholder="e.g. whisper this softly and intimately, breathy">
<button class="btn" id="voiceInstructClear">Clear</button>
</div>
<p class="hint">Plain English, told to the voice before it speaks. Only the
<b>Local (this Mac)</b> voices listen to it — Piper built-ins have no such control and
will ignore it. It changes <b>delivery</b>, not volume: measured, "whisper this softly
and intimately" stretched the same Spanish line from 4.0s to 6.2s. Wording is not
predictable — "speak loudly and forcefully" came out <i>quieter</i> than plain — so try
a few and judge by ear. Press ▶ on a voice below to hear it with this direction.</p>
<div id="voiceList" class="voicelist"></div>
<hr>
<h3 class="subhead">Premium voices</h3>
<div class="row">
<select id="cloudProvider" style="max-width:11rem">
<option value="off">Off — built-in voices</option>
<option value="kokoro">Kokoro (this Mac — free, fast)</option>
<option value="local">Qwen3 (this Mac — free, expressive)</option>
<option value="fish">fish.audio</option>
<option value="google">Google Cloud TTS</option>
</select>
<span class="hint" id="cloudState"></span>
</div>
<div id="cloudFields" hidden>
<label for="cloudKey">API key</label>
<input id="cloudKey" type="password" autocomplete="off" spellcheck="false">
<label for="cloudVoice">Voice</label>
<input id="cloudVoice" type="text" autocomplete="off" spellcheck="false"
placeholder="fish: default reference id · leave empty for Google">
<label for="cloudPerLang">Per-language voices (one per line: code = voice id)</label>
<textarea id="cloudPerLang" rows="4" spellcheck="false"
placeholder="spa_Latn = es-US-Chirp3-HD-Charon&#10;kor_Hang = ko-KR-Chirp3-HD-Charon"></textarea>
<div class="row">
<button class="btn" id="cloudSave">Save</button>
<button class="btn" id="cloudTest">Test</button>
<span class="hint" id="cloudHint"></span>
</div>
</div>
<p class="hint">Human-sounding voices from a paid service. Keys stay on this Mac.
If the service ever fails, the built-in voices take over automatically.</p>
<hr>
<h3 class="subhead">Voice dynamics</h3>
<label class="oprow">
<input type="checkbox" id="optDynamics">
<span>Follow the speaker's loudness</span>
</label>
<p class="hint">The translated voice gets quieter when the preacher drops to a whisper
and louder when he leans on a line. Applies to built-in and premium voices alike.</p>
<p class="hint"><b>Compressed on purpose.</b> The room spans about 40&nbsp;dB between a
whisper and a shout; reproduced faithfully into headphones or a hearing-aid pack the
whisper is inaudible and the shout is painful. This keeps the shape and holds the
range to about 14&nbsp;dB. Level only — the voice cannot actually whisper.</p>
</div>
</div>
</section>
<!-- 3. Settings -------------------------------------------------------- -->
<section class="group" id="grpSettings">
<button class="group-head" type="button" aria-expanded="false" aria-controls="grpSettingsBody">
<span class="chev" aria-hidden="true"></span>
<h2>Settings</h2>
<span class="group-sub" id="settingsSummary"></span>
</button>
<div class="group-body" id="grpSettingsBody">
<div class="group-inner">
<h3 class="subhead">Stream delay</h3>
<div class="row">
<input id="opDelay" type="number" min="0" max="120" step="1" style="max-width:6rem">
<span class="hint" style="margin:0">seconds</span>
<button class="btn" id="opSaveDelay">Set</button>
<span class="hint" id="opDelayHint"></span>
</div>
<p class="hint">For livestream viewers: hold captions and audio so they line up
with the stream's own delay. The room always sees live. 0 = off.</p>
<hr>
<h3 class="subhead">Bitfocus Companion</h3>
<div class="codeblock"><span class="urlname">Start</span><code id="opStartUrl">—</code><button class="btn" data-copy="opStartUrl">Copy</button></div>
<div class="codeblock"><span class="urlname">Stop</span><code id="opStopUrl">—</code><button class="btn" data-copy="opStopUrl">Copy</button></div>
<div class="codeblock"><span class="urlname">Toggle</span><code id="opToggleUrl">—</code><button class="btn" data-copy="opToggleUrl">Copy</button></div>
<h4 class="subhead2">Cough button</h4>
<p class="hint">Keeps translating and keeps you watching it — the congregation just
stops receiving. For a prayer request with a name in it, or anything said on a live
mic that was not meant to leave the room. Starting a service always clears it.</p>
<div class="codeblock"><span class="urlname">Hold</span><code id="opHoldUrl">—</code><button class="btn" data-copy="opHoldUrl">Copy</button></div>
<div class="codeblock"><span class="urlname">Resume</span><code id="opResumeUrl">—</code><button class="btn" data-copy="opResumeUrl">Copy</button></div>
<div class="codeblock"><span class="urlname">Hold toggle</span><code id="opHoldToggleUrl">—</code><button class="btn" data-copy="opHoldToggleUrl">Copy</button></div>
<h4 class="subhead2">Stream delay</h4>
<p class="hint">Nudge while watching the stream until the captions line up. Change
<code>by=</code> for a bigger step — one button at 1s for fine work, another at 5s.</p>
<div class="codeblock"><span class="urlname">+1 second</span><code id="opDelayUpUrl">—</code><button class="btn" data-copy="opDelayUpUrl">Copy</button></div>
<div class="codeblock"><span class="urlname">−1 second</span><code id="opDelayDownUrl">—</code><button class="btn" data-copy="opDelayDownUrl">Copy</button></div>
<div class="codeblock"><span class="urlname">Off</span><code id="opDelayOffUrl">—</code><button class="btn" data-copy="opDelayOffUrl">Copy</button></div>
<h4 class="subhead2">For button text and colour</h4>
<div class="codeblock"><span class="urlname">Status</span><code id="opStatusUrl">—</code><button class="btn" data-copy="opStatusUrl">Copy</button></div>
<div class="codeblock"><span class="urlname">Hold state</span><code id="opHoldStateUrl">—</code><button class="btn" data-copy="opHoldStateUrl">Copy</button></div>
<div class="codeblock"><span class="urlname">Delay now</span><code id="opDelayStateUrl">—</code><button class="btn" data-copy="opDelayStateUrl">Copy</button></div>
<p class="hint">Generic HTTP → GET. One word each: Status is <code>live</code>,
<code>ready</code> or <code>offline</code> (a held service still reads
<code>live</code> — it is live), Hold state is <code>held</code> or <code>open</code>,
Delay now is a number of seconds. Keep the key off the projector.</p>
<p class="hint">Set a Companion URL below and the server pushes
<code>translate_state</code>, <code>_hold</code>, <code>_delay</code> and
<code>_listeners</code> straight into custom variables — no polling.</p>
<hr>
<h3 class="subhead">Operator page PIN</h3>
<div class="row">
<input id="opPin" type="text" inputmode="numeric" autocomplete="off" spellcheck="false">
<button class="btn" id="opSavePin">Change</button>
<span class="hint" id="opPinHint"></span>
</div>
<p class="hint">For running the desk from a phone or tablet <b>on the church
network</b>. This tab never asks for it. The operator page is refused outright to
anything reaching the server from the internet — there is no setting to change that,
so the PIN only ever faces someone already in the building.</p>
<hr>
<h3 class="subhead">Advanced</h3>
<label for="port">Port</label>
<input id="port" type="number" min="1024" max="65535">
<p class="hint">Only if something else already uses it.</p>
<div class="row">
<button class="btn" id="savePort">Save &amp; restart server</button>
<span class="hint" id="portHint"></span>
</div>
<hr>
<h3 class="subhead">Another campus</h3>
<p class="hint">Carries the work: corrections, languages, pinned languages, voices,
output channel map and trims. Does <b>not</b> carry this Mac's PIN, control token or
device ids — each campus keeps its own, and premium-voice API keys stay on the machine
they were typed into.</p>
<div class="row">
<button class="btn" id="setExport">Save settings to a file</button>
<button class="btn" id="setImport">Load settings from a file</button>
</div>
<p class="hint" id="setHint"></p>
<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 it</button>
<span class="hint" id="updProgress"></span>
</div>
<details id="updNotesBox"><summary class="hint">What changed</summary>
<pre class="updnotes" id="updNotes"></pre></details>
</div>
<label for="updToken" style="margin-top:.75rem">Access token</label>
<input id="updToken" type="password" placeholder="only while the repository is private"
autocomplete="off" spellcheck="false">
<p class="hint">Stays on this Mac. It is not carried by <b>Save settings to a file</b>.</p>
<hr>
<label style="margin-top:.75rem">Models</label>
<p class="hint" id="modelInfo">—</p>
<div class="row">
<button class="btn" id="revealCache">Show model folder</button>
<span class="hint" id="cacheSize"></span>
</div>
</div>
</div>
</section>
<section class="card">
<h2>Log</h2>
<div class="log" id="log"></div>
</section>
</div><!-- /col-main -->
<!-- ============================= right rail ============================= -->
<aside class="col-rail">
<section class="card rail-card">
<h2>What the congregation opens</h2>
<p class="qrwarn" id="qrWarn" hidden></p>
<img id="qr" class="qr" alt="QR code to the translation page">
<div id="addrList" class="addrlist"></div>
<div class="railbtns">
<button class="btn btn-primary" id="copyAddr">Copy address</button>
<button class="btn" id="openPoster">Printable poster</button>
</div>
<h3 class="subhead">Operator page</h3>
<p class="hint">Run the desk from a phone or tablet. Needs the PIN.</p>
<div class="codeblock"><code id="opPageUrl">—</code><button class="btn" data-copy="opPageUrl">Copy</button></div>
<button class="btn railwide" id="openOperator">Open operator page</button>
</section>
</aside>
</main>
<script src="./server/public/languages.js"></script>
<script src="./pt-variants.js"></script>
<script src="./voice-catalog.js"></script>
<script src="./out-routing.js"></script>
<script src="./mt-guard.js"></script>
<script src="./liturgy.js"></script>
<script src="./short-questions.js"></script>
<script src="./preach.js"></script>
<script src="./spa-variants.js"></script>
<script src="./bible-lexicon.js"></script>
<script type="module" src="./engine.js"></script>
</body>
</html>