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>
This commit is contained in:
2026-09-23 15:41:21 -04:00
co-authored by Claude Opus 5
commit c9eef0672c
118 changed files with 36031 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
'use strict';
/*
* aac-encode.js — the ONE ADTS AAC encoder for the whole app.
*
* ADTS, not MP4: every frame carries its own header and length, which is what
* lets live-audio.js splice sentences from different clips into one continuous
* stream a phone can play with its screen locked. Do not "improve" this to an
* .m4a — its moov box describes the whole file up front and cannot be spliced.
*
* macOS uses /usr/bin/afconvert, which ships with the OS and costs the app
* nothing. Windows has no such tool, so it uses a bundled LGPL ffmpeg. That is
* the whole platform difference, and it lives here rather than at the five
* call sites — with one exception that callers DO have to read from here:
* the two encoders prepend different amounts of priming (see PRIMING_FRAMES).
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const WIN = process.platform === 'win32';
/*
* Finding ffmpeg from a FORKED CHILD. server.js and tts-service.js are plain
* node processes, so `process.resourcesPath` — an Electron-only property —
* is undefined in them. main.js puts the resolved path in the environment
* before forking; the other candidates are for running a child standalone.
*/
let ffmpegPath = null;
function ffmpeg() {
if (ffmpegPath) return ffmpegPath;
const candidates = [
process.env.CT_FFMPEG,
path.join(process.resourcesPath || '', 'ffmpeg.exe'),
path.join(__dirname, '..', 'vendor-win', 'ffmpeg.exe')
].filter(Boolean);
for (const c of candidates) {
try { if (fs.existsSync(c) && fs.statSync(c).isFile()) { ffmpegPath = c; return c; } } catch (_) {}
}
throw new Error('ffmpeg was not found — it is what encodes audio for phones.');
}
/*
* Samples of encoder priming, in 1024-sample AAC frames, that every clip
* carries at its front and the stream must drop.
*
* ADTS has no edit list to declare priming, so a decoder plays it as dead
* silence at every sentence join. MEASURED by cross-correlating the decoded
* clip against its source WAV at 16000 / 22050 / 24000 Hz on real speech:
*
* afconvert 2112 samples -> strip 2 frames (64 samples remain; the
* debt ledger in pushContinuous eats them)
* ffmpeg 1024 samples -> strip 1 frame (exactly one, no remainder)
*
* Getting it wrong costs real words: ffmpeg stripped at 2 keeps only 77% of
* the onset energy — the same damage the old comment warns about for
* afconvert at 3. The +64/chunk sample-debt arithmetic is IDENTICAL under both
* encoders (verified: 11264 samples emitted for 11200 in, either way), so it
* needs no platform branch. Do not change without re-measuring.
*/
const PRIMING_FRAMES = WIN ? 1 : 2;
/*
* Encode a mono 16-bit WAV to ADTS AAC at a PINNED sample rate.
*
* Pinned because a language's HLS stream carries one rate for its whole life;
* a clip at another rate makes the stream rebuild and dumps everything queued.
* 48 kbps everywhere — at 32 kbps afconvert silently resamples the input.
* `rate` may be omitted to keep the WAV's own rate (the silence generator).
*/
function encodeAdts(wavPath, outPath, rate) {
if (WIN) {
const args = ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y',
'-i', wavPath, '-f', 'adts', '-c:a', 'aac', '-b:a', '48k', '-ac', '1'];
if (rate) args.push('-ar', String(rate));
args.push('-map_metadata', '-1', outPath);
execFileSync(ffmpeg(), args, { windowsHide: true });
} else {
execFileSync('/usr/bin/afconvert',
['-f', 'adts', '-d', rate ? ('aac@' + rate) : 'aac', '-b', '48000', wavPath, outPath]);
}
}
module.exports = { encodeAdts, PRIMING_FRAMES };
+676
View File
@@ -0,0 +1,676 @@
/*
* live-audio.js — one continuous audio stream per language, as live HLS.
*
* WHY THIS EXISTS, because the obvious design is the one that fails:
*
* The natural thing is to send each sentence to the phone as its own little
* clip and play it. That is what this app did, and it cannot survive a locked
* screen. WebKit decides background behaviour from the media TYPE: an
* AudioContext is MediaType::WebAudio, which is restricted in the background,
* while an <audio> element is MediaType::Audio, which is not restricted at all.
* Web Audio is on the wrong side of that line by construction — no amount of
* keep-alive trickery moves it.
*
* Worse, iOS keeps a locked page's JavaScript and its WebSocket alive only
* while the page is continuously AUDIBLE. Stop making sound and a ten-second
* timer suspends the whole process. Ordinary pauses in a sermon are longer than
* ten seconds, so any per-sentence design loses its socket mid-service and
* cannot get it back until someone unlocks the phone.
*
* So the stream never stops. When nobody is speaking it carries encoded
* silence, which WebKit counts as audible (it never inspects sample values),
* which holds the process alive, which keeps the next sentence arriving. Idle
* silence costs about 1.9 kbit/s — 264 bytes per second per language.
*
* The audio also arrives over plain HTTP fetches driven by the media stack
* rather than over the WebSocket, so even if the socket does die while locked,
* the voice keeps playing and only the captions pause.
*
* A happy side effect: an audible media element puts the page in the
* MediaPlayback audio category instead of AmbientSound, so the ringer switch
* stops silencing it. Listeners with their phone on silent could not hear this
* app at all before.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { encodeAdts, PRIMING_FRAMES } = require('./aac-encode.js');
/* 1024 samples per AAC frame is fixed by the codec. At 22050 Hz that is
46.44 ms, so ~21 frames make a segment just under a second. Short segments
keep the live edge close; HLS players start roughly three segments back. */
/*
* Segment and window sizes are TIME — each stream derives frame counts from
* its own sample rate. A fixed count that gives 0.7s at 22050 Hz gives 0.35s
* at 44100 and shrinks retention into rotate-out failures. 0.70s segments
* (down from 0.975) move the live edge ~0.3-0.5s closer to now.
*/
const SEGMENT_SEC = 0.70;
/* ~12s of window. 8 (~7.8s) left players almost no slack: one brief stall and
the segment they wanted next had already rotated out, which reads as a fatal
error. Segments are a few hundred bytes of silence or 1-2 KB of speech, so
the memory cost of the extra headroom is nothing. */
const WINDOW_SEC = 11;
/*
* How much slack the continuous (microphone) feed carries. Paid once as delay,
* and it is the difference between a hiccup being absorbed and a hiccup being
* a hole in the middle of a word. The hardware output path does not go through
* here at all, so this costs the assistive-listening packs nothing.
*/
const CONTINUOUS_CUSHION_MS = 700;
/* A persistently jittery feed grows its cushion rather than skipping, but not
past this — beyond about two seconds the delay is its own problem. */
const CONTINUOUS_CUSHION_MAX_MS = 2100;
/*
* Extra retention, in seconds, on top of the window.
*
* A delayed listener is served the SAME segments as the room, just starting
* further back — there is only one sliding window per language and nothing to
* send "late" to one listener over HTTP. So the window has to still contain
* what they are about to ask for: retention must cover the stream delay as
* well as the normal player slack, or their next segment has already rotated
* out and the page reports a fatal error.
*
* Cheap enough not to think about: audio is 48 kbps, so a 60-second reserve is
* about 360 KB per language — under 5 MB for a dozen languages.
*/
let reserveSec = 0;
/*
* The stream plays at exactly 1x real time, so anything that queues faster than
* that falls behind FOREVER — the gap never closes on its own. Translated
* speech routinely runs longer than the original (Spanish especially), so
* without a cap the voice ends up minutes behind the preacher by the end of a
* sermon while every caption is current.
*
* When the backlog passes MAX, drop the OLDEST whole sentences until it is back
* under TARGET. Oldest, because in live translation the stale sentence is the
* worthless one — and whole sentences, because chopping mid-word is worse than
* skipping. The caption for anything dropped is already on screen.
*/
/*
* MEASURED against a real service (2026-09-13, 377 sentences replayed through
* this very module): at 6/3 the dropper threw away 73 sentences — 19.4% of the
* sermon — and the median dropped sentence was 3.0 s old. It was not
* trimming stale drift; it was firing on ordinary same-breath bursts, where
* one final becomes 2-4 sentences whose clips land together. The stream then
* drained in under 2 s and sat empty until the next clip: the "skip" and the
* "quiet" were two halves of one event.
*
* Two changes, both measured on the replay. Wider thresholds (9/6): 73 -> 28.
* Plus the rule below — only a sentence whose CAPTION is at least
* STALE_CAPTION_SEC old may be dropped: 73 -> 16 (4.2%), and every one of
* those was genuinely stale. Gating on time since push instead was tried and
* rejected: after any supply stall the flushed backlog is "fresh" by push
* time, and that variant played 94 s of stale audio.
*/
const MAX_BACKLOG_SEC = 9;
const TARGET_BACKLOG_SEC = 6;
const STALE_CAPTION_SEC = 6;
/*
* Silence appended after each sentence. Sentences are synthesised separately
* and butted together with no gap at all, which is what makes a stream of them
* sound blocky and rushed — real speech breathes between sentences.
*
* But the pause is AUDIO, and audio is exactly what we are short of when
* running behind: 320ms on a 2s sentence is another 16% of drift. So the pause
* is a luxury spent only when the stream is keeping up. Caught up, it breathes;
* behind, it gets on with it. That is what a human interpreter does too.
*/
const SENTENCE_GAP_MS = 320;
/* Raised with TARGET, or the breath between sentences would be squeezed to
nothing on 97% of sentences at the new steady state (measured). */
const GAP_COMFORT_SEC = 1.5; // below this backlog, take the full pause
const GAP_SQUEEZE_SEC = 4.0; // above this, no pause at all
/* How often the pump wakes. Not once per frame — that would be 21 timers a
second per language for no benefit. It emits however many frames the wall
clock says are due. */
const TICK_MS = 240;
const SILENCE_DIR = path.join(require('os').tmpdir(), 'church-translate-silence');
// ------------------------------------------------------------------ ADTS
/*
* Split an ADTS file into whole frames. Every frame starts with a 12-bit sync
* word and carries its own length, so a stream is just frames concatenated —
* which is exactly what makes this container splice-able and MP4 not.
*/
function adtsFrames(buf) {
const out = [];
let off = 0;
while (off + 7 <= buf.length) {
if (buf[off] !== 0xff || (buf[off + 1] & 0xf0) !== 0xf0) break;
const len = ((buf[off + 3] & 0x03) << 11) | (buf[off + 4] << 3) | (buf[off + 5] >> 5);
if (len < 7 || off + len > buf.length) break;
out.push(buf.subarray(off, off + len));
off += len;
}
return out;
}
function adtsSampleRate(buf) {
const TABLE = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000];
if (buf.length < 4 || buf[0] !== 0xff) return 0;
return TABLE[(buf[2] & 0x3c) >> 2] || 0;
}
/*
* One frame of encoded silence at a given rate, made once and reused forever.
* Encoded through server/aac-encode.js like everything else — same reasoning as the
* clip encoder in tts-service.js.
*/
const silenceCache = new Map();
function silenceFrame(sampleRate) {
if (silenceCache.has(sampleRate)) return silenceCache.get(sampleRate);
fs.mkdirSync(SILENCE_DIR, { recursive: true });
const wav = path.join(SILENCE_DIR, 'sil-' + sampleRate + '.wav');
const aac = path.join(SILENCE_DIR, 'sil-' + sampleRate + '.aac');
const n = sampleRate; // one second is plenty to slice from
const b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12);
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(sampleRate, 24); b.writeUInt32LE(sampleRate * 2, 28);
b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
fs.writeFileSync(wav, b);
/* 48k to MATCH tts-service.js's clip encoder. At 32 kbps afconvert silently
RESAMPLES 44100 Hz input down to 32000, so the Chinese stream would splice
44.1 kHz speech against 32 kHz silence — mismatched-rate frames in one
stream, which players handle by glitching. */
encodeAdts(wav, aac); // keeps the WAV's own rate — see aac-encode.js
const frames = adtsFrames(fs.readFileSync(aac));
/* Skip the first couple — the encoder's priming frames are not steady state. */
const frame = frames[Math.min(3, frames.length - 1)];
silenceCache.set(sampleRate, frame);
try { fs.unlinkSync(wav); } catch (_) {}
return frame;
}
// ---------------------------------------------------------------- streams
function segmentsToKeep() {
return Math.max(6, Math.round((WINDOW_SEC + reserveSec) / SEGMENT_SEC));
}
class LanguageStream {
constructor(lang, sampleRate, onLog) {
this.lang = lang;
this.sampleRate = sampleRate;
this.onLog = onLog;
this.frameMs = (1024 / sampleRate) * 1000;
this.framesPerSegment = Math.max(4, Math.round(SEGMENT_SEC * sampleRate / 1024));
this.keepSegments = segmentsToKeep();
this.pending = []; // [{frames, gap, id, secs, at}] — one entry per sentence; `at` is its CAPTION time
this.pendingFrames = 0; // total frames queued, for the backlog check
this.onSentence = null; // fires with (id, secs) as a sentence starts playing out
this.onDrop = null; // fires with ([ids], secondsDropped) when the trimmer discards sentences
this.dropped = 0; // sentences discarded by the trimmer, this service
this.droppedSec = 0;
this.dropTimes = []; // when, for a "recently" view on the operator page
this.building = []; // frames of the segment being filled
this.segments = []; // { seq, body, frames }
this.nextSeq = 1;
this.timer = null;
this.lastTick = 0;
this.carry = 0; // fractional frames owed from the last tick
}
start() {
if (this.timer) return;
/*
* Seed one segment of silence immediately, so /live/<lang>.m3u8 exists from
* the first moment a player can ask for it. Without this there is a ~1-2s
* window after a listener picks a language where the playlist 404s — and a
* 404 at exactly that moment is indistinguishable from "this device cannot
* play HLS", which once sent pages to the clip fallback for good.
*/
if (!this.segments.length) {
const sil = silenceFrame(this.sampleRate);
for (let i = 0; i < this.framesPerSegment; i++) this.building.push(sil);
this.closeSegment();
}
this.lastTick = Date.now();
this.carry = 0;
this.timer = setInterval(() => this.tick(), TICK_MS);
if (this.timer.unref) this.timer.unref();
}
stop() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.continuous = false; // re-prime the cushion on the next service
this.underruns = 0;
this.reprimes = 0;
this.pending.length = 0;
this.pendingFrames = 0;
this.building.length = 0;
/* Drop the window too. Keeping it would serve a playlist that never
advances, and a player reaching the end of a frozen live playlist stalls
rather than failing — which is much harder to notice than a 404. */
this.segments.length = 0;
}
/* Queue a sentence. Frames go out at the stream's own pace, so a long
sentence simply occupies the next few seconds of the stream. */
/*
* Continuous feed (the live microphone): no sentence gap, no read-along
* announce, and an exact-sample debt ledger. Each 0.7s chunk arrives with
* 2112 samples of encoder priming; stripping two whole AAC frames removes
* 2048 of them, and the remaining +64 per chunk would stretch an hour of
* feed by ~20 seconds — the backlog trimmer would then "fix" that with
* audible chunk drops. When the debt reaches a full frame, one extra
* (mostly-priming) frame is stripped and the ledger settles.
*/
pushContinuous(buf) {
const rate = adtsSampleRate(buf);
if (rate && rate !== this.sampleRate) {
this.sampleRate = rate;
this.frameMs = (1024 / rate) * 1000;
this.framesPerSegment = Math.max(4, Math.round(SEGMENT_SEC * rate / 1024));
this.pending.length = 0;
this.pendingFrames = 0;
this.building.length = 0;
}
/*
* A JITTER CUSHION, primed once.
*
* The mic feed produces exactly real time — 0.7s of audio every 0.7s — so
* supply equals demand with nothing spare, and the steady-state backlog
* measured only ~800ms. tick() inserts SILENCE whenever the queue runs dry,
* so any hiccup longer than that cushion — a busy event loop, a WebSocket
* that bunched two chunks, a GC pause — punches an audible hole straight
* through the middle of a word. On a sentence-shaped TTS stream a hole
* lands in a pause and nobody notices; on a continuous feed it is a skip.
*
* MEASURED with a metronome-perfect feed and no network: 0 underruns, 800ms
* backlog. That is healthy but thin, and the real path has neither of those
* luxuries.
*
* Priming silence costs that much added delay ONCE, at the start, and buys
* the same amount of jitter tolerance for the whole service.
*/
if (!this.continuous) {
this.continuous = true;
this.underruns = 0;
this.cushionMs = CONTINUOUS_CUSHION_MS;
this.reprimes = 0;
}
/*
* SELF-HEALING, and this is the part that matters.
*
* A cushion primed once is spent once: the first hiccup big enough to
* empty the queue eats it, and every hiccup after that is an audible hole
* because the buffer never comes back. MEASURED — two stalls (500ms and
* 900ms late) took the backlog from 1472ms to 704ms permanently, and the
* stream reported 15 underrun frames.
*
* So top up whenever the queue is found EMPTY at push time. Empty means
* silence has already gone out — the audio is lost either way — and adding
* the cushion back is the difference between losing it once and losing it
* every time. Never top up a queue that still holds audio; that would
* insert silence into the middle of a word to fix a problem that has not
* happened.
*
* A feed that keeps stalling earns a bigger cushion, up to a ceiling: more
* delay is a fair price for not being punched full of holes, but it cannot
* grow without limit.
*/
if (this.pendingFrames === 0) {
if (this.reprimes > 0) {
this.cushionMs = Math.min(CONTINUOUS_CUSHION_MAX_MS, this.cushionMs + 350);
}
const sil0 = silenceFrame(this.sampleRate);
const cushion = Math.round(this.cushionMs / this.frameMs);
const pad = [];
for (let i = 0; i < cushion; i++) pad.push(sil0);
this.pending.push({ frames: pad, gap: 0, id: null, secs: 0 });
this.pendingFrames += pad.length;
this.reprimes++;
this.onLog('live mic feed for ' + this.lang + ': ' +
(this.reprimes === 1 ? 'primed ' : 'refilled ') +
Math.round(cushion * this.frameMs) + 'ms of jitter cushion' +
(this.reprimes > 1 ? ' (refill #' + this.reprimes + ')' : ''));
}
/* Encoder priming, from aac-encode.js — 2 frames under afconvert, 1 under
ffmpeg. The +64 remainder below is the same under both (measured). */
let strip = PRIMING_FRAMES;
this.sampleDebt = (this.sampleDebt || 0) + 64;
if (this.sampleDebt >= 1024) { strip += 1; this.sampleDebt -= 1024; }
let frames = adtsFrames(buf);
if (frames.length > strip + 2) frames = frames.slice(strip);
if (!frames.length) return;
this.pending.push({ frames, gap: 0, id: null, secs: (frames.length * 1024) / this.sampleRate });
this.pendingFrames += frames.length;
/* No trimBacklog here: a live feed produces exactly real time by
construction (the debt ledger guarantees it), so the backlog stays a
constant one-chunk bubble. */
}
push(buf, terminal, id, captionAt) {
const rate = adtsSampleRate(buf);
if (rate && rate !== this.sampleRate) {
/* A voice was swapped underneath us. Splicing mismatched rates produces
chipmunk audio, so rebuild the stream instead. */
this.onLog('live audio for ' + this.lang + ' switching ' + this.sampleRate + ' -> ' + rate);
this.sampleRate = rate;
this.frameMs = (1024 / rate) * 1000;
this.framesPerSegment = Math.max(4, Math.round(SEGMENT_SEC * rate / 1024));
this.pending.length = 0;
this.pendingFrames = 0;
this.building.length = 0;
}
/*
* The encoder prepends priming to every clip — afconvert 2112 samples,
* ffmpeg 1024 (both measured; see aac-encode.js) — and ADTS
* has no edit list to declare it, so the decoder plays it as dead silence
* at every sentence boundary — 96-142 ms per join. Dropping the first two
* frames removes 2048 of those samples; frame 2 keeps frame 1 as its MDCT
* overlap partner, so the speech onset survives (measured error confined to
* the first 1024 samples, RMS 10-21 LSB). Dropping THREE destroys the onset
* (-5 dBFS error, 12-38% energy loss). Do not raise this number.
*/
let frames = adtsFrames(buf);
if (frames.length > PRIMING_FRAMES + 2) frames = frames.slice(PRIMING_FRAMES);
if (!frames.length) return;
/* The gap rides with the sentence so that dropping one drops its pause too. */
/*
* A full stop earns a full breath; a fragment the VAD cut mid-thought gets
* only a beat, because a sentence-final pause after half a sentence is
* exactly the halting rhythm listeners hear as "blocky".
*/
const ended = terminal === '.' || terminal === '!' || terminal === '?' ||
terminal === '\u3002' || terminal === undefined;
const gapMs = this.gapMsForBacklog() * (ended ? 1 : 0.4);
const gap = Math.round(gapMs / this.frameMs);
const sil = silenceFrame(this.sampleRate);
const secs = (frames.length * 1024) / this.sampleRate; // speech only, sans gap
for (let i = 0; i < gap; i++) frames.push(sil);
/* `at` is when the sentence was SAID (the caption's timestamp), not when
its audio arrived — the difference is the whole dropper fix above. */
this.pending.push({ frames, gap, id, secs, at: Number(captionAt) || Date.now() });
this.pendingFrames += frames.length;
this.trimBacklog();
}
backlogSeconds() { return (this.pendingFrames * 1024) / this.sampleRate; }
gapMsForBacklog() {
const b = this.backlogSeconds();
if (b <= GAP_COMFORT_SEC) return SENTENCE_GAP_MS;
if (b >= GAP_SQUEEZE_SEC) return 0;
/* Taper rather than switch, so the rhythm does not lurch at a threshold. */
const t = (b - GAP_COMFORT_SEC) / (GAP_SQUEEZE_SEC - GAP_COMFORT_SEC);
return Math.round(SENTENCE_GAP_MS * (1 - t));
}
trimBacklog() {
if (this.backlogSeconds() <= MAX_BACKLOG_SEC) return;
/*
* Shorten the breathing room before losing anyone's words. A clipped pause
* costs a little naturalness; a dropped sentence costs the sentence. Only
* once every pause is gone is dropping the right answer.
*/
for (let i = 0; i < this.pending.length && this.backlogSeconds() > TARGET_BACKLOG_SEC; i++) {
const p = this.pending[i];
const trim = Math.min(p.gap, p.frames.length - 1);
if (trim <= 0) continue;
p.frames.length -= trim;
this.pendingFrames -= trim;
p.gap -= trim;
}
if (this.backlogSeconds() <= MAX_BACKLOG_SEC) return;
let dropped = 0;
let droppedSec = 0;
const ids = [];
while (this.pending.length > 1 && this.backlogSeconds() > TARGET_BACKLOG_SEC) {
/* Never cut the sentence that is coming out of the speaker mid-word —
if the head has started playing, the oldest DROPPABLE one is next. */
const at = this.pending[0].announced ? 1 : 0;
if (at >= this.pending.length) break;
/* Only a sentence the preacher said STALE_CAPTION_SEC ago is stale enough
to lose. If the oldest droppable one is fresher than that, everything
behind it is fresher still — stop, and let the hurry work it down. */
if (Date.now() - (this.pending[at].at || 0) < STALE_CAPTION_SEC * 1000) break;
const gone = this.pending.splice(at, 1)[0];
this.pendingFrames -= gone.frames.length;
droppedSec += (gone.frames.length * 1024) / this.sampleRate;
if (gone.id != null) ids.push(gone.id);
dropped++;
}
if (dropped) {
this.dropped += dropped;
this.droppedSec += droppedSec;
this.dropTimes.push(Date.now());
if (this.dropTimes.length > 200) this.dropTimes.splice(0, this.dropTimes.length - 200);
if (this.onDrop) { try { this.onDrop(ids, droppedSec); } catch (_) {} }
}
if (dropped) {
this.onLog('live audio ' + this.lang + ': dropped ' + dropped +
' stale sentence(s) to catch up (backlog now ' +
this.backlogSeconds().toFixed(1) + 's)');
}
}
tick() {
const now = Date.now();
const elapsed = now - this.lastTick;
this.lastTick = now;
/* Frames due since the last wake, carrying the fraction forward so the
stream does not drift slow. */
const exact = elapsed / this.frameMs + this.carry;
let due = Math.floor(exact);
this.carry = exact - due;
/* After a long stall (a laptop sleeping) do not dump a minute of silence
into the buffer — just resume at the live edge. */
if (due > this.framesPerSegment * 2) due = this.framesPerSegment * 2;
const sil = silenceFrame(this.sampleRate);
for (let i = 0; i < due; i++) {
let frame = sil;
while (this.pending.length && !this.pending[0].frames.length) this.pending.shift();
if (!this.pending.length && this.continuous) {
/*
* The queue ran dry on a feed that is supposed to be unbroken. This is
* the skip, and it used to happen silently — the stream simply played
* a little silence and nobody could tell it from the room being quiet.
* Counted and reported, because "it skips sometimes" is not something
* anyone can act on.
*/
this.underruns = (this.underruns || 0) + 1;
if (this.underruns === 1 || this.underruns % 25 === 0) {
this.onLog('live mic feed for ' + this.lang + ': ' + this.underruns +
' underrun frame(s) — audio arriving slower than real time');
}
}
if (this.pending.length) {
const head = this.pending[0];
/* The first frame of a sentence leaving the queue is the moment it
starts existing as sound — which is what the read-along highlight on
the phones needs to know. */
if (!head.announced) {
head.announced = true;
if (this.onSentence && head.id != null) {
try { this.onSentence(head.id, head.secs); } catch (_) {}
}
}
frame = head.frames.shift();
this.pendingFrames--;
}
this.building.push(frame);
if (this.building.length >= this.framesPerSegment) this.closeSegment();
}
}
closeSegment() {
const body = Buffer.concat(this.building);
this.segments.push({ seq: this.nextSeq++, body, frames: this.building.length });
this.building = [];
while (this.segments.length > this.keepSegments) this.segments.shift();
}
segmentDuration() { return (this.framesPerSegment * 1024) / this.sampleRate; }
/*
* `backSec` is how far behind the live edge this listener should start. 0 for
* the room; the stream delay for someone who came through the tunnel. Same
* playlist, same segments, different starting position — which is the only
* way to delay one listener of a shared HLS window and not the others.
*/
playlist(backSec) {
if (!this.segments.length) return null;
const dur = this.segmentDuration();
/* Never ask for a position older than the window actually holds, or the
player seeks to something that has rotated out and stalls. */
const held = Math.max(0, (this.segments.length - 1) * dur);
const back = Math.min(Math.max(0, Number(backSec) || 0) + 1.2, held);
const lines = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:' + Math.ceil(dur),
'#EXT-X-MEDIA-SEQUENCE:' + this.segments[0].seq,
/* Without this a player starts three segments back and stays there for
the whole service. Measured: 4.92 s of lag becomes 1.61 s. */
'#EXT-X-START:TIME-OFFSET=-' + back.toFixed(2) + ',PRECISE=YES'
];
for (const s of this.segments) {
lines.push('#EXTINF:' + dur.toFixed(3) + ',');
/* Relative to the PLAYLIST's directory, which is /live/ — so the language
has to be in the path or the player asks for /live/17.aac and gets a
404. The playlist itself is /live/<lang>.m3u8, not /live/<lang>/. */
lines.push(this.lang + '/' + s.seq + '.aac');
}
return lines.join('\n') + '\n';
}
segment(seq) {
const s = this.segments.find((x) => x.seq === seq);
return s ? s.body : null;
}
}
// ----------------------------------------------------------------- facade
class LiveAudio {
constructor(onLog) {
this.streams = new Map();
this.onLog = onLog || (() => {});
}
/* Default rate is a starting guess; the first real clip corrects it. */
ensure(lang, sampleRate) {
let s = this.streams.get(lang);
if (!s) {
s = new LanguageStream(lang, sampleRate || 22050, this.onLog);
s.onSentence = (id, secs) => {
if (this.onSentenceStart) { try { this.onSentenceStart(lang, id, secs); } catch (_) {} }
};
s.onDrop = (ids, secs) => {
if (this.onDropped) { try { this.onDropped(lang, ids, secs); } catch (_) {} }
};
this.streams.set(lang, s);
this.onLog('live audio stream open for ' + lang);
}
s.start();
return s;
}
push(lang, buf, sampleRate, terminal, id, captionAt) {
const s = this.ensure(lang, sampleRate || adtsSampleRate(buf));
s.push(buf, terminal, id, captionAt);
}
/*
* What the trimmer has thrown away, per language — the number that was
* invisible for a whole service. `recent` is drop EVENTS in the last minute,
* which is what an operator can act on; totals are for the log.
*/
stats() {
const out = {};
const cut = Date.now() - 60000;
for (const [lang, s] of this.streams) {
if (!s.dropped && !s.underruns) continue;
out[lang] = {
dropped: s.dropped,
droppedSec: +s.droppedSec.toFixed(1),
recent: s.dropTimes.filter((t) => t > cut).length,
underruns: s.continuous ? (s.underruns || 0) : undefined
};
}
return out;
}
/* Per-language backlog in seconds — how far the voice is behind the text.
The engine slows or quickens synthesis from this, like an interpreter. */
backlog() {
const out = {};
for (const [lang, s] of this.streams) {
if (s.timer) out[lang] = +s.backlogSeconds().toFixed(2);
}
return out;
}
/*
* Called whenever the set of languages anyone is listening to changes.
* OPENS a stream for every active language rather than only restarting ones
* that already exist — the stream has to be audible before the first sentence,
* not after it, or the phone is suspended before that sentence can arrive.
*/
retain(langs, rateFor) {
const keep = new Set(langs);
for (const lang of keep) this.ensure(lang, rateFor ? rateFor(lang) : 22050);
for (const [lang, s] of this.streams) {
if (keep.has(lang)) continue;
if (s.timer) { s.stop(); this.onLog('live audio stream idle for ' + lang); }
}
}
stopAll() { for (const [, s] of this.streams) s.stop(); }
playlist(lang, backSec) {
const s = this.streams.get(lang);
return s ? s.playlist(backSec) : null;
}
/*
* Keep enough history to serve a listener who is `sec` behind the live edge.
* Called whenever the stream delay changes, and applied to streams that
* already exist as well as new ones.
*/
reserve(sec) {
const n = Math.max(0, Math.min(300, Math.round(Number(sec) || 0)));
if (n === reserveSec) return;
reserveSec = n;
const keep = segmentsToKeep();
for (const st of this.streams.values()) {
st.keepSegments = keep;
/* Shrinking takes effect on the next rotate; growing must not drop
anything that is already held. */
while (st.segments.length > keep) st.segments.shift();
}
this.onLog('audio window now ' + ((WINDOW_SEC + n)).toFixed(0) + 's (delay reserve ' + n + 's)');
}
segment(lang, seq) { const s = this.streams.get(lang); return s ? s.segment(seq) : null; }
}
module.exports = { LiveAudio, adtsFrames, adtsSampleRate, silenceFrame };
+13
View File
@@ -0,0 +1,13 @@
{
"name": "Live Translation",
"short_name": "Translate",
"description": "Live sermon translation in your language",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0b0d12",
"theme_color": "#0b0d12",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#0b0d12"/>
<g fill="none" stroke="#6ea8fe" stroke-width="26" stroke-linecap="round" stroke-linejoin="round">
<path d="M104 168h150M179 136v32M214 168c0 62-45 112-100 130"/>
<path d="M150 232c14 30 40 54 74 66"/>
<path d="M266 400l68-168 68 168M292 344h84"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 397 B

+89
View File
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5">
<meta name="theme-color" content="#0b0d12">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="color-scheme" content="dark">
<title>Live Translation</title>
<link rel="manifest" href="/app.webmanifest">
<link rel="stylesheet" href="/listen.css">
</head>
<body>
<!-- ───────────────────────── language picker ───────────────────────── -->
<section id="picker" class="screen is-active">
<div class="picker-inner">
<header class="picker-head">
<div class="church" id="churchName">Live Translation</div>
<h1 id="chooseTitle">Choose your language</h1>
<!-- Cycles through the same question in other languages, so anyone can
tell what this page is for without reading English. -->
<div class="rotator" id="rotator" aria-hidden="true"></div>
</header>
<div class="featured" id="featured"></div>
<div class="search-wrap">
<div class="search-box">
<input id="search" type="search" inputmode="search" autocomplete="off" enterkeyhint="search"
autocorrect="off" spellcheck="false" placeholder="Search all languages">
<button id="searchClear" class="search-clear" type="button" aria-label="Clear search" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<div class="results" id="results" role="listbox"></div>
</div>
</div>
</section>
<!-- ───────────────────────── caption reader ────────────────────────── -->
<section id="reader" class="screen">
<header class="bar">
<button id="btnBack" class="bar-btn" aria-label="Change language">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>
</button>
<div class="bar-lang">
<span id="barLangName">—</span>
<span id="liveDot" class="dot" title="Live"></span>
</div>
<div class="bar-actions">
<button id="btnSpeak" class="bar-btn" aria-label="Speak aloud" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true" class="ic-off"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M22 9l-6 6M16 9l6 6"/></svg>
<svg viewBox="0 0 24 24" aria-hidden="true" class="ic-on"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7M18.5 5.5a9 9 0 010 13"/></svg>
</button>
<button id="btnOriginal" class="bar-btn bar-btn-text bar-btn-src" aria-label="Show original" aria-pressed="false">EN</button>
<button id="btnSize" class="bar-btn bar-btn-text" aria-label="Text size">Aa</button>
</div>
</header>
<div id="voiceBar" class="voicebar" hidden>
<label for="voicePick">Voice</label>
<select id="voicePick"></select>
</div>
<p id="voiceHint" class="voicehint" hidden></p>
<main id="stream" class="stream" tabindex="0">
<div id="waiting" class="waiting">
<div class="pulse"><span></span><span></span><span></span></div>
<p id="waitingText">The message hasn't started yet</p>
</div>
<div id="lines" class="lines"></div>
<div id="partial" class="line line-partial" hidden></div>
</main>
<button id="jumpLive" class="jump" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
<span id="jumpLabel">Live</span>
</button>
<div id="toast" class="toast" hidden></div>
</section>
<script src="/languages.js"></script>
<script src="/ui-strings.js"></script>
<script src="/listen.js"></script>
</body>
</html>
+186
View File
@@ -0,0 +1,186 @@
/*
* languages.js — the language catalog, shared by the NAS server, the listener
* page, and the Mac engine.
*
* `code` is a FLORES-200 code, which is what NLLB-200 expects. `tts` is the
* BCP-47 tag we hand to the phone's speechSynthesis; it is a best effort, and a
* phone that has no matching voice simply shows captions without speaking.
*
* `native` is what the listener actually sees in the picker. Never show someone
* the English name of their own language when you can help it.
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.Languages = factory();
}(typeof self !== 'undefined' ? self : this, function () {
// [ FLORES code, English name, native name, BCP-47 for TTS, rtl ]
var RAW = [
['eng_Latn', 'English', 'English', 'en-US'],
['spa_Latn', 'Spanish (Latin America)', 'Español (América Latina)', 'es-MX'],
['zho_Hans', 'Chinese (Simplified)', '简体中文', 'zh-CN'],
['zho_Hant', 'Chinese (Traditional)','繁體中文', 'zh-TW'],
['yue_Hant', 'Cantonese', '廣東話', 'zh-HK'],
['fra_Latn', 'French', 'Français', 'fr-FR'],
/* One NLLB Portuguese, two voices: the Brazilian and European variants
share their translation (por_PT is aliased to por_Latn at the MT
boundary in engine.js) and differ in the voice that reads it. */
['por_Latn', 'Portuguese (Brazil)', 'Português (Brasil)', 'pt-BR'],
['por_PT', 'Portuguese (Portugal)', 'Português (Portugal)', 'pt-PT'],
/* Same NLLB Spanish, read in a Castilian voice. There is no grammatical
transform between them: converting ustedes->vosotros needs to know
whether "you" was one person or a room, and neither the English nor
the Spanish carries that — see spa-variants.js for the measurements. */
['spa_ES', 'Spanish (Spain)', 'Español (España)', 'es-ES'],
/* Mexico shares Latin America's grammar exactly (RAE: vosotros "carecen
de uso en América", and Mexico is tuteo-exclusive), so the text is
identical — this exists so a Mexican listener sees their own country
named, and so the operator can give it its own voice. */
['spa_MX', 'Spanish (Mexico)', 'Español (México)', 'es-MX'],
['kor_Hang', 'Korean', '한국어', 'ko-KR'],
['vie_Latn', 'Vietnamese', 'Tiếng Việt', 'vi-VN'],
['hat_Latn', 'Haitian Creole', 'Kreyòl Ayisyen', 'ht-HT'],
['arb_Arab', 'Arabic', 'العربية', 'ar-SA', true],
['rus_Cyrl', 'Russian', 'Русский', 'ru-RU'],
['ukr_Cyrl', 'Ukrainian', 'Українська', 'uk-UA'],
['ron_Latn', 'Romanian', 'Română', 'ro-RO'],
['pol_Latn', 'Polish', 'Polski', 'pl-PL'],
['deu_Latn', 'German', 'Deutsch', 'de-DE'],
['ita_Latn', 'Italian', 'Italiano', 'it-IT'],
['nld_Latn', 'Dutch', 'Nederlands', 'nl-NL'],
['tgl_Latn', 'Tagalog', 'Tagalog', 'fil-PH'],
['ceb_Latn', 'Cebuano', 'Cebuano', 'fil-PH'],
['ind_Latn', 'Indonesian', 'Bahasa Indonesia', 'id-ID'],
['zsm_Latn', 'Malay', 'Bahasa Melayu', 'ms-MY'],
['tha_Thai', 'Thai', 'ไทย', 'th-TH'],
['khm_Khmr', 'Khmer', 'ភាសាខ្មែរ', 'km-KH'],
['lao_Laoo', 'Lao', 'ລາວ', 'lo-LA'],
['mya_Mymr', 'Burmese', 'မြန်မာ', 'my-MM'],
['shn_Mymr', 'Shan', ' လိၵ်ႈတႆး', 'shn'],
['kac_Latn', 'Kachin (Jingpho)', 'Jinghpaw', 'kac'],
['npi_Deva', 'Nepali', 'नेपाली', 'ne-NP'],
['hin_Deva', 'Hindi', 'हिन्दी', 'hi-IN'],
['urd_Arab', 'Urdu', 'اردو', 'ur-PK', true],
['ben_Beng', 'Bengali', 'বাংলা', 'bn-BD'],
['pan_Guru', 'Punjabi', 'ਪੰਜਾਬੀ', 'pa-IN'],
['guj_Gujr', 'Gujarati', 'ગુજરાતી', 'gu-IN'],
['tam_Taml', 'Tamil', 'தமிழ்', 'ta-IN'],
['tel_Telu', 'Telugu', 'తెలుగు', 'te-IN'],
['mal_Mlym', 'Malayalam', 'മലയാളം', 'ml-IN'],
['kan_Knda', 'Kannada', 'ಕನ್ನಡ', 'kn-IN'],
['mar_Deva', 'Marathi', 'मराठी', 'mr-IN'],
['sin_Sinh', 'Sinhala', 'සිංහල', 'si-LK'],
['pes_Arab', 'Persian (Farsi)', 'فارسی', 'fa-IR', true],
['prs_Arab', 'Dari', 'دری', 'fa-AF', true],
['pbt_Arab', 'Pashto', 'پښتو', 'ps-AF', true],
['ckb_Arab', 'Kurdish (Sorani)', 'کوردی', 'ckb', true],
['kmr_Latn', 'Kurdish (Kurmanji)', 'Kurmancî', 'ku'],
['heb_Hebr', 'Hebrew', 'עברית', 'he-IL', true],
['tur_Latn', 'Turkish', 'Türkçe', 'tr-TR'],
['amh_Ethi', 'Amharic', 'አማርኛ', 'am-ET'],
['tir_Ethi', 'Tigrinya', 'ትግርኛ', 'ti-ET'],
['som_Latn', 'Somali', 'Soomaali', 'so-SO'],
['swh_Latn', 'Swahili', 'Kiswahili', 'sw-KE'],
['yor_Latn', 'Yoruba', 'Yorùbá', 'yo-NG'],
['ibo_Latn', 'Igbo', 'Igbo', 'ig-NG'],
['hau_Latn', 'Hausa', 'Hausa', 'ha-NG'],
['lin_Latn', 'Lingala', 'Lingála', 'ln'],
['kin_Latn', 'Kinyarwanda', 'Ikinyarwanda', 'rw-RW'],
['run_Latn', 'Kirundi', 'Ikirundi', 'rn'],
['lug_Latn', 'Luganda', 'Luganda', 'lg'],
['nya_Latn', 'Chichewa', 'Chichewa', 'ny'],
['sna_Latn', 'Shona', 'chiShona', 'sn'],
['zul_Latn', 'Zulu', 'isiZulu', 'zu-ZA'],
['xho_Latn', 'Xhosa', 'isiXhosa', 'xh-ZA'],
['afr_Latn', 'Afrikaans', 'Afrikaans', 'af-ZA'],
['jpn_Jpan', 'Japanese', '日本語', 'ja-JP'],
['ell_Grek', 'Greek', 'Ελληνικά', 'el-GR'],
['hun_Latn', 'Hungarian', 'Magyar', 'hu-HU'],
['ces_Latn', 'Czech', 'Čeština', 'cs-CZ'],
['slk_Latn', 'Slovak', 'Slovenčina', 'sk-SK'],
['bul_Cyrl', 'Bulgarian', 'Български', 'bg-BG'],
['srp_Cyrl', 'Serbian', 'Српски', 'sr-RS'],
['hrv_Latn', 'Croatian', 'Hrvatski', 'hr-HR'],
['bos_Latn', 'Bosnian', 'Bosanski', 'bs-BA'],
['slv_Latn', 'Slovenian', 'Slovenščina', 'sl-SI'],
['als_Latn', 'Albanian', 'Shqip', 'sq-AL'],
['lit_Latn', 'Lithuanian', 'Lietuvių', 'lt-LT'],
['lvs_Latn', 'Latvian', 'Latviešu', 'lv-LV'],
['est_Latn', 'Estonian', 'Eesti', 'et-EE'],
['fin_Latn', 'Finnish', 'Suomi', 'fi-FI'],
['swe_Latn', 'Swedish', 'Svenska', 'sv-SE'],
['nob_Latn', 'Norwegian', 'Norsk', 'nb-NO'],
['dan_Latn', 'Danish', 'Dansk', 'da-DK'],
['isl_Latn', 'Icelandic', 'Íslenska', 'is-IS'],
['kat_Geor', 'Georgian', 'ქართული', 'ka-GE'],
['hye_Armn', 'Armenian', 'Հայերեն', 'hy-AM'],
['azj_Latn', 'Azerbaijani', 'Azərbaycan', 'az-AZ'],
['uzn_Latn', 'Uzbek', 'Oʻzbek', 'uz-UZ'],
['kaz_Cyrl', 'Kazakh', 'Қазақша', 'kk-KZ'],
['kir_Cyrl', 'Kyrgyz', 'Кыргызча', 'ky-KG'],
['tgk_Cyrl', 'Tajik', 'Тоҷикӣ', 'tg-TJ'],
['khk_Cyrl', 'Mongolian', 'Монгол', 'mn-MN'],
['bod_Tibt', 'Tibetan', 'བོད་སྐད་', 'bo'],
['gle_Latn', 'Irish', 'Gaeilge', 'ga-IE'],
['cym_Latn', 'Welsh', 'Cymraeg', 'cy-GB'],
['eus_Latn', 'Basque', 'Euskara', 'eu-ES'],
['cat_Latn', 'Catalan', 'Català', 'ca-ES'],
['glg_Latn', 'Galician', 'Galego', 'gl-ES'],
['quy_Latn', 'Quechua', 'Runasimi', 'qu'],
['ayr_Latn', 'Aymara', 'Aymar aru', 'ay'],
['grn_Latn', 'Guarani', "Avañe'ẽ", 'gn'],
['mri_Latn', 'Maori', 'Te Reo Māori', 'mi-NZ'],
['smo_Latn', 'Samoan', 'Gagana Samoa', 'sm'],
['fij_Latn', 'Fijian', 'Na Vosa Vakaviti', 'fj'],
['tpi_Latn', 'Tok Pisin', 'Tok Pisin', 'tpi']
];
var LANGS = RAW.map(function (r) {
return { code: r[0], name: r[1], native: r[2], tts: r[3], rtl: !!r[4] };
});
var BY_CODE = {};
LANGS.forEach(function (l) { BY_CODE[l.code] = l; });
// A sane opening set for a US church. The operator can change this at any time.
var DEFAULT_FEATURED = [
'spa_Latn', 'zho_Hans', 'por_Latn', 'fra_Latn',
'kor_Hang', 'vie_Latn', 'hat_Latn', 'arb_Arab'
];
function get(code) { return BY_CODE[code] || null; }
function label(code) {
var l = BY_CODE[code];
if (!l) return code;
return l.native === l.name ? l.native : l.native + ' · ' + l.name;
}
/* Case- and accent-insensitive search across both names and the code, so
"espanol", "Español", "spanish" and "spa" all find Spanish. */
function normalize(s) {
return String(s || '')
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '');
}
function search(query) {
var q = normalize(query).trim();
if (!q) return LANGS.slice();
return LANGS.filter(function (l) {
return normalize(l.name).indexOf(q) !== -1 ||
normalize(l.native).indexOf(q) !== -1 ||
normalize(l.code).indexOf(q) !== -1;
});
}
return {
LANGS: LANGS,
DEFAULT_FEATURED: DEFAULT_FEATURED,
get: get,
label: label,
search: search,
codes: function () { return LANGS.map(function (l) { return l.code; }); }
};
}));
+448
View File
@@ -0,0 +1,448 @@
/*
* listen.css — the congregant's screen.
*
* Two rules drive every choice here: it is dark because a bright phone in a
* dim auditorium is rude to the row behind you, and the type is large because
* people are reading it at arm's length in low light while listening.
*/
/*
* Make the `hidden` attribute actually hide things.
*
* The browser's own [hidden] rule is `display: none`, but it lives in the user-
* agent stylesheet, so any author rule that sets `display` — every .warn,
* .toast and flex row below — silently outranks it. The element stays on
* screen with hidden="true" and the JavaScript looks like it isn't running.
* That is how the operator console ended up permanently insisting the Mac
* engine was disconnected while it was sitting there working.
*/
[hidden] { display: none !important; }
:root {
--bg: #0b0d12;
--surface: #151922;
--raised: #1d2331;
--line: #2a3244;
--text: #eef1f6;
--muted: #8b93a7;
--accent: #6ea8fe;
--live: #34d399;
--radius: 16px;
--cap-size: 1.5rem; /* nudged by the Aa button */
--cap-lead: 1.55;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "Helvetica Neue", Arial, sans-serif;
-webkit-text-size-adjust: 100%;
overscroll-behavior: none;
}
body { overflow: hidden; }
.screen {
position: fixed;
left: 0;
right: 0;
/*
* Pinned to the VISUAL viewport, not the layout viewport.
*
* `position: fixed` is measured against the layout viewport, and on iOS those
* two come apart: collapsing the URL bar pans the visual viewport downward
* while the layout viewport stays put, so a bar fixed at layout-top ends up
* above what the user can actually see. `100dvh` fixed the HEIGHT half of
* that and this is the POSITION half — the same bug wearing a different hat,
* which is why it came back after being fixed once.
*
* The page itself never scrolls (body is overflow:hidden), so the existing
* self-heal — which watches window.scrollY and scrollingElement — sees zero
* on both and correctly does nothing. Nothing was scrolling. The viewport was
* moving underneath.
*
* listen.js keeps these two custom properties in step with visualViewport.
* The fallbacks are what a browser without visualViewport gets, which is the
* behaviour this had before.
*/
top: var(--vv-top, 0px);
height: var(--vv-h, 100dvh);
display: none;
}
.screen.is-active { display: flex; flex-direction: column; }
/* ───────────────────────────── picker ───────────────────────────── */
#picker { overflow-y: auto; -webkit-overflow-scrolling: touch; }
.picker-inner {
width: 100%;
max-width: 40rem;
margin: 0 auto;
padding: max(2.5rem, env(safe-area-inset-top)) 1.25rem calc(2rem + env(safe-area-inset-bottom));
}
.picker-head { text-align: center; margin-bottom: 1.75rem; }
.church {
font-size: .8125rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: .75rem;
}
.picker-head h1 {
font-size: clamp(1.5rem, 6vw, 2rem);
line-height: 1.2;
margin: 0 0 .5rem;
font-weight: 650;
}
/* The same question, cycling through other languages. */
.rotator {
min-height: 1.5rem;
color: var(--muted);
font-size: 1rem;
transition: opacity .4s ease;
}
.rotator.fade { opacity: 0; }
.featured {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
gap: .75rem;
margin-bottom: 1.75rem;
}
.lang-btn {
appearance: none;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
padding: 1.0625rem .875rem;
min-height: 4.25rem;
font: inherit;
text-align: center;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
gap: .1875rem;
transition: background .15s ease, border-color .15s ease, transform .1s ease;
}
.lang-btn:hover { background: var(--raised); border-color: #3a445c; }
.lang-btn:active { transform: scale(.975); }
.lang-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.lang-native { font-size: 1.1875rem; font-weight: 600; line-height: 1.25; }
.lang-en { font-size: .8125rem; color: var(--muted); }
.search-wrap { position: relative; }
#search {
width: 100%;
padding: .9375rem 1rem;
font-size: 1rem;
color: var(--text);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
outline: none;
}
#search:focus { border-color: var(--accent); }
#search::placeholder { color: var(--muted); }
.results { margin-top: .75rem; display: grid; gap: .375rem; }
.results:empty { display: none; }
.result {
appearance: none;
width: 100%;
text-align: start;
border: 1px solid transparent;
background: var(--surface);
color: var(--text);
border-radius: 12px;
padding: .8125rem 1rem;
font: inherit;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
}
.result:hover { background: var(--raised); }
.result:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.result b { font-weight: 600; font-size: 1.0625rem; }
.result span { color: var(--muted); font-size: .8125rem; flex: none; }
.no-results { color: var(--muted); text-align: center; padding: 1rem; font-size: .9375rem; }
/* ───────────────────────────── reader ───────────────────────────── */
.bar {
flex: none;
/* Pinned. The language name and the speaker button are the only controls a
listener has; losing them off the top of a long sermon is not acceptable. */
position: sticky;
top: 0;
z-index: 6;
display: flex;
align-items: center;
gap: .5rem;
padding: max(.5rem, env(safe-area-inset-top)) .625rem .5rem;
background: rgba(11, 13, 18, .92);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
}
.bar-lang {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: .5rem;
font-size: 1rem;
font-weight: 600;
}
#barLangName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dot {
flex: none;
width: .5rem;
height: .5rem;
border-radius: 50%;
background: var(--muted);
transition: background .3s ease;
}
.dot.is-live { background: var(--live); animation: breathe 2.4s ease-in-out infinite; }
@keyframes breathe { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
.bar-actions { display: flex; gap: .25rem; }
.bar-btn {
appearance: none;
border: 0;
background: transparent;
color: var(--text);
width: 2.75rem;
height: 2.75rem;
border-radius: 12px;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.bar-btn:hover { background: var(--raised); }
.bar-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.bar-btn svg {
width: 1.375rem; height: 1.375rem;
fill: none; stroke: currentColor; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round;
}
.bar-btn-text { font-size: 1rem; font-weight: 600; letter-spacing: .01em; }
/* Speaker icon swaps between muted and sounding. */
#btnSpeak .ic-on { display: none; }
#btnSpeak[aria-pressed="true"] { color: var(--accent); }
#btnSpeak[aria-pressed="true"] .ic-on { display: block; }
#btnSpeak[aria-pressed="true"] .ic-off { display: none; }
.stream {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 1.25rem 1.125rem calc(4rem + env(safe-area-inset-bottom));
/* Deliberately NOT scroll-behavior: smooth. That makes every programmatic
scroll an animation, so a page catching up on 40 lines restarts a long
animation on each new sentence and never reaches the bottom. listen.js
picks smooth-or-instant per scroll instead. */
}
.lines { display: flex; flex-direction: column; gap: .875rem; }
.line {
font-size: var(--cap-size);
line-height: var(--cap-lead);
letter-spacing: .003em;
animation: rise .28s ease-out both;
}
@media (prefers-reduced-motion: reduce) { .line { animation: none; } }
@keyframes rise { from { opacity: 0; transform: translateY(.5rem); } to { opacity: 1; transform: none; } }
/*
* The reading eye follows the VOICE, not recency. Every line reads at full
* weight — the newest is not special, because the newest is precisely what the
* voice has NOT said yet — and the one the voice is speaking right now carries
* the highlight, so a listener can read along and always knows where in the
* text the audio is.
*/
.line.is-old { color: #b7bfd2; }
.line.is-speaking {
color: var(--text);
background: rgba(96, 165, 250, .10);
box-shadow: inset 3px 0 0 0 #60a5fa;
border-radius: .375rem;
padding: .125rem .5rem .125rem .625rem;
margin-left: -.625rem;
transition: background .25s ease, box-shadow .25s ease;
}
@media (prefers-reduced-motion: reduce) { .line.is-speaking { transition: none; } }
.line-partial { color: var(--muted); font-style: italic; margin-top: .875rem; }
.line-original {
display: block;
margin-top: .3125rem;
font-size: .8em;
color: var(--muted);
font-style: italic;
}
/* When translation for a language isn't flowing yet. */
.line.is-untranslated { opacity: .72; }
.waiting {
height: 100%;
min-height: 55vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.25rem;
color: var(--muted);
text-align: center;
}
.waiting p { margin: 0; font-size: 1.0625rem; max-width: 20rem; }
.waiting[hidden] { display: none; }
.pulse { display: flex; gap: .4375rem; }
.pulse span {
width: .5rem; height: .5rem; border-radius: 50%;
background: var(--muted);
animation: bounce 1.4s ease-in-out infinite;
}
.pulse span:nth-child(2) { animation-delay: .18s; }
.pulse span:nth-child(3) { animation-delay: .36s; }
@keyframes bounce { 0%, 60%, 100% { transform: translateY(0); opacity: .4; } 30% { transform: translateY(-.4375rem); opacity: 1; } }
.jump {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(1.25rem + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: .375rem;
padding: .625rem 1.125rem;
border: 0;
border-radius: 999px;
background: var(--accent);
color: #08101f;
font: inherit;
font-weight: 650;
font-size: .9375rem;
cursor: pointer;
box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .5);
}
.jump[hidden] { display: none; }
.jump svg { width: 1.125rem; height: 1.125rem; fill: none; stroke: currentColor; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.toast {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(4.5rem + env(safe-area-inset-bottom));
background: var(--raised);
border: 1px solid var(--line);
color: var(--text);
padding: .625rem 1rem;
border-radius: 999px;
font-size: .875rem;
max-width: calc(100vw - 2.5rem);
text-align: center;
z-index: 10;
}
.toast[hidden] { display: none; }
/* Text-size steps, driven by the Aa button. */
body.size-2 { --cap-size: 1.875rem; }
body.size-3 { --cap-size: 2.375rem; --cap-lead: 1.45; }
/* Right-to-left scripts. */
#reader[dir="rtl"] .stream,
#reader[dir="rtl"] .lines { text-align: right; }
/* The "EN" button: tap it to see the preacher's own words under each line. */
.bar-btn-src { font-size: .8125rem; letter-spacing: .04em; color: var(--muted); }
.bar-btn-src[aria-pressed="true"] { color: var(--accent); background: var(--raised); }
/* ---- voice picker: only appears when speech is on and there is a choice ---- */
.voicebar {
flex: none;
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
gap: .625rem;
padding: .5rem 1.125rem .5625rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.voicebar label {
font-size: .6875rem;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
flex: none;
}
.voicebar select {
flex: 1;
min-width: 0;
appearance: none;
background: var(--raised);
color: var(--text);
border: 1px solid var(--line);
border-radius: 9px;
padding: .4375rem .75rem;
font: inherit;
font-size: .875rem;
}
.voicebar select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Voices come from the device, not from us. When it has few or poor ones, say
where better ones live rather than letting people conclude we sound bad. */
.voicehint {
flex: none;
margin: 0;
padding: .5rem 1.125rem .625rem;
font-size: .78125rem;
line-height: 1.45;
color: var(--muted);
background: var(--surface);
border-bottom: 1px solid var(--line);
}
.voicehint[hidden] { display: none; }
.voicehint b { color: var(--text); font-weight: 600; }
/* iOS renders no native clear control for type=search — this is it. Centered
on the input (not .search-wrap, which grows when results render). */
.search-box { position: relative; }
#search { padding-inline-end: 3.25rem; font-size: max(1rem, 16px); }
.search-clear {
position: absolute; top: 50%; right: .5rem; transform: translateY(-50%);
width: 2.25rem; height: 2.25rem; border: 0; border-radius: 50%;
background: rgba(148,163,184,.18); color: #cbd5e6; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.search-clear svg { width: .875rem; height: .875rem; stroke: currentColor; stroke-width: 2.5; fill: none; }
.search-clear:active { background: rgba(148,163,184,.32); }
File diff suppressed because one or more lines are too long
+201
View File
@@ -0,0 +1,201 @@
/*
* operator.css — the sound desk view.
*
* The START button is deliberately enormous. On a Sunday it is pressed once,
* often by a volunteer, sometimes in the dark, sometimes on a tablet, usually
* while three other things are going wrong.
*/
/*
* Make the `hidden` attribute actually hide things.
*
* The browser's own [hidden] rule is `display: none`, but it lives in the user-
* agent stylesheet, so any author rule that sets `display` — every .warn,
* .toast and flex row below — silently outranks it. The element stays on
* screen with hidden="true" and the JavaScript looks like it isn't running.
* That is how the operator console ended up permanently insisting the Mac
* engine was disconnected while it was sitting there working.
*/
[hidden] { display: none !important; }
:root {
--bg: #0b0d12; --surface: #151922; --raised: #1d2331; --line: #2a3244;
--text: #eef1f6; --muted: #8b93a7; --accent: #6ea8fe;
--go: #2fbf71; --stop: #e5484d; --warn: #f5a524;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-webkit-text-size-adjust: 100%;
}
h1 { font-size: 1.5rem; margin: 0; font-weight: 650; }
h2 { font-size: 1.0625rem; margin: 0 0 .375rem; font-weight: 650; }
h2.mt { margin-top: 1.75rem; }
p { margin: 0 0 .875rem; line-height: 1.5; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8125rem; }
.muted { color: var(--muted); font-size: .875rem; }
.tiny { font-size: .8125rem; }
.err { color: var(--stop); font-size: .875rem; }
.eyebrow { font-size: .75rem; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin-bottom: .25rem; }
/* ---- PIN gate ---- */
.gate { display: none; min-height: 100dvh; place-items: center; padding: 1.25rem; }
.gate.is-active { display: grid; }
#console { display: none; }
#console.is-active { display: block; }
.gate-card {
width: 100%; max-width: 21rem;
background: var(--surface); border: 1px solid var(--line);
border-radius: 18px; padding: 1.75rem;
display: flex; flex-direction: column; gap: .75rem;
}
/* ---- shell ---- */
.wrap { max-width: 46rem; margin: 0 auto; padding: 1.5rem 1.125rem 5rem; }
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; }
.ghost { color: var(--accent); text-decoration: none; font-size: .875rem; white-space: nowrap; }
.ghost:hover { text-decoration: underline; }
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: 18px; padding: 1.375rem; margin-bottom: 1rem;
}
.card.control { padding-bottom: 1rem; }
/* ---- the button ---- */
.big {
width: 100%; min-height: 8.5rem;
border: 0; border-radius: 16px; cursor: pointer;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .375rem;
background: var(--go); color: #052e18;
font: inherit; transition: background .18s ease, transform .08s ease;
}
.big:active { transform: scale(.99); }
.big:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
.big[data-live="true"] { background: var(--stop); color: #fff; }
.big[disabled] { background: var(--raised); color: var(--muted); cursor: not-allowed; }
.big-label { font-size: 1.625rem; font-weight: 750; letter-spacing: .02em; }
.big-sub { font-size: .9375rem; opacity: .85; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; margin-top: 1rem; }
.stat { background: var(--raised); border-radius: 12px; padding: .75rem; text-align: center; }
.stat b { display: block; font-size: 1.375rem; font-weight: 700; }
.stat span { font-size: .75rem; color: var(--muted); }
.warn {
margin-top: 1rem; padding: .875rem 1rem;
background: rgba(245, 165, 36, .12);
border: 1px solid rgba(245, 165, 36, .4);
border-radius: 12px; font-size: .875rem; line-height: 1.5;
display: flex; flex-direction: column; gap: .25rem;
}
.warn strong { color: var(--warn); }
.enginebar {
margin-top: .75rem; font-size: .8125rem; color: var(--muted);
display: flex; flex-wrap: wrap; gap: .875rem;
}
/* ---- inputs ---- */
input, textarea, select {
width: 100%; padding: .75rem .875rem;
background: var(--raised); color: var(--text);
border: 1px solid var(--line); border-radius: 10px;
font: inherit; font-size: .9375rem; outline: none;
}
input:focus, textarea:focus { border-color: var(--accent); }
textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8125rem; line-height: 1.6; }
label { display: block; font-size: .875rem; color: var(--muted); margin-bottom: .5rem; }
label input { margin-top: .375rem; }
.btn {
appearance: none; border: 1px solid var(--line);
background: var(--raised); color: var(--text);
padding: .6875rem 1.125rem; border-radius: 10px;
font: inherit; font-size: .9375rem; font-weight: 550;
cursor: pointer; text-decoration: none; display: inline-block; text-align: center;
}
.btn:hover { background: #262d3d; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #08101f; font-weight: 650; }
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-top: .75rem; }
.row .btn { flex: none; }
.row input { flex: 1; min-width: 10rem; }
/* ---- language chips ---- */
.chips { display: flex; flex-wrap: wrap; gap: .4375rem; margin-bottom: .75rem; min-height: 2rem; }
.chip {
display: inline-flex; align-items: center; gap: .4375rem;
background: var(--raised); border: 1px solid var(--line);
border-radius: 999px; padding: .375rem .5rem .375rem .8125rem;
font-size: .875rem;
}
.chip button {
appearance: none; border: 0; background: transparent; color: var(--muted);
cursor: pointer; font-size: 1.125rem; line-height: 1; padding: 0 .25rem;
}
.chip button:hover { color: var(--stop); }
.chips:empty::before { content: "None yet"; color: var(--muted); font-size: .875rem; }
.results { display: grid; gap: .25rem; margin-top: .5rem; max-height: 15rem; overflow-y: auto; }
.results:empty { display: none; }
.results button {
appearance: none; text-align: left; width: 100%;
background: var(--raised); border: 1px solid transparent; color: var(--text);
border-radius: 8px; padding: .5625rem .75rem; font: inherit; font-size: .875rem; cursor: pointer;
}
.results button:hover { border-color: var(--accent); }
.results button span { color: var(--muted); margin-left: .5rem; font-size: .8125rem; }
/* ---- qr + code ---- */
.qrrow { display: flex; gap: 1.25rem; flex-wrap: wrap; align-items: flex-start; }
.qr { width: 10rem; height: 10rem; background: #fff; border-radius: 12px; padding: .5rem; flex: none; }
.qrside { flex: 1; min-width: 14rem; }
.codeblock {
display: flex; align-items: center; gap: .5rem;
background: var(--raised); border: 1px solid var(--line);
border-radius: 10px; padding: .625rem .75rem; margin-bottom: .5rem;
}
.codeblock code { flex: 1; overflow-x: auto; white-space: nowrap; color: #cbd5e6; }
.copy {
appearance: none; border: 0; background: var(--line); color: var(--text);
border-radius: 7px; padding: .3125rem .625rem; font: inherit; font-size: .75rem; cursor: pointer; flex: none;
}
.copy:hover { background: var(--accent); color: #08101f; }
.toast {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: 1.5rem; background: var(--raised); border: 1px solid var(--line);
padding: .6875rem 1.125rem; border-radius: 999px; font-size: .875rem; z-index: 20;
}
.toast[hidden] { display: none; }
@media (max-width: 30rem) {
.big-label { font-size: 1.375rem; }
.qr { width: 100%; height: auto; max-width: 15rem; margin: 0 auto; }
}
/* ---- who is listening, by language ---- */
.wholist { display: flex; flex-wrap: wrap; gap: .4375rem; margin-top: .75rem; }
.wholist[hidden] { display: none; }
.who {
display: inline-flex; align-items: baseline; gap: .4375rem;
background: var(--raised); border: 1px solid var(--line);
border-radius: 999px; padding: .3125rem .75rem;
font-size: .8125rem;
}
.who b { font-variant-numeric: tabular-nums; font-weight: 700; font-size: .9375rem; }
.who span { color: var(--muted); }
.op-health { margin: .5rem 0 0; font-size: .8125rem; font-weight: 600; text-align: center; }
.op-health[data-level="green"] { color: #4ade80; }
.op-health[data-level="amber"] { color: #fbbf24; }
.op-health[data-level="red"] { color: #f87171; }
+129
View File
@@ -0,0 +1,129 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b0d12">
<meta name="color-scheme" content="dark">
<title>Translation — Operator</title>
<link rel="stylesheet" href="/operator.css">
</head>
<body>
<!-- PIN gate ------------------------------------------------------------- -->
<section id="gate" class="gate is-active">
<form id="gateForm" class="gate-card">
<h1>Operator</h1>
<p class="muted">Enter the PIN for this church's translation system.</p>
<input id="pin" type="password" inputmode="numeric" autocomplete="current-password"
placeholder="PIN" aria-label="Operator PIN" required>
<button type="submit" class="btn btn-primary">Unlock</button>
<p id="gateErr" class="err" hidden></p>
<p class="muted tiny">The PIN is in <code>data/OPERATOR-PIN.txt</code> on the NAS.</p>
</form>
</section>
<!-- Console -------------------------------------------------------------- -->
<main id="console" class="wrap">
<header class="top">
<div>
<div class="eyebrow">Live Translation</div>
<h1 id="svcName">—</h1>
</div>
<a class="ghost" href="/" target="_blank" rel="noopener">Listener view ↗</a>
</header>
<!-- The one control that matters on a Sunday morning. -->
<section class="card control">
<button id="bigBtn" class="big" data-live="false">
<span class="big-label">START TRANSLATING</span>
<span class="big-sub" id="bigSub">Ready</span>
</button>
<p class="op-health" id="opHealth" hidden></p>
<div class="stats">
<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>
<!-- Who is actually reading, and in what. The total alone does not tell you
whether a language is worth keeping. -->
<div id="whoList" class="wholist" hidden></div>
<div id="engineWarn" class="warn" hidden>
<strong>The Mac engine is not connected.</strong>
<span>Open <em>Church Translate Engine</em> on the backstage Mac Studio. Nothing will translate until it is running.</span>
</div>
<div id="engineInfo" class="enginebar" hidden></div>
</section>
<!-- Languages ---------------------------------------------------------- -->
<section class="card">
<h2>Languages on the front page</h2>
<p class="muted">These show as big buttons before anyone searches. Everything else
is still reachable through search — all <b id="totalLangs">0</b> of them.</p>
<div id="featuredList" class="chips"></div>
<input id="langSearch" type="search" placeholder="Add a language…" autocomplete="off">
<div id="langResults" class="results"></div>
<h2 class="mt">Always translate</h2>
<p class="muted">Normally a language is only translated while someone is actually
listening to it. Pin one here to have it ready before the first person arrives.</p>
<div id="pinnedList" class="chips"></div>
</section>
<!-- Glossary ----------------------------------------------------------- -->
<section class="card">
<h2>Corrections</h2>
<p class="muted">One per line, in the form
<code>what it heard =&gt; what it should say</code>. This is how “have a cook”
becomes “Habakkuk” and your pastor’s name stops being a different word every
week. Matching ignores capitals and only hits whole words.</p>
<textarea id="bias" rows="7" spellcheck="false"></textarea>
<button id="saveGlossary" class="btn">Save corrections</button>
</section>
<!-- Setup -------------------------------------------------------------- -->
<section class="card">
<h2>The QR code</h2>
<div class="qrrow">
<img id="qr" class="qr" alt="QR code to the listener page">
<div class="qrside">
<label>Public address
<input id="publicUrl" type="url" placeholder="https://translate.yourchurch.org" spellcheck="false">
</label>
<p class="muted tiny">Leave blank to use whatever address this page was opened at.
Set it once the Cloudflare tunnel is up so the QR code always points at the public name.</p>
<div class="row">
<button id="savePublic" class="btn">Save</button>
<a id="posterLink" class="btn" href="/poster" target="_blank" rel="noopener">Printable poster ↗</a>
</div>
</div>
</div>
</section>
<section class="card">
<h2>Stream Deck / Companion</h2>
<p class="muted">Add a <b>Generic HTTP</b> action in Companion. Method GET, and one of:</p>
<div class="codeblock"><code id="urlToggle">—</code><button class="copy" data-copy="urlToggle">Copy</button></div>
<div class="codeblock"><code id="urlStart">—</code><button class="copy" data-copy="urlStart">Copy</button></div>
<div class="codeblock"><code id="urlStop">—</code><button class="copy" data-copy="urlStop">Copy</button></div>
<p class="muted tiny">Keep this key off the projector — anyone with it can start and stop translation.</p>
</section>
<section class="card">
<h2>Change the PIN</h2>
<div class="row">
<input id="newPin" type="password" placeholder="New PIN (4+ characters)" autocomplete="new-password">
<button id="savePin" class="btn">Update PIN</button>
</div>
</section>
<div id="toast" class="toast" hidden></div>
</main>
<script src="/languages.js"></script>
<script src="/operator.js"></script>
</body>
</html>
+333
View File
@@ -0,0 +1,333 @@
/*
* operator.js — the sound desk console.
*
* Everything here is secondary to one thing: the big button has to be right,
* and it has to tell the truth about whether the system is actually working.
* A green "START" on a Sunday when the Mac engine is closed would be worse
* than no button at all, so the button disables itself when there is no engine.
*/
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var PIN_KEY = 'ct.pin';
var app = { pin: null, cfg: null, status: null, ws: null, base: location.origin, elapsedTimer: null };
var toastTimer = null;
function toast(msg) {
var t = $('toast');
t.textContent = msg;
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { t.hidden = true; }, 2600);
}
function api(path, opts) {
opts = opts || {};
opts.headers = Object.assign({ 'X-Operator-Pin': app.pin }, opts.headers || {});
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts).then(function (r) {
return r.json().then(function (j) {
if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
return j;
});
});
}
function save(patch, okMsg) {
return api('/api/config', { method: 'POST', body: JSON.stringify(patch) })
.then(function (j) { app.cfg = j.config; toast(okMsg || 'Saved'); return j; })
.catch(function (e) { toast('Could not save: ' + e.message); throw e; });
}
// ───────────────────────────────────────────── gate
$('gateForm').addEventListener('submit', function (e) {
e.preventDefault();
var pin = $('pin').value.trim();
if (!pin) return;
app.pin = pin;
api('/api/config')
.then(function (j) {
try { sessionStorage.setItem(PIN_KEY, pin); } catch (_) {}
$('gateErr').hidden = true;
enterConsole(j);
})
.catch(function () {
app.pin = null;
$('gateErr').textContent = 'That PIN was not accepted.';
$('gateErr').hidden = false;
$('pin').value = '';
$('pin').focus();
});
});
function enterConsole(j) {
app.cfg = j.config;
app.status = j.status;
app.base = (j.config.publicUrl || j.publicBase || location.origin).replace(/\/+$/, '');
$('gate').classList.remove('is-active');
$('console').classList.add('is-active');
$('totalLangs').textContent = Languages.LANGS.length;
renderConfig();
renderStatus(j.status);
connect();
}
// ───────────────────────────────────────────── status + big button
function fmtElapsed(since) {
if (!since) return '—';
var s = Math.max(0, Math.floor((Date.now() - since) / 1000));
var m = Math.floor(s / 60);
return m + ':' + String(s % 60).padStart(2, '0');
}
/* One chip per language actually being read, busiest first. */
function renderWho(rows) {
var box = $('whoList');
box.textContent = '';
if (!rows.length) { box.hidden = true; return; }
box.hidden = false;
rows.forEach(function (row) {
var l = Languages.get(row.code);
var el = document.createElement('span');
el.className = 'who';
var n = document.createElement('b');
n.textContent = row.count;
var name = document.createElement('span');
name.textContent = l ? l.native : row.code;
el.appendChild(n);
el.appendChild(name);
box.appendChild(el);
});
}
function renderStatus(st) {
if (!st) return;
app.status = st;
$('svcName').textContent = st.serviceName || 'Live Translation';
/* The traffic light — computed on the server so every surface agrees. */
var hl = $('opHealth');
if (hl) {
hl.hidden = !st.health;
if (st.health) {
hl.setAttribute('data-level', st.health.level);
hl.textContent = st.health.detail;
}
}
var live = !!st.live;
var ready = !!st.engineConnected;
var btn = $('bigBtn');
btn.dataset.live = live ? 'true' : 'false';
btn.disabled = !ready && !live;
btn.querySelector('.big-label').textContent = live ? 'STOP TRANSLATING' : 'START TRANSLATING';
var stale = !!st.engineStale;
$('bigSub').textContent = stale
? 'The Mac engine has stopped responding'
: live ? 'On air — the congregation is reading'
: (ready ? 'Engine ready' : 'Waiting for the Mac engine');
/* Connected-but-silent is the dangerous state: the socket is up, so
everything looks healthy, but nothing is being transcribed. */
$('engineWarn').hidden = ready && !stale;
if (stale) {
$('engineWarn').querySelector('strong').textContent = 'The Mac engine is connected but silent.';
$('engineWarn').querySelector('span').textContent =
'It has not reported for over 10 seconds. Quit and reopen Church Translate Engine on the Mac Studio.';
} else if (!ready) {
$('engineWarn').querySelector('strong').textContent = 'The Mac engine is not connected.';
$('engineWarn').querySelector('span').textContent =
'Open Church Translate Engine on the backstage Mac Studio. Nothing will translate until it is running.';
}
$('stListeners').textContent = st.listeners || 0;
$('stLangs').textContent = (st.activeLanguages || []).length;
$('stElapsed').textContent = fmtElapsed(st.startedAt);
renderWho(st.listenersByLanguage || []);
clearInterval(app.elapsedTimer);
if (live && st.startedAt) {
app.elapsedTimer = setInterval(function () {
$('stElapsed').textContent = fmtElapsed(st.startedAt);
}, 1000);
}
var es = st.engineStatus || {};
var bits = [];
if (es.device) bits.push('🎙 ' + es.device);
if (es.model) bits.push('🧠 ' + es.model);
if (typeof es.peakDb === 'number' && es.peakDb > -99) {
/* Peak over the last few seconds — an instantaneous sample lands between
syllables and reads as silence on a working feed. */
bits.push('📶 ' + es.peakDb + ' dB peak' + (es.peakDb < -40 ? ' (low)' : ''));
} else if (typeof es.rms === 'number') {
bits.push('📶 ' + (es.rms > 0.002 ? 'audio present' : 'silent'));
}
if (typeof es.lagMs === 'number') bits.push('⏱ ' + Math.round(es.lagMs) + ' ms behind');
$('engineInfo').textContent = bits.join(' ');
$('engineInfo').hidden = bits.length === 0;
}
$('bigBtn').addEventListener('click', function () {
if (!app.ws || app.ws.readyState !== 1) { toast('Not connected to the server.'); return; }
app.ws.send(JSON.stringify({ t: 'toggle' }));
});
function connect() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var ws = new WebSocket(proto + '//' + location.host + '/ws?role=operator&pin=' + encodeURIComponent(app.pin));
app.ws = ws;
ws.onmessage = function (ev) {
var m;
try { m = JSON.parse(ev.data); } catch (_) { return; }
if (m.t === 'status') renderStatus(m.status);
};
ws.onclose = function () {
if (app.ws !== ws) return;
app.ws = null;
setTimeout(connect, 2000);
};
}
// ───────────────────────────────────────────── languages
function chip(code, onRemove) {
var l = Languages.get(code) || { native: code, name: code };
var el = document.createElement('span');
el.className = 'chip';
el.textContent = l.native === l.name ? l.native : l.native + ' · ' + l.name;
var x = document.createElement('button');
x.type = 'button';
x.setAttribute('aria-label', 'Remove ' + l.name);
x.textContent = '×';
x.addEventListener('click', function () { onRemove(code); });
el.appendChild(x);
return el;
}
function renderConfig() {
var cfg = app.cfg;
var f = $('featuredList');
f.textContent = '';
(cfg.featured || []).forEach(function (code) {
f.appendChild(chip(code, function (c) {
save({ featured: cfg.featured.filter(function (x) { return x !== c; }) })
.then(renderConfig);
}));
});
var p = $('pinnedList');
p.textContent = '';
(cfg.pinned || []).forEach(function (code) {
p.appendChild(chip(code, function (c) {
save({ pinned: cfg.pinned.filter(function (x) { return x !== c; }) })
.then(renderConfig);
}));
});
$('bias').value = ((cfg.glossary && cfg.glossary.fixes) || []).join('\n');
$('publicUrl').value = cfg.publicUrl || '';
var base = (cfg.publicUrl || app.base).replace(/\/+$/, '');
$('qr').src = '/qr.svg?url=' + encodeURIComponent(base + '/');
$('posterLink').href = '/poster?pin=' + encodeURIComponent(app.pin);
var key = encodeURIComponent(cfg.controlToken);
$('urlToggle').textContent = base + '/api/control/toggle?key=' + key;
$('urlStart').textContent = base + '/api/control/start?key=' + key;
$('urlStop').textContent = base + '/api/control/stop?key=' + key;
}
var searchTimer = null;
$('langSearch').addEventListener('input', function (e) {
var q = e.target.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(function () { renderLangResults(q); }, 90);
});
function renderLangResults(q) {
var box = $('langResults');
box.textContent = '';
if (!q.trim()) return;
Languages.search(q).slice(0, 25).forEach(function (l) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = l.native;
var s = document.createElement('span');
s.textContent = l.name;
b.appendChild(s);
b.addEventListener('click', function () {
var featured = (app.cfg.featured || []).slice();
if (featured.indexOf(l.code) === -1) featured.push(l.code);
save({ featured: featured }, l.name + ' added').then(function () {
$('langSearch').value = '';
box.textContent = '';
renderConfig();
});
});
box.appendChild(b);
});
}
// ───────────────────────────────────────────── the rest
$('saveGlossary').addEventListener('click', function () {
var lines = $('bias').value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
/* Warn rather than silently drop: a line without "=>" is not a correction
and would otherwise look saved while doing nothing. */
var bad = lines.filter(function (l) { return l.indexOf('=>') === -1; });
var good = lines.filter(function (l) { return l.indexOf('=>') !== -1; });
var glossary = Object.assign({}, app.cfg.glossary, { fixes: good });
save({ glossary: glossary }, bad.length
? (good.length + ' saved · ' + bad.length + ' line(s) ignored, they need "=>"')
: (good.length + ' corrections saved'));
});
$('savePublic').addEventListener('click', function () {
save({ publicUrl: $('publicUrl').value.trim() }, 'Address saved').then(renderConfig);
});
$('savePin').addEventListener('click', function () {
var pin = $('newPin').value.trim();
if (pin.length < 4) { toast('Use at least 4 characters.'); return; }
save({ operatorPin: pin }, 'PIN updated').then(function () {
app.pin = pin;
try { sessionStorage.setItem(PIN_KEY, pin); } catch (_) {}
$('newPin').value = '';
renderConfig();
});
});
document.addEventListener('click', function (e) {
var btn = e.target.closest('.copy');
if (!btn) return;
var text = $(btn.dataset.copy).textContent;
navigator.clipboard.writeText(text)
.then(function () { toast('Copied'); })
.catch(function () { toast('Copy failed — select it by hand'); });
});
// Resume a session on reload without retyping the PIN.
var saved = null;
try { saved = sessionStorage.getItem(PIN_KEY); } catch (_) {}
if (saved) {
app.pin = saved;
api('/api/config').then(enterConsole).catch(function () {
app.pin = null;
try { sessionStorage.removeItem(PIN_KEY); } catch (_) {}
$('pin').focus();
});
} else {
$('pin').focus();
}
})();
+94
View File
@@ -0,0 +1,94 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Translation — Poster</title>
<style>
:root { --ink: #10131a; --muted: #5b6478; }
* { box-sizing: border-box; }
body {
margin: 0; background: #f4f5f8; color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
display: grid; place-items: center; min-height: 100vh; padding: 1.5rem;
}
.sheet {
width: 100%; max-width: 46rem; aspect-ratio: 8.5 / 11;
background: #fff; border-radius: 12px; box-shadow: 0 1.5rem 3rem rgba(0,0,0,.12);
padding: 3.5rem 3rem; display: flex; flex-direction: column; align-items: center;
text-align: center; gap: 1.25rem;
}
.church { font-size: .875rem; letter-spacing: .18em; text-transform: uppercase; color: var(--muted); }
h1 { font-size: 2.75rem; line-height: 1.1; margin: 0; font-weight: 750; letter-spacing: -.01em; }
.sub { font-size: 1.25rem; color: var(--muted); margin: 0; }
.qr { width: 17rem; height: 17rem; margin: .5rem 0; }
.url { font-size: 1.125rem; font-weight: 600; letter-spacing: .01em; word-break: break-all; }
.langs {
display: flex; flex-wrap: wrap; justify-content: center; gap: .5rem .875rem;
font-size: 1.0625rem; color: var(--ink); margin-top: auto; padding-top: 1.25rem;
border-top: 1px solid #e3e6ec; width: 100%;
}
.langs span { white-space: nowrap; }
.foot { font-size: .8125rem; color: var(--muted); }
.bar { max-width: 46rem; width: 100%; display: flex; gap: .5rem; justify-content: flex-end; margin-bottom: .75rem; }
.bar button {
border: 1px solid #d3d8e2; background: #fff; color: var(--ink);
padding: .5rem 1rem; border-radius: 8px; font: inherit; font-size: .875rem; cursor: pointer;
}
@media print {
body { background: #fff; padding: 0; display: block; }
.bar { display: none; }
.sheet { box-shadow: none; border-radius: 0; max-width: none; width: 100%; aspect-ratio: auto; min-height: 100vh; }
}
</style>
</head>
<body>
<div class="bar"><button onclick="window.print()">Print</button></div>
<div class="sheet">
<div class="church" id="church">—</div>
<h1 id="head">Listen in your language</h1>
<p class="sub" id="sub">Scan with your phone camera</p>
<img class="qr" id="qr" alt="QR code">
<div class="url" id="url">—</div>
<div class="langs" id="langs"></div>
<div class="foot">Free · No app to download · Works on any phone</div>
</div>
<script src="/languages.js"></script>
<script>
(function () {
var params = new URLSearchParams(location.search);
var pin = params.get('pin');
/* "Listen in your language" written in the languages most likely to be in the
room — a poster nobody can read is just decoration. */
var INVITES = ['Escucha en tu idioma', '用您的语言收听', 'Écoutez dans votre langue',
'Ouça no seu idioma', '당신의 언어로 들으세요', 'Nghe bằng ngôn ngữ của bạn',
'Koute nan lang ou', 'استمع بلغتك', 'Слухайте своєю мовою', 'Sikiliza kwa lugha yako'];
function render(cfg, base) {
document.getElementById('church').textContent = cfg.serviceName || '';
document.getElementById('url').textContent = base.replace(/^https?:\/\//, '');
document.getElementById('qr').src = '/qr.svg?url=' + encodeURIComponent(base + '/');
var box = document.getElementById('langs');
box.textContent = '';
INVITES.forEach(function (t) {
var s = document.createElement('span');
s.textContent = t;
box.appendChild(s);
});
}
var url = pin ? '/api/config' : '/api/languages';
var opts = pin ? { headers: { 'X-Operator-Pin': pin } } : {};
fetch(url, opts).then(function (r) { return r.json(); }).then(function (j) {
var cfg = j.config || j;
var base = (cfg.publicUrl || j.publicBase || location.origin).replace(/\/+$/, '');
render(cfg, base);
}).catch(function () {
render({ serviceName: '' }, location.origin);
});
})();
</script>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
/*
* ui-strings.js — the handful of words on the listener page itself.
*
* Someone who needs a Spanish sermon should not have to read an English button
* to get it. These are hand-written for the languages a US church sees most;
* anything else falls back to English, and the picker still shows every
* language in its own script.
*
* Order: choose, waiting, speak, stop, reconnecting, change, original, live
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.UIStrings = factory();
}(typeof self !== 'undefined' ? self : this, function () {
var KEYS = ['choose', 'waiting', 'speak', 'stop', 'reconnecting', 'change', 'original', 'live'];
var TABLE = {
eng_Latn: ['Choose your language', "The message hasn't started yet", 'Speak aloud', 'Stop', 'Reconnecting…', 'Change language', 'Show original', 'Live'],
spa_Latn: ['Elige tu idioma', 'El mensaje aún no ha comenzado', 'Leer en voz alta', 'Detener', 'Reconectando…', 'Cambiar idioma', 'Ver original', 'En vivo'],
zho_Hans: ['选择您的语言', '信息尚未开始', '朗读', '停止', '正在重新连接…', '更换语言', '显示原文', '直播中'],
zho_Hant: ['選擇您的語言', '信息尚未開始', '朗讀', '停止', '正在重新連線…', '更換語言', '顯示原文', '直播中'],
yue_Hant: ['揀你嘅語言', '信息仲未開始', '朗讀', '停止', '重新連線緊…', '轉語言', '顯示原文', '直播中'],
fra_Latn: ['Choisissez votre langue', "Le message n'a pas encore commencé", 'Lire à voix haute', 'Arrêter', 'Reconnexion…', 'Changer de langue', "Voir l'original", 'En direct'],
por_Latn: ['Escolha o seu idioma', 'A mensagem ainda não começou', 'Ler em voz alta', 'Parar', 'Reconectando…', 'Mudar idioma', 'Ver original', 'Ao vivo'],
por_PT: ['Escolha o seu idioma', 'A mensagem ainda não começou', 'Ler em voz alta', 'Parar', 'A restabelecer ligação…', 'Mudar idioma', 'Ver original', 'Em direto'],
kor_Hang: ['언어를 선택하세요', '메시지가 아직 시작되지 않았습니다', '소리내어 읽기', '중지', '다시 연결 중…', '언어 변경', '원문 보기', '실시간'],
vie_Latn: ['Chọn ngôn ngữ của bạn', 'Bài giảng chưa bắt đầu', 'Đọc to', 'Dừng', 'Đang kết nối lại…', 'Đổi ngôn ngữ', 'Xem bản gốc', 'Trực tiếp'],
hat_Latn: ['Chwazi lang ou', 'Mesaj la poko kòmanse', 'Li awotvwa', 'Sispann', 'N ap rekonekte…', 'Chanje lang', 'Montre orijinal la', 'An dirèk'],
arb_Arab: ['اختر لغتك', 'لم تبدأ الرسالة بعد', 'القراءة بصوت عالٍ', 'إيقاف', 'جارٍ إعادة الاتصال…', 'تغيير اللغة', 'عرض النص الأصلي', 'مباشر'],
rus_Cyrl: ['Выберите язык', 'Проповедь ещё не началась', 'Читать вслух', 'Стоп', 'Переподключение…', 'Сменить язык', 'Показать оригинал', 'В эфире'],
ukr_Cyrl: ['Оберіть мову', 'Проповідь ще не почалася', 'Читати вголос', 'Стоп', 'Перепідключення…', 'Змінити мову', 'Показати оригінал', 'Наживо'],
deu_Latn: ['Wähle deine Sprache', 'Die Predigt hat noch nicht begonnen', 'Vorlesen', 'Stopp', 'Neu verbinden…', 'Sprache ändern', 'Original anzeigen', 'Live'],
ita_Latn: ['Scegli la tua lingua', 'Il messaggio non è ancora iniziato', 'Leggi ad alta voce', 'Ferma', 'Riconnessione…', 'Cambia lingua', 'Mostra originale', 'In diretta'],
tgl_Latn: ['Piliin ang iyong wika', 'Hindi pa nagsisimula ang mensahe', 'Basahin nang malakas', 'Itigil', 'Kumokonekta muli…', 'Palitan ang wika', 'Ipakita ang orihinal', 'Live'],
swh_Latn: ['Chagua lugha yako', 'Ujumbe bado haujaanza', 'Soma kwa sauti', 'Simamisha', 'Inaunganisha tena…', 'Badilisha lugha', 'Onyesha asili', 'Moja kwa moja'],
amh_Ethi: ['ቋንቋዎን ይምረጡ', 'መልእክቱ ገና አልጀመረም', 'ጮክ ብሎ ማንበብ', 'አቁም', 'እንደገና በመገናኘት ላይ…', 'ቋንቋ ቀይር', 'ዋናውን አሳይ', 'በቀጥታ'],
pes_Arab: ['زبان خود را انتخاب کنید', 'پیام هنوز شروع نشده است', 'خواندن با صدای بلند', 'توقف', 'در حال اتصال مجدد…', 'تغییر زبان', 'نمایش متن اصلی', 'زنده'],
hin_Deva: ['अपनी भाषा चुनें', 'संदेश अभी शुरू नहीं हुआ है', 'ज़ोर से पढ़ें', 'रोकें', 'पुनः कनेक्ट हो रहा है…', 'भाषा बदलें', 'मूल दिखाएँ', 'लाइव'],
jpn_Jpan: ['言語を選択してください', 'メッセージはまだ始まっていません', '読み上げる', '停止', '再接続中…', '言語を変更', '原文を表示', 'ライブ'],
pol_Latn: ['Wybierz swój język', 'Kazanie jeszcze się nie zaczęło', 'Czytaj na głos', 'Zatrzymaj', 'Ponowne łączenie…', 'Zmień język', 'Pokaż oryginał', 'Na żywo'],
ron_Latn: ['Alege limba ta', 'Mesajul nu a început încă', 'Citește cu voce tare', 'Oprește', 'Reconectare…', 'Schimbă limba', 'Arată originalul', 'În direct'],
som_Latn: ['Dooro luqaddaada', 'Farriintu weli ma bilaaban', 'Kor u akhri', 'Jooji', 'Dib u xidhiidhinaya…', 'Beddel luqadda', 'Tus qoraalka asalka ah', 'Toos'],
npi_Deva: ['आफ्नो भाषा छान्नुहोस्', 'सन्देश अझै सुरु भएको छैन', 'ठूलो स्वरमा पढ्नुहोस्', 'रोक्नुहोस्', 'पुन: जडान हुँदै…', 'भाषा परिवर्तन गर्नुहोस्', 'मूल देखाउनुहोस्', 'प्रत्यक्ष'],
mya_Mymr: ['သင့်ဘာသာစကားကို ရွေးပါ', 'တရားဒေသနာ မစသေးပါ', 'အသံထွက်ဖတ်ပါ', 'ရပ်ပါ', 'ပြန်လည်ချိတ်ဆက်နေသည်…', 'ဘာသာစကားပြောင်းပါ', 'မူရင်းပြပါ', 'တိုက်ရိုက်']
};
function pack(arr) {
var o = {};
KEYS.forEach(function (k, i) { o[k] = arr[i]; });
return o;
}
var STRINGS = {};
Object.keys(TABLE).forEach(function (code) { STRINGS[code] = pack(TABLE[code]); });
function forLang(code) {
/* Fall back to English rather than showing a key name. A missing string is
a cosmetic problem; a blank button is a broken page. */
/* A regional variant reads its base language's strings; showing a Spanish
speaker an English page because the code says spa_ES rather than
spa_Latn is exactly what languages.js says never to do. */
var VARIANT_OF = { por_PT: 'por_Latn', spa_ES: 'spa_Latn', spa_MX: 'spa_Latn' };
return Object.assign({}, STRINGS.eng_Latn, STRINGS[code] || STRINGS[VARIANT_OF[code]] || {});
}
return { KEYS: KEYS, STRINGS: STRINGS, forLang: forLang, has: function (c) { return !!STRINGS[c]; } };
}));
+2077
View File
File diff suppressed because it is too large Load Diff