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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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; }); }
|
||||
};
|
||||
}));
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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 => 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>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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]; } };
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user