Files
church-translate/app/index.html
T
christianandClaude Opus 5 c9eef0672c Church Translate, as it runs at the campuses
Live sermon translation into 100+ languages, running entirely on one Mac per
campus: audio in, voice detection, Whisper on the GPU, NLLB-200 in its own
process, and a built-in web server the congregation reads from on their
phones. No audio leaves the building.

This is the source only. What used to sit beside it — 14 GB of speech and
translation models, 8 GB of voice bundles, 6 GB of natural voices, the Electron
caches, and the app data holding the operator PIN and every service transcript
— is machine state and downloaded assets, and stays in the NAS backup where it
belongs. The vendored node, cloudflared and Windows ffmpeg binaries are out too
(533 MB, re-fetched for packaging); the browser-side runtime beside them is
kept, so a checkout still serves the congregation page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:41:21 -04:00

437 lines
22 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>
<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>