commit c9eef0672c1e9a555ab5b900c15a20a83a839855 Author: Christian Date: Wed Sep 23 15:41:21 2026 -0400 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b46de1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Church Translate — what git keeps is the source; the weight is regenerable. +# +# The sibling folders of this repo (2-app-data, 3-models, 4-voices, +# 5-natural-voices, 6-python-voice-engine, 7-extras) are machine state and +# downloaded assets — 38 GB, and 2-app-data holds the operator PIN and every +# service transcript. They belong in the NAS backup, never in git. + +.DS_Store +*.log + +# installed and built +app/node_modules/ +app/dist/ +app/logs/ + +# shipped installers — Gitea's Releases tab is the place for these +Releases/*.dmg +Releases/*.blockmap + +# vendored executables, re-fetched for packaging (533 MB) +# the browser-side runtime next to them (transformers.min.js, the ort wasm) +# IS kept, so a checkout can still run the congregation page +app/vendor/node +app/vendor/cloudflared +app/vendor-win/ + +# python voice engine: rebuilt per machine by app/tools/setup-local-voices.sh +venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..625d29b --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Church Translate + +Live sermon translation into 100+ languages. One Mac app per campus. No servers, +no deployment, no subscription. + +Congregants point a phone camera at a QR code, tap their language once, and read +along — or let their phone read it aloud in their ear. No app, no account. + +Built because [Kaleo](https://withkaleo.com/) is $24–149/month and +[OneAccord](https://www.oneaccord.ai/) starts at $150/month for five hours, and +both are wrappers around cloud speech and translation APIs that an M-series Mac +runs locally for nothing. + +--- + +## How it works + +Everything is inside the one app. Install it on the Mac at a campus, point it at +the pastor's microphone, and it serves the congregation page itself. + +``` + ┌──────────────── one Mac, one campus ─────────────────┐ + │ │ + │ Dante ─┐ │ + │ Waves ─┼─► audio in ──► voice detection │ + │ M32 ───┘ │ │ + │ ▼ │ + │ Whisper (Mac GPU) │ + │ │ │ + │ NLLB-200 (separate process) │ + │ │ │ + │ built-in web server ──────────────┼──► phones on + │ │ the WiFi + └───────────────────────────────────────────────────────┘ +``` + +**No audio ever leaves the Mac,** and nothing leaves the building unless you +choose to publish it. Only finished sentences cross the network, so a full +auditorium costs almost nothing to serve. + +Two details that are not preference but necessity: translation runs in its own +plain-Node child process, because Chromium's allocator refuses the model's +gigabyte-sized weight blocks and kills the process; and speech recognition runs +on the GPU while translation runs on the CPU, because neither fits in the +other's runtime. + +## What it costs + +| | | +| --- | --- | +| Speech recognition | Whisper large-v3-turbo, on the Mac's GPU — **$0** | +| Translation, 100+ languages | NLLB-200, on the Mac's CPU — **$0** | +| Spoken audio | The listener's own phone — **$0**, at any scale | +| Serving the page | The app itself — **$0** | +| Dante input | Dante Virtual Soundcard, **~$50 once per campus** — or free via Waves or console USB | + +## Performance + +Measured on an M2 Max, from the end of a spoken sentence to captions on a phone. +Partial text appears well before that, so it feels quicker than the numbers. + +| | | +| --- | --- | +| Live preview | updates every **500–800 ms** while the sentence is still being spoken | +| Finished sentence | **~1.6 s** behind the speaker | +| Speech recognition | 4.9× real time (accurate model) | +| Translation | ~390 ms per language, flat over 240 consecutive runs | +| Memory | plateaus at 10–11 GB; no drift across a long service | + +Two speech models run at once: a fast one drives the live preview, an accurate +one produces the line the congregation keeps. They run on separate tracks, so +the preview costs the finished caption almost nothing. + +## Getting started + +- **[docs/SETUP.md](docs/SETUP.md)** — installing at a campus +- **[docs/AUDIO.md](docs/AUDIO.md)** — Dante, Waves SoundGrid, console USB +- **[docs/COMPANION.md](docs/COMPANION.md)** — the Stream Deck button +- **[docs/SUNDAY.md](docs/SUNDAY.md)** — print this and tape it to the desk +- **[docs/PUBLIC-ACCESS.md](docs/PUBLIC-ACCESS.md)** — optional, for people off-site + +```bash +cd app && npm install && npm start # run from source +cd app && npm run dist # build the .app +``` + +## Things worth knowing + +- **Only languages people are actually listening to get translated.** Nobody on + Icelandic means no CPU spent on Icelandic. Pin a language on the operator page + to have it ready before the first person arrives. +- **Corrections beat retraining.** Operator page → Corrections → + `have a cook => Habakkuk`. Whole-word, case-insensitive, instant. +- **A late arrival sees the last 40 lines**, so walking in halfway still makes sense. +- **Transcripts** are written per service, original and every translation, under + `~/Library/Application Support/Church Translate/data/transcripts/`. +- **Each campus is independent.** Nothing is shared, nothing syncs, and one + campus being down has no effect on another. diff --git a/Releases/INSTALL.md b/Releases/INSTALL.md new file mode 100644 index 0000000..19e60ab --- /dev/null +++ b/Releases/INSTALL.md @@ -0,0 +1,106 @@ +# Church Translate — Installing at a Campus + +## What you need +- An Apple Silicon Mac (M1 or newer). 16 GB+ RAM recommended. +- The sermon audio reaching that Mac: Dante Virtual Soundcard, Waves, or any + input device. A mixer direct-out of the pastor's mic is ideal. +- Wi-Fi that phones in the room can join (guest network is fine, but test the + QR code — some guest networks isolate clients from each other, which blocks + the listener page; ask IT to allow "client-to-client" for the Mac, or put + the Mac on the same network as the congregation). + +## Install +1. Copy `Church Translate-3.0.1-arm64.dmg` to the Mac, open it, drag the app + to Applications. +2. First launch on a new Mac (macOS 15+): macOS will block the app because it + is not notarized. Open **System Settings → Privacy & Security**, scroll + down, and click **Open Anyway** next to the Church Translate message, then + launch again. (One time only.) + - Alternative for a tech: `xattr -dr com.apple.quarantine "/Applications/Church Translate.app"` +3. First run downloads the speech and translation models (~2 GB). Let it + finish once on good internet — after that the app works fully offline + except internet sharing. + +## The one networking rule +The Mac must be ON a network the phones can reach — in practice: **keep the +Mac's Wi-Fi ON and joined to the same Wi-Fi the congregation uses** (wired +AV networks are usually isolated VLANs phones cannot route to). The Setup pane +lists every address the Mac has and marks which network each one is; the QR +uses the highlighted one, and Wi-Fi is preferred automatically. If phones +"can't open the page", this is almost always why. + +## Set up (5 minutes) +1. **Setup pane** → Input device: pick the Dante/Waves/interface channel the + sermon arrives on. Speak: the meter should go green past the notch. +2. Adjust **Sensitivity** during a soundcheck: voice crosses the notch, room + noise does not. (This is remembered.) +3. **Operator pane** → add front-page languages, pin ("Always translate") the + languages your campus serves, add corrections (`what it heard => what it + should say`) as you find them. +4. Print the poster / share the QR — congregation phones open the local + address shown on the Setup pane. + +## Stream Deck / Companion +Operator pane shows four URLs (Start / Stop / Toggle / Status) for Bitfocus +Companion "Generic HTTP" buttons. Status returns `live`, `ready`, or `offline` +as plain text. + +## Livestream viewers (YouTube) +1. Setup pane → **Internet sharing** → "Share to the internet". +2. Copy the `https://….trycloudflare.com` link into the stream description or + a pinned comment. **The link is new each time you start sharing** — paste a + fresh one each stream (only the streaming campus needs this). +3. Operator pane → **Stream delay**: set roughly your stream's latency + (typically 10–30s for YouTube) so captions/voice line up with the video. + The room always sees live. +- Optional permanent link: create a named Cloudflare Tunnel on a church domain + (one-time IT task, free) pointing at `http://:3100`, and put that URL + in the operator "publicUrl" setting; then the QR/poster use it too. + +## Free professional voices (optional, one-time setup, $0) +The app can run studio-quality voices ON the Mac itself — no subscription, no +usage fees, weights are Apache-licensed (owned forever). Covers Spanish, +Portuguese, Chinese, Korean, French (+ English, German, Italian, Russian). +One-time setup per Mac (~10 min, ~2GB download): + + bash "/Applications/Church Translate.app/Contents/Resources/app/tools/setup-local-voices.sh" + +Then: Operator pane → Premium voices → "Local (this Mac — free)". If the local +engine ever fails, the built-in voices take over automatically. Languages it +doesn't cover (Vietnamese, Arabic, Haitian) stay on the built-in voices. +fish.audio (prepaid pay-per-use, not a subscription) remains available as an +alternative for those languages. + +## The two Portugueses +"Português (Brasil)" and "Português (Portugal)" carry genuinely different +text — one translation is produced, then each variant applies its own +corpus-tested grammar pass (você/tu system, está falando / está a falar, +me disse / disse-me, vocabulary). Have a native speaker of each spot-check a +service before promoting it heavily. + +## During the service +The Operator pane's health line is the one thing to watch: +- **Green** — healthy; shows how far behind the speaker the captions are. +- **Amber** — running behind; idle languages may be trimmed to catch up. +- **Red** — something is wrong (not connected / badly behind). Restart the + app if it does not clear itself in a minute; settings and corrections are + safe (automatic backups, 30 kept). + +## Per-campus checklist (before the first Sunday) +- [ ] Audio feed reaches the Mac and the meter goes green on speech +- [ ] Phone on the congregation Wi-Fi can open the QR link and hear a voice +- [ ] Languages pinned; corrections list started +- [ ] Stream Deck buttons tested (if used) +- [ ] A volunteer knows: green/amber/red, and "restart the app" as the fix + +## Updates +Replace the app with a newer DMG the same way. Settings, corrections, models, +and voices all survive updates (they live outside the app). Check the version +in the DMG filename; the app's build number is in Finder → Get Info. + +## Known limits +- Apple Silicon only (this build). +- Haitian Creole (and ~70 other languages) are captions-only — no synthesized + voice exists for them; the phone's own voice is used where the device has one. +- The internet-sharing link changes on every start (that is how account-less + tunnels work) — use a named tunnel for a permanent link. diff --git a/app/_bench-batch.mjs b/app/_bench-batch.mjs new file mode 100644 index 0000000..0429ab3 --- /dev/null +++ b/app/_bench-batch.mjs @@ -0,0 +1,118 @@ +/* + * Does one batched decode across N target languages beat N serial calls? + * + * The premise (verified in the installed tokenizer source): NLLB's tokenizer + * prefixes the ENCODER input with the SOURCE language only — the target is + * applied purely as `forced_bos_token_id` on the decoder. So one encoder input + * serves every target, and the only thing that differs per row is the first + * decoder token. + */ +import { pipeline, env, LogitsProcessor, LogitsProcessorList } from '@huggingface/transformers'; +import os from 'os'; import path from 'path'; +env.allowLocalModels = false; +env.cacheDir = path.join(os.homedir(), 'Library/Application Support/Church Translate Models'); + +const TEXT = 'And I want you to understand that God has a plan for your life'; +const LANGS = ['spa_Latn','por_Latn','fra_Latn','zho_Hans','hat_Latn','hin_Deva','kor_Hang','vie_Latn','arb_Arab']; +const SRC = 'eng_Latn'; + +/* Forces a DIFFERENT language token on each row of the batch. The stock + ForcedBOSTokenLogitsProcessor holds a single id for the whole batch. */ +class PerRowForcedBOS extends LogitsProcessor { + constructor(ids, eos) { super(); this.ids = ids; this.eos = eos; } + _call(input_ids, logits) { + for (let i = 0; i < input_ids.length; ++i) { + const row = input_ids[i]; + const d = logits[i].data; + if (row.length === 1) { + /* First decoder step: this row's target language. */ + d.fill(-Infinity); + d[this.ids[i]] = 0; + continue; + } + /* + * transformers.js only breaks the decode loop once EVERY row is done + * (models.js: `if (stop.every(x => x)) break`) and nothing pads a row + * that already finished — so a short language keeps generating free + * text while a long one catches up. Pin finished rows to EOS; the + * trailing tokens are then stripped by skip_special_tokens. + */ + if (row[row.length - 1] === this.eos) { + d.fill(-Infinity); + d[Number(this.eos)] = 0; + } + } + return logits; + } +} + +const pipe = await pipeline('translation', 'Xenova/nllb-200-distilled-600M', { dtype: 'fp32' }); +const tok = pipe.tokenizer, model = pipe.model; + +console.log('tokenizer:', tok.constructor.name, + '| lang_to_token:', typeof tok.lang_to_token, + '| post_processor:', !!(tok.post_processor && tok.post_processor.config)); + +/* Pin the source language into the post-processor exactly as the library does. */ +for (const item of tok.post_processor.config.single) { + if ('SpecialToken' in item && tok.languageRegex.test(item.SpecialToken.id)) { + item.SpecialToken.id = tok.lang_to_token(SRC); + break; + } +} +const EOS = BigInt(model.generation_config.eos_token_id ?? tok.model.convert_tokens_to_ids([''])[0]); +console.log('eos token id:', EOS.toString()); +const bosOf = (l) => tok.model.convert_tokens_to_ids([tok.lang_to_token(l)])[0]; + +/* Confirm the encoder input really is target-independent. */ +const a = tok(TEXT).input_ids.tolist()[0]; +console.log('encoder ids (src-prefixed):', a.slice(0, 4).join(','), '… len', a.length); + +async function batched(langs) { + const enc = tok(Array(langs.length).fill(TEXT), { padding: true, truncation: true }); + const lp = new LogitsProcessorList(); + lp.push(new PerRowForcedBOS(langs.map(bosOf), EOS)); + const out = await model.generate({ + ...enc, + logits_processor: lp, + forced_bos_token_id: null, + max_new_tokens: 128, + }); + const rows = tok.batch_decode(out, { skip_special_tokens: true }); + const o = {}; langs.forEach((l, i) => o[l] = rows[i].trim()); + return o; +} + +async function serial(langs) { + const o = {}; + for (const l of langs) { + const r = await pipe(TEXT, { src_lang: SRC, tgt_lang: l }); + o[l] = (Array.isArray(r) ? r[0] : r).translation_text.trim(); + } + return o; +} + +/* warm both paths */ +await serial(['spa_Latn']); await batched(['spa_Latn']); + +console.log('\n N | serial | batched | speedup'); +console.log('---+----------+----------+---------'); +const sample = {}; +for (const n of [1, 2, 3, 5, 9]) { + const langs = LANGS.slice(0, n); + let s = Date.now(); const so = await serial(langs); s = Date.now() - s; + let b = Date.now(); const bo = await batched(langs); b = Date.now() - b; + console.log(String(n).padStart(2) + ' | ' + (s + 'ms').padStart(8) + ' | ' + (b + 'ms').padStart(8) + + ' | ' + (s / b).toFixed(2) + '×'); + if (n === 9) { sample.serial = so; sample.batched = bo; } +} + +console.log('\n--- identical output? ---'); +let same = 0; +for (const l of LANGS) { + const ok = sample.serial[l] === sample.batched[l]; + if (ok) same++; else console.log(' DIFFER ' + l + '\n serial : ' + sample.serial[l] + '\n batched: ' + sample.batched[l]); +} +console.log(' ' + same + '/' + LANGS.length + ' identical'); +console.log(' spa batched: ' + sample.batched.spa_Latn); +console.log(' zho batched: ' + sample.batched.zho_Hans); diff --git a/app/_bench-mt.mjs b/app/_bench-mt.mjs new file mode 100644 index 0000000..f6beba3 --- /dev/null +++ b/app/_bench-mt.mjs @@ -0,0 +1,32 @@ +import { pipeline, env } from '@huggingface/transformers'; +import os from 'os'; import path from 'path'; +env.allowLocalModels = false; +env.cacheDir = path.join(os.homedir(), 'Library/Application Support/Church Translate Models'); + +/* Partial-length text: a rolling 6s window is roughly this long. */ +const TEXT = 'And I want you to understand that God has a plan for your life'; +const LANGS = ['spa_Latn','por_Latn','fra_Latn','zho_Hans','hat_Latn','hin_Deva','kor_Hang','vie_Latn','arb_Arab']; + +for (const dtype of ['fp32','q8']) { + let pipe; + const t0 = Date.now(); + try { pipe = await pipeline('translation','Xenova/nllb-200-distilled-600M',{ dtype }); } + catch (e) { console.log(dtype + ': FAILED to load — ' + String(e.message).slice(0,80)); continue; } + const load = Date.now() - t0; + /* warm */ + await pipe(TEXT, { src_lang:'eng_Latn', tgt_lang:'spa_Latn' }); + const times = []; const outs = {}; + for (const l of LANGS) { + const s = Date.now(); + const o = await pipe(TEXT, { src_lang:'eng_Latn', tgt_lang:l }); + times.push(Date.now() - s); + outs[l] = (Array.isArray(o)?o[0]:o).translation_text.trim(); + } + const mean = times.reduce((a,b)=>a+b,0)/times.length; + console.log('\n=== ' + dtype + ' === load ' + (load/1000).toFixed(1) + 's'); + console.log(' per language: ' + times.map(t=>t+'ms').join(' ')); + console.log(' mean ' + mean.toFixed(0) + 'ms | 1 lang ' + mean.toFixed(0) + 'ms | 2 langs ' + (mean*2).toFixed(0) + 'ms | 9 langs ' + (mean*9/1000).toFixed(1) + 's'); + console.log(' spa: ' + outs.spa_Latn); + console.log(' fra: ' + outs.fra_Latn); + console.log(' zho: ' + outs.zho_Hans); +} diff --git a/app/_bench-opus.mjs b/app/_bench-opus.mjs new file mode 100644 index 0000000..c07475d --- /dev/null +++ b/app/_bench-opus.mjs @@ -0,0 +1,85 @@ +/* + * Opus-MT bilingual vs NLLB-600M, on THIS Mac, on sermon sentences. + * + * The claim under test: a 77M bilingual model matches or beats NLLB-200- + * distilled-600M on high-resource pairs at ~1/8 the size. Published chrF says + * yes; no published CPU latency comparison exists, and quality on preaching + * English is not what any leaderboard measures. So: measure both here. + */ +import { pipeline, env } from '@huggingface/transformers'; +import os from 'os'; import path from 'path'; +env.allowLocalModels = false; +env.cacheDir = path.join(os.homedir(), 'Library/Application Support/Church Translate Models'); + +const SENTENCES = [ + 'And I want you to understand that God has a plan for your life', + 'Turn with me to the book of Romans chapter eight', + 'He was faithful even when it cost him everything', + 'Would you stand with me as we pray', + 'The grace of God is not something you can earn', + 'Peter denied him three times before the rooster crowed', +]; +const PAIRS = [ + { code: 'spa_Latn', opus: 'Xenova/opus-mt-en-es', name: 'Spanish' }, + { code: 'fra_Latn', opus: 'Xenova/opus-mt-en-fr', name: 'French' }, + { code: 'zho_Hans', opus: 'Xenova/opus-mt-en-zh', name: 'Chinese' }, +]; + +const t = (f) => { const s = Date.now(); return f().then((r) => [Date.now() - s, r]); }; +const out = (o) => (Array.isArray(o) ? o[0] : o).translation_text.trim(); + +console.log('loading NLLB fp32…'); +const nllb = await pipeline('translation', 'Xenova/nllb-200-distilled-600M', { dtype: 'fp32' }); +await nllb(SENTENCES[0], { src_lang: 'eng_Latn', tgt_lang: 'spa_Latn' }); + +const results = {}; +for (const p of PAIRS) { + const ts = [], txt = []; + for (const s of SENTENCES) { + const [ms, o] = await t(() => nllb(s, { src_lang: 'eng_Latn', tgt_lang: p.code })); + ts.push(ms); txt.push(out(o)); + } + results[p.name] = { nllb: { ms: ts, txt } }; +} + +for (const dtype of ['fp32', 'q8']) { + for (const p of PAIRS) { + let pipe; + const t0 = Date.now(); + try { pipe = await pipeline('translation', p.opus, { dtype }); } + catch (e) { console.log(p.name + ' ' + dtype + ': LOAD FAILED — ' + String(e.message).slice(0, 90)); continue; } + const load = Date.now() - t0; + await pipe(SENTENCES[0]); + const ts = [], txt = []; + for (const s of SENTENCES) { const [ms, o] = await t(() => pipe(s)); ts.push(ms); txt.push(out(o)); } + results[p.name]['opus_' + dtype] = { ms: ts, txt, load }; + await pipe.dispose?.(); + } +} + +const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length; +console.log('\n=== speed, ms per sentence (mean of ' + SENTENCES.length + ') ==='); +console.log('language | NLLB fp32 | Opus fp32 | Opus q8 | best speedup'); +for (const p of PAIRS) { + const r = results[p.name]; + const n = mean(r.nllb.ms); + const f = r.opus_fp32 ? mean(r.opus_fp32.ms) : NaN; + const q = r.opus_q8 ? mean(r.opus_q8.ms) : NaN; + const best = Math.min(f || Infinity, q || Infinity); + console.log(p.name.padEnd(9) + ' | ' + (n.toFixed(0) + 'ms').padStart(10) + ' | ' + + ((f ? f.toFixed(0) : '—') + 'ms').padStart(9) + ' | ' + ((q ? q.toFixed(0) : '—') + 'ms').padStart(7) + + ' | ' + (n / best).toFixed(2) + 'x'); +} + +console.log('\n=== output, sentence by sentence ==='); +SENTENCES.forEach((s, i) => { + console.log('\n"' + s + '"'); + for (const p of PAIRS) { + const r = results[p.name]; + console.log(' ' + p.name); + console.log(' NLLB : ' + r.nllb.txt[i]); + if (r.opus_fp32) console.log(' Opus fp32 : ' + r.opus_fp32.txt[i]); + if (r.opus_q8) console.log(' Opus q8 : ' + r.opus_q8.txt[i] + + (r.opus_fp32 && r.opus_q8.txt[i] !== r.opus_fp32.txt[i] ? ' <-- differs from fp32' : '')); + } +}); diff --git a/app/_ctxprobe.mjs b/app/_ctxprobe.mjs new file mode 100644 index 0000000..b090f75 --- /dev/null +++ b/app/_ctxprobe.mjs @@ -0,0 +1,98 @@ +import { pipeline, env } from '@huggingface/transformers'; +env.allowLocalModels = false; +env.cacheDir = '/Users/Studio/Library/Application Support/Church Translate Models'; + +// --- chrF (character n-gram F-score, beta=2), written from scratch: no npm pkg exists for node +function ngrams(s, n) { + const t = s.replace(/\s+/g, ''); + const m = new Map(); + for (let i = 0; i + n <= t.length; i++) { const g = t.slice(i, i + n); m.set(g, (m.get(g) || 0) + 1); } + return m; +} +function chrF(hyp, ref, N = 6, beta = 2) { + let pS = 0, rS = 0, cnt = 0; + for (let n = 1; n <= N; n++) { + const H = ngrams(hyp, n), R = ngrams(ref, n); + let hs = 0, rs = 0, ov = 0; + for (const [g, c] of H) { hs += c; ov += Math.min(c, R.get(g) || 0); } + for (const [, c] of R) rs += c; + if (hs === 0 || rs === 0) continue; + pS += ov / hs; rS += ov / rs; cnt++; + } + if (!cnt) return 0; + const p = pS / cnt, r = rS / cnt; + if (p + r === 0) return 0; + return (1 + beta * beta) * p * r / (beta * beta * p + r); +} + +const PAIRS = [ + ["Habakkuk stood upon his watchtower and waited to see what the Lord would say.", "And the Lord answered him and told him to write the vision plainly upon tablets."], + ["Turn with me to Second Chronicles chapter seven verse fourteen.", "If my people who are called by my name will humble themselves and pray, I will heal their land."], + ["For by grace you have been saved through faith, and that not of yourselves.", "It is the gift of God, not of works, so that no one may boast."], + ["Melchizedek, king of Salem, brought out bread and wine.", "He was priest of God Most High, and he blessed Abram that day."], + ["The Lord is my shepherd; I shall not want.", "He maketh me to lie down in green pastures and leadeth me beside the still waters."], + ["Jesus wept at the tomb of Lazarus.", "Then he cried with a loud voice and called his friend out of the grave."], + ["Paul wrote to the church at Philippi from a prison cell.", "He told them to rejoice in the Lord always, and again he said rejoice."], + ["The elders fell down before the Lamb with harps and golden bowls of incense.", "And they sang a new song, saying, worthy art thou to take the scroll."], +]; +const LANGS = [['spa_Latn','Spanish'],['hat_Latn','Haitian'],['arb_Arab','Arabic'],['kor_Hang','Korean'],['vie_Latn','Vietnamese']]; + +const t0 = Date.now(); +const pipe = await pipeline('translation', 'Xenova/nllb-200-distilled-600M', { dtype: 'fp32' }); +console.log(`MODEL LOAD: ${((Date.now()-t0)/1000).toFixed(1)}s`); + +const tr = async (text, tgt) => { + const t = Date.now(); + const r = await pipe(text, { src_lang: 'eng_Latn', tgt_lang: tgt }); + return { text: (Array.isArray(r)?r[0]:r).translation_text.trim(), ms: Date.now()-t }; +}; +await tr(PAIRS[0][0], 'spa_Latn'); // warmup + +const rows = []; +const tStart = Date.now(); +for (const [code, name] of LANGS) { + for (const [s1, s2] of PAIRS) { + const a = await tr(s1, code); + const b = await tr(s2, code); + const c = await tr(s1 + ' ' + s2, code); + const simA = chrF(c.text, a.text); + const covB = chrF(c.text, b.text); + const lenRatio = c.text.length / a.text.length; + // clean-prefix test: does a.text appear verbatim at the head of c.text? + const prefixExact = c.text.startsWith(a.text); + // fuzzy prefix: chrF of the first a.text.length chars of c vs a + const prefixFuzzy = chrF(c.text.slice(0, a.text.length), a.text); + const dropped = simA > 0.85 && lenRatio < 1.35; + rows.push({ name, code, s1: s1.slice(0,28), a: a.text, b: b.text, c: c.text, + simA:+simA.toFixed(3), covB:+covB.toFixed(3), lenRatio:+lenRatio.toFixed(2), + prefixExact, prefixFuzzy:+prefixFuzzy.toFixed(3), dropped, + msA:a.ms, msB:b.ms, msC:c.ms }); + } +} +const wall = (Date.now()-tStart)/1000; + +console.log(`\nWALL CLOCK for ${rows.length*3} calls (${PAIRS.length} pairs x ${LANGS.length} langs x 3): ${wall.toFixed(1)}s`); +console.log(`=> per-call avg ${(wall*1000/(rows.length*3)).toFixed(0)}ms\n`); + +console.log('lang dropRate meanCovB prefixExact prefixFuzzy meanLenRatio msA msC ratio'); +for (const [code,name] of LANGS) { + const R = rows.filter(r=>r.code===code); + const f=(k)=> (R.reduce((s,r)=>s+(typeof r[k]==='boolean'? (r[k]?1:0) : r[k]),0)/R.length); + console.log( + name.padEnd(12) + + (f('dropped')*100).toFixed(0).padStart(6)+'% ' + + f('covB').toFixed(3).padStart(8) + ' ' + + (f('prefixExact')*100).toFixed(0).padStart(10)+'% ' + + f('prefixFuzzy').toFixed(3).padStart(10) + ' ' + + f('lenRatio').toFixed(2).padStart(12) + ' ' + + f('msA').toFixed(0).padStart(4) + ' ' + f('msC').toFixed(0).padStart(4) + ' ' + + (f('msC')/f('msA')).toFixed(2) + ); +} +console.log('\n--- samples ---'); +for (const r of rows.filter(r=>r.code==='hat_Latn'||r.code==='arb_Arab').slice(0,6)) { + console.log(`\n[${r.name}] ${r.s1}... dropped=${r.dropped} covB=${r.covB} prefixExact=${r.prefixExact}`); + console.log(' a:', r.a); console.log(' b:', r.b); console.log(' c:', r.c); +} +import fs from 'fs'; +fs.writeFileSync('/private/tmp/claude-502/-Users-Studio-Documents-BandLineupDisplay/cee248f6-d861-4845-bd13-03867cf46596/scratchpad/rows.json', JSON.stringify(rows,null,1)); diff --git a/app/_gen-lexicon.js b/app/_gen-lexicon.js new file mode 100644 index 0000000..01877df --- /dev/null +++ b/app/_gen-lexicon.js @@ -0,0 +1,147 @@ +/* + * Regenerates bible-lexicon.js from every bible/lexicon-*.json. + * Run after editing any lexicon file. + */ +const fs = require('fs'); +const path = require('path'); + +const files = fs.readdirSync('bible').filter((f) => /^lexicon-.*\.json$/.test(f)).sort(); +const english = {}; // lang -> { English term: local form } +const fixes = {}; // lang -> { wrong local form: right local form } +const refs = {}; +let total = 0; + +/* A variant can INHERIT its base language's table and then diverge. Portugal + keeps "David" where Brazil writes "Davi", so por_PT cannot simply reuse + por_Latn — but it should not have to restate 85 shared entries either. */ +const inheritQueue = []; + +for (const f of files) { + const j = JSON.parse(fs.readFileSync(path.join('bible', f), 'utf8')); + const lang = j.language; + if (j.inherits) { inheritQueue.push(j); refs[lang] = j.reference; continue; } + refs[lang] = j.reference; + const t = {}; + for (const grp of ['books', 'people', 'places', 'terms']) { + for (const [en, loc] of Object.entries(j[grp] || {})) { + if (en.startsWith('_') || en === loc) continue; // no-ops earn nothing + t[en] = loc; + } + } + english[lang] = t; + total += Object.keys(t).length; + const tf = {}; + for (const [bad, good] of Object.entries(j._target_fixes || {})) { + if (bad.startsWith('_')) continue; + tf[bad] = good; + } + if (Object.keys(tf).length) fixes[lang] = tf; +} + +/* Resolve inheritance after every base table is built. */ +for (const j of inheritQueue) { + const from = english[j.inherits]; + if (!from) throw new Error(j.language + ' inherits ' + j.inherits + ', which does not exist'); + const t = Object.assign({}, from); + for (const k of (j.remove || [])) delete t[k]; + for (const [en, loc] of Object.entries(j.overrides || {})) { + if (en === loc) delete t[en]; // "same as English" means: do not touch it + else t[en] = loc; + } + english[j.language] = t; + total += Object.keys(t).length; + const fb = fixes[j.inherits]; + if (fb) fixes[j.language] = Object.assign({}, fb, j.targetFixes || {}); + else if (j.targetFixes) fixes[j.language] = j.targetFixes; +} + +const j2 = (o) => JSON.stringify(o, null, 2).split('\n').map((l, i) => i ? ' ' + l : l).join('\n'); + +const OUT = [ + '/*', + ' * bible-lexicon.js — GENERATED from bible/lexicon-*.json by _gen-lexicon.js.', + ' * Do not hand-edit; edit the JSON and regenerate.', + ' *', + ' * TWO TABLES, and the difference is the whole safety argument.', + ' *', + ' * ENGLISH[] maps an ENGLISH term to the local form. It only ever fires on', + ' * English text that survived translation, so there is no target-language', + ' * homograph to collide with — the trap that makes a target-side lexicon', + ' * dangerous (a Spanish entry for "Marca" would turn "la marca de la bestia"', + ' * into "la Marcos de la bestia"). No CJK word-boundary problem either, and', + ' * no noun gender or grammatical case to agree with.', + ' *', + ' * FIXES[] is target-side and therefore restricted to forms MEASURED wrong', + ' * against a named published edition, each one checked for not being an', + ' * ordinary word in that language. Never add to it by intuition.', + ' *', + ' * Naming authorities: ' + Object.entries(refs).map(([k, v]) => k + ' = ' + v).join('; ') + '.', + ' * Verse TEXT from any copyrighted edition appears nowhere in this app; proper', + ' * nouns and book titles are naming conventions, not copyrightable subject', + ' * matter.', + ' */', + '(function (root, factory) {', + " if (typeof module === 'object' && module.exports) module.exports = factory();", + ' else root.BibleLexicon = factory();', + "}(typeof self !== 'undefined' ? self : this, function () {", + " 'use strict';", + '', + ' const ENGLISH = ' + j2(english) + ';', + '', + ' const FIXES = ' + j2(fixes) + ';', + '', + ' /* A regional variant inherits its base language. Canonical list: NLLB_ALIAS. */', + " const VARIANT_OF = { spa_ES: 'spa_Latn', spa_MX: 'spa_Latn', por_PT: 'por_Latn', zho_Hant: 'zho_Hans' };", + ' const base = (lang) => (ENGLISH[lang] ? lang : VARIANT_OF[lang]);', + '', + ' function forLanguage(lang) { return ENGLISH[base(lang)] || null; }', + '', + ' /*', + ' * WHOLE-UTTERANCE NAME LOOKUP — the deterministic guarantee.', + ' *', + ' * A bare name is the worst input this model receives. MEASURED into', + ' * Spanish: "Peter." came back as "\u00bfQu\u00e9 es eso?" and "Jesus." as', + ' * "\u00bfQu\u00e9 quieres?" — the name GONE, replaced by invented dialogue.', + ' * Into French, "Habakkuk." became "Habakuk est mort" (Habakkuk is dead).', + ' * A preacher listing names one at a time produced a run of questions with', + ' * no names in them at all.', + ' *', + ' * The cause is ONE CHARACTER: the terminal period. Without it the model', + ' * translates the term ("Peter" -> "Pedro"); with it, it reads a complete', + ' * conversational turn and invents a reply. preach.js strips the period for', + ' * short utterances, which fixes most of it — this is the guarantee for the', + ' * rest, because a name we know should never depend on the model at all.', + ' *', + ' * Matched on the bare word, punctuation and case stripped, and ONLY when', + ' * the utterance is that name and nothing else — an ordinary sentence', + ' * containing the name is untouched.', + ' */', + ' function wholeUtterance(text, lang) {', + ' const t = ENGLISH[base(lang)];', + ' if (!t) return null;', + ' const bare = String(text == null ? \'\' : text)', + ' .replace(/^[\\s"\\u2018\\u201c(\\[\\\']+/, \'\')', + ' .replace(/[\\s.!?,;:"\\u2019\\u201d)\\]\\\']+$/, \'\')', + ' .trim();', + ' if (!bare) return null;', + ' if (Object.prototype.hasOwnProperty.call(t, bare)) return t[bare];', + ' const lower = bare.toLowerCase();', + ' for (const k of Object.keys(t)) if (k.toLowerCase() === lower) return t[k];', + ' return null;', + ' }', + '', + ' function fixesFor(lang) { return FIXES[base(lang)] || null; }', + ' function languages() { return Object.keys(ENGLISH); }', + '', + ' return { forLanguage: forLanguage, fixesFor: fixesFor,', + ' wholeUtterance: wholeUtterance, languages: languages,', + ' ENGLISH: ENGLISH, FIXES: FIXES };', + '}));' +].join('\n'); + +fs.writeFileSync('bible-lexicon.js', OUT); +console.log('wrote bible-lexicon.js — ' + files.length + ' languages, ' + total + ' English entries'); +for (const [l, t] of Object.entries(english)) { + console.log(' ' + l.padEnd(10) + String(Object.keys(t).length).padStart(4) + ' entries, ' + + Object.keys(fixes[l] || {}).length + ' target fixes (' + refs[l] + ')'); +} diff --git a/app/_lenprobe.mjs b/app/_lenprobe.mjs new file mode 100644 index 0000000..db6a4c7 --- /dev/null +++ b/app/_lenprobe.mjs @@ -0,0 +1,32 @@ +import { pipeline, env } from '@huggingface/transformers'; +env.allowLocalModels = false; +env.cacheDir = '/Users/Studio/Library/Application Support/Church Translate Models'; +const pipe = await pipeline('translation','Xenova/nllb-200-distilled-600M',{dtype:'fp32'}); +const G = [ + ["Habakkuk stood upon his watchtower.","He waited to see what the Lord would say to him.","And the Lord answered him and told him to write the vision.","Make it plain upon tablets, so that he may run who reads it."], + ["Turn with me to Second Chronicles chapter seven.","If my people who are called by my name will humble themselves and pray,","and seek my face and turn from their wicked ways,","then will I hear from heaven and will heal their land."], + ["Paul wrote to the church at Philippi from a prison cell.","He told them to rejoice in the Lord always.","And again he said unto them, rejoice.","Let your moderation be known unto all men, for the Lord is at hand."], +]; +const L=[['hat_Latn','Haitian'],['arb_Arab','Arabic'],['kor_Hang','Korean']]; +const t=async(x,c)=>{const s=Date.now();const r=await pipe(x,{src_lang:'eng_Latn',tgt_lang:c});return{text:(Array.isArray(r)?r[0]:r).translation_text.trim(),ms:Date.now()-s}}; +await t(G[0][0],'spa_Latn'); +function ng(s,n){const q=s.replace(/\s+/g,'');const m=new Map();for(let i=0;i+n<=q.length;i++){const g=q.slice(i,i+n);m.set(g,(m.get(g)||0)+1)}return m} +function chrF(h,r,N=6,b=2){let P=0,R=0,c=0;for(let n=1;n<=N;n++){const H=ng(h,n),F=ng(r,n);let hs=0,rs=0,ov=0;for(const[g,x]of H){hs+=x;ov+=Math.min(x,F.get(g)||0)}for(const[,x]of F)rs+=x;if(!hs||!rs)continue;P+=ov/hs;R+=ov/rs;c++}if(!c)return 0;const p=P/c,r2=R/c;return p+r2?(1+b*b)*p*r2/(b*b*p+r2):0} +console.log('lang nSent lenRatio(out/sum-of-singles) covLAST covFIRST ms'); +for(const[code,name]of L){ + for(const gi in G){ + const S=G[gi]; + const singles=[];for(const s of S)singles.push((await t(s,code)).text); + for(const n of [2,3,4]){ + const src=S.slice(0,n).join(' '); + const o=await t(src,code); + const sumLen=singles.slice(0,n).reduce((a,x)=>a+x.length,0); + console.log(name.padEnd(9)+String(n).padStart(4)+ + (' '+(o.text.length/sumLen).toFixed(2)).padStart(28)+ + (' '+chrF(o.text,singles[n-1]).toFixed(3)).padStart(9)+ + (' '+chrF(o.text,singles[0]).toFixed(3)).padStart(10)+ + (' '+o.ms).padStart(8)); + if(n===4)console.log(' OUT:',o.text.slice(0,240)); + } + } +} diff --git a/app/_pb.js b/app/_pb.js new file mode 100644 index 0000000..a83b4c3 --- /dev/null +++ b/app/_pb.js @@ -0,0 +1,32 @@ +const ort = require('onnxruntime-node'); +const fs = require('fs'); +const D='/private/tmp/claude-502/-Users-Studio-Documents-BandLineupDisplay/cee248f6-d861-4845-bd13-03867cf46596/scratchpad/piper/'; +const cfg = JSON.parse(fs.readFileSync(D+'lessac.json','utf8')); +console.log('sample_rate', cfg.audio.sample_rate, 'num_symbols', cfg.num_symbols, 'phoneme_type', cfg.phoneme_type, 'lang', cfg.language && cfg.language.code); +console.log('inference defaults', JSON.stringify(cfg.inference)); +const map = cfg.phoneme_id_map; +// crude: use the espeak IPA of the sentence is unavailable here; approximate with the raw characters that exist in the map +const IPA = "dʒiːzəs sɛd tuː ðɛm aɪ æm ðə bɹɛd ʌv laɪf huːɛvɚ kʌmz tuː miː"; +const ids=[map['^'][0]]; +for (const ch of IPA){ if(map[ch]){ ids.push(map[ch][0]); ids.push(map['_'][0]); } } +ids.push(map['$'][0]); +(async()=>{ + const ep = process.argv[2]||'cpu'; + const t=Date.now(); + const sess = await ort.InferenceSession.create(D+'lessac.onnx',{executionProviders:[ep]}); + console.log(`[${ep}] load ${Date.now()-t}ms inputs=${sess.inputNames.join(',')}`); + const mk=(len)=>({ + input: new ort.Tensor('int64', BigInt64Array.from(ids.map(BigInt)), [1, ids.length]), + input_lengths: new ort.Tensor('int64', BigInt64Array.from([BigInt(ids.length)]), [1]), + scales: new ort.Tensor('float32', Float32Array.from([cfg.inference.noise_scale, len, cfg.inference.noise_w]), [3]) + }); + for (const ls of [1.0, 0.85, 0.7]) { + const feeds = mk(ls); + let o = await sess.run(feeds); + const k=sess.outputNames[0]; + const frames=o[k].data.length; + let tot=0; for(let i=0;i<8;i++){const a=Date.now(); await sess.run(feeds); tot+=Date.now()-a;} + const avg=tot/8/1000, dur=frames/cfg.audio.sample_rate; + console.log(`[${ep}] length_scale=${ls} audio ${dur.toFixed(2)}s infer ${avg.toFixed(3)}s RTF ${(avg/dur).toFixed(4)} rss ${Math.round(process.memoryUsage().rss/1048576)}MB`); + } +})().catch(e=>console.log(`[${process.argv[2]}] ERR`, e.message)); diff --git a/app/_preview-groups.html b/app/_preview-groups.html new file mode 100644 index 0000000..a99132e --- /dev/null +++ b/app/_preview-groups.html @@ -0,0 +1,60 @@ + +group + channel CSS check + +
Church Translate +3.11.0 · 75
+
+

Hero spans both columns

+
+
+ +
+
+ channel 1 +
+
+

Some content so the section has height.

+
+
+
+
+

Line

Line

Line

Line

Line

Line

Line

Line

+
+
+

Line

Line

+

Log

+
+ +
+ + diff --git a/app/_preview-langlist.html b/app/_preview-langlist.html new file mode 100644 index 0000000..fcede33 --- /dev/null +++ b/app/_preview-langlist.html @@ -0,0 +1,51 @@ + + + + +
+

Languages on the front page

+

+
+ + + +
+
+ +

On — all 104 languages are offered. 32 of them have a voice; the rest show captions only.

+
+ + diff --git a/app/_probe-bare.mjs b/app/_probe-bare.mjs new file mode 100644 index 0000000..6d4adf1 --- /dev/null +++ b/app/_probe-bare.mjs @@ -0,0 +1,25 @@ +import { pipeline, env } from '@huggingface/transformers'; +import { createRequire } from 'module'; +import os from 'os'; import path from 'path'; +const require = createRequire(import.meta.url); +const Guard = require('./mt-guard.js'); +const B = require('./bible-lexicon.js'); +env.allowLocalModels = false; +env.cacheDir = path.join(os.homedir(), 'Library/Application Support/Church Translate Models'); +const pipe = await pipeline('translation', 'Xenova/nllb-200-distilled-600M', { dtype: 'fp32' }); +const say = async (t, l) => { + const o = await pipe(t, { src_lang: 'eng_Latn', tgt_lang: l }); + return (Array.isArray(o) ? o[0] : o).translation_text.trim(); +}; +const lex = B.forLanguage('spa_Latn'); +const BARE = ['Peter.','John.','Matthew.','Noah.','Paul.','Moses.','David.','Abraham.','Jesus.','Mary.','Habakkuk.','Genesis.','Romans.','Peter','John']; +console.log('a BARE NAME as the whole utterance:\n'); +for (const t of BARE) { + const raw = await say(t, 'spa_Latn'); + const g = Guard.clean(t, raw); + const bare = t.replace(/\.$/, ''); + const want = lex[bare] || bare; + const kept = g.text.toLowerCase().indexOf(want.toLowerCase()) !== -1 || + g.text.toLowerCase().indexOf(bare.toLowerCase()) !== -1; + console.log(' ' + t.padEnd(12) + 'NLLB: ' + raw.padEnd(32) + 'after guard: ' + g.text.padEnd(26) + (kept ? '' : '<-- NAME LOST')); +} diff --git a/app/_probe-book.mjs b/app/_probe-book.mjs new file mode 100644 index 0000000..b77ee92 --- /dev/null +++ b/app/_probe-book.mjs @@ -0,0 +1,32 @@ +import { pipeline, env } from '@huggingface/transformers'; +import os from 'os'; import path from 'path'; +env.allowLocalModels=false; +env.cacheDir=path.join(os.homedir(),'Library/Application Support/Church Translate Models'); +const pipe=await pipeline('translation','Xenova/nllb-200-distilled-600M',{dtype:'fp32'}); + +const PHRASINGS=[ + 'Open up to the book of John.', + 'Open to the book of John.', + 'Turn to the book of John.', + 'Turn with me to the book of John.', + 'Go to the book of John.', + 'Open your Bibles to the book of John.', + 'The book of John chapter three.', + 'In the book of Romans Paul writes this.', + 'Read the book of Genesis with me.', + 'This is a test of the names Jacob, Peter, John.', + 'Jacob, Peter and John were there.', + 'The names are Jacob, Peter and John.' +]; +console.log('=== SPANISH: which phrasings get "libro" and which get "obra"? ==='); +for (const t of PHRASINGS) { + const o=await pipe(t,{src_lang:'eng_Latn',tgt_lang:'spa_Latn'}); + const s=(Array.isArray(o)?o[0]:o).translation_text.trim(); + const flag=/\bobra/i.test(s)?' <-- OBRA (wrong)':(/\blibro/i.test(s)?' libro OK':''); + console.log(' '+t+'\n '+s+flag); +} +console.log('\n=== "the book of X" across the served languages ==='); +for (const lang of ['spa_Latn','por_Latn','fra_Latn','zho_Hans','kor_Hang','vie_Latn','hat_Latn','arb_Arab','hin_Deva']) { + const o=await pipe('Open up to the book of John.',{src_lang:'eng_Latn',tgt_lang:lang}); + console.log(' '+lang+' '+(Array.isArray(o)?o[0]:o).translation_text.trim()); +} diff --git a/app/_probe-dot.mjs b/app/_probe-dot.mjs new file mode 100644 index 0000000..d42ac4e --- /dev/null +++ b/app/_probe-dot.mjs @@ -0,0 +1,22 @@ +import { pipeline, env } from '@huggingface/transformers'; +import os from 'os'; import path from 'path'; +env.allowLocalModels = false; +env.cacheDir = path.join(os.homedir(), 'Library/Application Support/Church Translate Models'); +const pipe = await pipeline('translation', 'Xenova/nllb-200-distilled-600M', { dtype: 'fp32' }); +const say = async (t, l) => { + const o = await pipe(t, { src_lang: 'eng_Latn', tgt_lang: l }); + return (Array.isArray(o) ? o[0] : o).translation_text.trim(); +}; +const CASES = ['Peter.','John.','Matthew.','Noah.','Jesus.','Mary.','Moses.','Habakkuk.','Donald Trump.', + 'Yes.','Okay.','Right.','Exactly.','Amen.','Watch this.','Come on.','Genesis.','Romans.', + 'Simon Peter.','John the Baptist.']; +const LANGS = ['spa_Latn','por_Latn','fra_Latn']; +for (const lang of LANGS) { + console.log('\n=== ' + lang + ' ==='); + console.log(' input'.padEnd(22) + 'WITH the period'.padEnd(34) + 'period STRIPPED before MT'); + for (const t of CASES) { + const withDot = await say(t, lang); + const stripped = await say(t.replace(/[.!?]+$/, ''), lang); + console.log(' ' + t.padEnd(20) + withDot.slice(0, 32).padEnd(34) + stripped.slice(0, 32)); + } +} diff --git a/app/_probe-idioms.mjs b/app/_probe-idioms.mjs new file mode 100644 index 0000000..b1e2797 --- /dev/null +++ b/app/_probe-idioms.mjs @@ -0,0 +1,43 @@ +import { pipeline, env } from '@huggingface/transformers'; +import os from 'os'; import path from 'path'; +env.allowLocalModels=false; +env.cacheDir=path.join(os.homedir(),'Library/Application Support/Church Translate Models'); +const pipe=await pipeline('translation','Xenova/nllb-200-distilled-600M',{dtype:'fp32'}); +const say=async(t,l='spa_Latn')=>{const o=await pipe(t,{src_lang:'eng_Latn',tgt_lang:l});return (Array.isArray(o)?o[0]:o).translation_text.trim();}; + +/* Real preaching idioms. The question for each is not "is it good Spanish" but + "did the MEANING survive" — a fluent wrong answer is the dangerous case. */ +const IDIOMS=[ + 'Open up to the book of John.', + 'Open up to John chapter three.', + 'Open up your Bibles.', + 'Turn with me to John chapter three.', + 'Look at verse four with me.', + 'Drop down to verse ten.', + 'Back up to verse one.', + 'Come on, somebody.', + 'Give God a hand of praise.', + 'Put a hand on your neighbor.', + 'Say it with me.', + 'Can somebody say amen.', + 'I want you to catch this.', + 'Watch this.', + 'Are you tracking with me.', + 'That will preach.', + 'He is a good good Father.', + 'God showed up.', + 'The enemy is defeated.', + 'Walk it out.', + 'Let that sink in.', + 'Write this down.', + 'Circle that in your Bible.', + 'Underline that word.', + 'We are going to camp out here for a minute.', + 'Let me unpack that for you.', + 'Turn the page.', + 'Stand to your feet.', + 'Bow your heads with me.' +]; +console.log('idiom -> Spanish'); +console.log('='.repeat(70)); +for (const t of IDIOMS) console.log(' '+t+'\n '+await say(t)); diff --git a/app/_probe-names.mjs b/app/_probe-names.mjs new file mode 100644 index 0000000..5280de5 --- /dev/null +++ b/app/_probe-names.mjs @@ -0,0 +1,41 @@ +import { pipeline, env } from '@huggingface/transformers'; +import { createRequire } from 'module'; +import os from 'os'; import path from 'path'; import fs from 'fs'; +const require = createRequire(import.meta.url); +env.allowLocalModels=false; +env.cacheDir=path.join(os.homedir(),'Library/Application Support/Church Translate Models'); +const pipe=await pipeline('translation','Xenova/nllb-200-distilled-600M',{dtype:'fp32'}); + +/* The names a preacher actually says, frequency-ranked from the owner's own + Strong's export, plus the Gospel/epistle authors that are rare INSIDE + scripture but constant as book references. */ +const ranked=require('/private/tmp/claude-502/-Users-Studio-Documents-BandLineupDisplay/cee248f6-d861-4845-bd13-03867cf46596/scratchpad/proper-nouns.json') + .map(r=>r.english).filter(n=>/^[A-Z][a-z]+$/.test(n)); +const NAMES=[...new Set(['Matthew','Mark','Luke','John','Paul','Peter','James','Jude','Timothy','Titus', + 'Jesus','Moses','David','Abraham','Isaac','Jacob','Joseph','Mary','Elijah','Isaiah','Jeremiah', + 'Daniel','Solomon','Samuel','Saul','Noah','Adam','Eve','Joshua','Thomas','Philip','Andrew', + 'Stephen','Barnabas','Silas','Apollos','Lazarus','Nicodemus','Pilate','Herod','Jerusalem', + 'Bethlehem','Nazareth','Galilee','Egypt','Babylon','Corinth','Ephesus','Rome','Zion', + ...ranked.slice(0,20)])].slice(0,60); + +const LANGS=['por_Latn','fra_Latn','zho_Hans','zho_Hant','kor_Hang','vie_Latn','hat_Latn','arb_Arab','hin_Deva']; +const LATIN=/[A-Za-z]/; +const NONLATIN=new Set(['zho_Hans','zho_Hant','kor_Hang','arb_Arab','hin_Deva']); + +const out={}; +for (const lang of LANGS) { + out[lang]={}; + for (const n of NAMES) { + const o=await pipe('We read about '+n+' in the Bible.',{src_lang:'eng_Latn',tgt_lang:lang}); + out[lang][n]=(Array.isArray(o)?o[0]:o).translation_text.trim(); + } + /* For a non-Latin script, surviving Latin letters mean the name was never + translated at all — an unambiguous, mechanically detectable failure. */ + const survived=NONLATIN.has(lang) + ? NAMES.filter(n=>LATIN.test(out[lang][n])) + : NAMES.filter(n=>new RegExp('\\b'+n+'\\b').test(out[lang][n])); + console.log(lang+' '+String(survived.length).padStart(2)+'/'+NAMES.length+' names left in ENGLISH: '+survived.slice(0,18).join(', ')+(survived.length>18?' …':'')); + out[lang]._survived=survived; +} +fs.writeFileSync('/tmp/name-probe.json',JSON.stringify(out,null,1)); +console.log('\nsaved /tmp/name-probe.json'); diff --git a/app/_probe-nllb.mjs b/app/_probe-nllb.mjs new file mode 100644 index 0000000..ef85ea1 --- /dev/null +++ b/app/_probe-nllb.mjs @@ -0,0 +1,53 @@ +/* Measure the hallucination and what actually stops it. Runs the same model, + dtype and cache the app uses, in a plain node process like mt-service.js. */ +import { pipeline, env } from '@huggingface/transformers'; +import os from 'os'; +import path from 'path'; + +env.allowLocalModels = false; +env.cacheDir = path.join(os.homedir(), 'Library/Application Support/Church Translate Models'); + +const t0 = Date.now(); +const pipe = await pipeline('translation', 'Xenova/nllb-200-distilled-600M', { dtype: 'fp32' }); +console.log('loaded in ' + ((Date.now() - t0) / 1000).toFixed(1) + 's\n'); + +/* The exact lines from the operator's screenshot, plus stress cases. */ +const CASES = [ + 'Donald Trump.', + 'Hello, hello.', + 'His name was John.', + 'Open up to the book, Matthew.', + 'Today we are going to talk about Peter in the Bible.', + 'Turn with me to John chapter 3.', + 'Amen.', + 'Jesus wept.', + 'Paul wrote to the church at Corinth.', + 'How many countries in the world?', + 'And God said, let there be light, and there was light.' +]; + +const CONFIGS = [ + { name: 'current (no params)', opts: {} }, + { name: 'cap 2x+8', opts: null, cap: (n) => Math.ceil(n * 2) + 8 }, + { name: 'cap 2x+8 +nrng3', opts: { no_repeat_ngram_size: 3 }, cap: (n) => Math.ceil(n * 2) + 8 }, + { name: 'beams2 cap2x+8', opts: { num_beams: 2, no_repeat_ngram_size: 3 }, cap: (n) => Math.ceil(n * 2) + 8 } +]; + +const tok = pipe.tokenizer; +for (const cfg of CONFIGS) { + console.log('=== ' + cfg.name + ' ==='); + let total = 0; + for (const text of CASES) { + const nIn = tok(text).input_ids.dims.at(-1); + const opts = Object.assign({ src_lang: 'eng_Latn', tgt_lang: 'spa_Latn' }, cfg.opts || {}); + if (cfg.cap) opts.max_new_tokens = cfg.cap(nIn); + const s = Date.now(); + const out = await pipe(text, opts); + const ms = Date.now() - s; + total += ms; + const got = (Array.isArray(out) ? out[0] : out).translation_text.trim(); + const flag = (!/[??]/.test(text) && /[??]/.test(got)) ? ' <-- INVENTED A QUESTION' : ''; + console.log(' ' + String(ms).padStart(5) + 'ms in' + String(nIn).padStart(3) + ' ' + got + flag); + } + console.log(' total ' + total + 'ms\n'); +} diff --git a/app/_probe-number.mjs b/app/_probe-number.mjs new file mode 100644 index 0000000..4f77f25 --- /dev/null +++ b/app/_probe-number.mjs @@ -0,0 +1,45 @@ +import { pipeline, env } from '@huggingface/transformers'; +import os from 'os'; import path from 'path'; +env.allowLocalModels=false; +env.cacheDir=path.join(os.homedir(),'Library/Application Support/Church Translate Models'); +const pipe=await pipeline('translation','Xenova/nllb-200-distilled-600M',{dtype:'fp32'}); +const say=async(t)=>{const o=await pipe(t,{src_lang:'eng_Latn',tgt_lang:'spa_Latn'});return (Array.isArray(o)?o[0]:o).translation_text.trim();}; + +/* Unambiguous number markers only. Verb endings are skipped deliberately — + they are where the ambiguity lives, so counting them would beg the question. */ +function number(s){ + const SING=/\b(tú|ti|tu|tus|te|contigo|usted|consigo)\b/i; + const PLUR_LA=/\b(ustedes|sus)\b/i; + const PLUR_ES=/\b(vosotros|vuestr[oa]s?|os)\b/i; + const vosVerb=/\w(áis|éis)\b|\b\w+[aei]d\b/; // habláis / hablad + const sing=SING.test(s), la=PLUR_LA.test(s), es=PLUR_ES.test(s)||vosVerb.test(s); + if (es && !sing) return 'PLURAL (Peninsular)'; + if (la && !sing) return 'PLURAL (LatAm)'; + if (sing && !la && !es) return 'SINGULAR'; + if (sing && (la||es)) return 'MIXED — both in one sentence'; + return 'unmarked'; +} + +/* Every one of these is a preacher speaking to a room, so every one should be + PLURAL in Spanish. Anything singular is the model guessing wrong. */ +const TO_THE_ROOM=[ + 'Open your Bibles.','Bow your heads with me.','Lift your hands.','Close your eyes.', + 'Stand to your feet.','Repeat after me.','Pray with me.','Look at verse four with me.', + 'Turn to the person next to you.','Give thanks to the Lord.','Come to the altar.', + 'Listen to me carefully.','I want you to understand this.','You are the light of the world.', + 'Do you believe that?','Love one another.','Do not be afraid.','Remember what He did for you.', + 'God has a plan for you.','You have been forgiven.','Trust in the Lord with all your heart.', + 'Forgive those who hurt you.','You cannot serve two masters.','Take out your notes.', + 'Say it with me.','Are you ready?','Let us pray together.','Turn the page with me.' +]; +const tally={}; +console.log('every line below is addressed to a ROOM, so every one should be PLURAL\n'); +for (const t of TO_THE_ROOM) { + const s=await say(t); const n=number(s); + tally[n]=(tally[n]||0)+1; + const bad = n==='SINGULAR' ? ' <-- singular: wrong for a congregation' : ''; + console.log(' '+n.padEnd(24)+s+bad); +} +console.log('\nTALLY of '+TO_THE_ROOM.length+' congregation-directed sentences:'); +for (const [k,v] of Object.entries(tally).sort((a,b)=>b[1]-a[1])) + console.log(' '+String(v).padStart(3)+' '+k+' ('+Math.round(v/TO_THE_ROOM.length*100)+'%)'); diff --git a/app/_probe-progress.mjs b/app/_probe-progress.mjs new file mode 100644 index 0000000..764dabd --- /dev/null +++ b/app/_probe-progress.mjs @@ -0,0 +1,34 @@ +/* Everything below is already on disk. Whatever this prints is what the app + calls "Downloading". */ +import { pipeline, env } from '@huggingface/transformers'; +import os from 'os'; import path from 'path'; +env.allowLocalModels = false; +env.cacheDir = path.join(os.homedir(), 'Library/Application Support/Church Translate Models'); + +const seen = []; +const t0 = Date.now(); +await pipeline('translation', 'Xenova/nllb-200-distilled-600M', { + dtype: 'fp32', + progress_callback: (p) => seen.push({ ms: Date.now() - t0, ...p }) +}); + +const byStatus = {}; +for (const e of seen) byStatus[e.status] = (byStatus[e.status] || 0) + 1; +console.log('event counts by status:', byStatus); +console.log('total events:', seen.length, 'over', ((Date.now()-t0)/1000).toFixed(1) + 's\n'); + +console.log('per file — did any bytes actually move?'); +const files = {}; +for (const e of seen) { + if (!e.file) continue; + const f = files[e.file] || (files[e.file] = { statuses: new Set(), maxTotal: 0, progressEvents: 0 }); + f.statuses.add(e.status); + if (e.total) f.maxTotal = Math.max(f.maxTotal, e.total); + if (e.status === 'progress') f.progressEvents++; +} +for (const [name, f] of Object.entries(files)) { + console.log(' ' + name.padEnd(38) + + (f.maxTotal ? (f.maxTotal / 1048576).toFixed(1) + ' MB' : ' — ').padStart(10) + + ' progress-events=' + String(f.progressEvents).padEnd(5) + + ' [' + Array.from(f.statuses).join(', ') + ']'); +} diff --git a/app/_probe-register.mjs b/app/_probe-register.mjs new file mode 100644 index 0000000..8b0263b --- /dev/null +++ b/app/_probe-register.mjs @@ -0,0 +1,42 @@ +import { pipeline, env } from '@huggingface/transformers'; +import os from 'os'; import path from 'path'; +env.allowLocalModels=false; +env.cacheDir=path.join(os.homedir(),'Library/Application Support/Church Translate Models'); +const pipe=await pipeline('translation','Xenova/nllb-200-distilled-600M',{dtype:'fp32'}); +const say=async(t)=>{const o=await pipe(t,{src_lang:'eng_Latn',tgt_lang:'spa_Latn'});return (Array.isArray(o)?o[0]:o).translation_text.trim();}; + +/* Classify which form of address NLLB reached for. */ +function reg(s){ + const hits=[]; + if (/\bvosotros\b|\bvuestr[oa]s?\b|\bos\b(?!\s*[.,])/i.test(s)) hits.push('VOSOTROS'); + if (/\bustedes\b/i.test(s)) hits.push('ustedes'); + if (/\busted\b/i.test(s)) hits.push('usted'); + /* vosotros verb endings */ + if (/\w(áis|éis|ís)\b/.test(s)) hits.push('VOSOTROS-verb'); + /* tú imperatives / 2sg endings */ + if (/\b(mira|abre|vuelve|ven|escucha|piensa|deja|pon|ve|di|haz|sé)\b/i.test(s)) hits.push('tú'); + /* ustedes imperative/3pl */ + if (/\w(en|an)\b/.test(s) && /\b(abran|miren|vengan|escuchen|piensen|dejen|pongan|vayan|digan|hagan|estén|sean|levanten|inclinen)\b/i.test(s)) hits.push('ustedes-verb'); + return hits.length?hits.join('+'):'—'; +} + +const LINES=[ + 'Open your Bibles.','Open your Bibles to John chapter three.','Bow your heads with me.', + 'Stand to your feet.','Turn to the person next to you.','Listen to me carefully.', + 'I want you to understand this.','God has a plan for you.','You are the light of the world.', + 'You cannot serve two masters.','Do you believe that?','Are you following me?', + 'Lift your hands.','Close your eyes.','Repeat after me.','Come to the altar.', + 'Give thanks to the Lord.','Pray with me.','Look at verse four with me.', + 'Remember what He did for you.','You have been forgiven.','Do not be afraid.', + 'Love one another.','Forgive those who hurt you.','Trust in the Lord with all your heart.' +]; +console.log('English -> Spanish [register NLLB chose]'); +console.log('='.repeat(78)); +const tally={}; +for (const t of LINES) { + const s=await say(t); const r=reg(s); + for (const k of r.split('+')) tally[k]=(tally[k]||0)+1; + console.log(' '+t+'\n '+s+'\n ['+r+']'); +} +console.log('\nTALLY across '+LINES.length+' direct-address sentences:'); +for (const [k,v] of Object.entries(tally).sort((a,b)=>b[1]-a[1])) console.log(' '+String(v).padStart(3)+' '+k); diff --git a/app/_probe-rewrites.mjs b/app/_probe-rewrites.mjs new file mode 100644 index 0000000..8461e9b --- /dev/null +++ b/app/_probe-rewrites.mjs @@ -0,0 +1,33 @@ +import { pipeline, env } from '@huggingface/transformers'; +import os from 'os'; import path from 'path'; +env.allowLocalModels=false; +env.cacheDir=path.join(os.homedir(),'Library/Application Support/Church Translate Models'); +const pipe=await pipeline('translation','Xenova/nllb-200-distilled-600M',{dtype:'fp32'}); +const say=async(t,l)=>{const o=await pipe(t,{src_lang:'eng_Latn',tgt_lang:l});return (Array.isArray(o)?o[0]:o).translation_text.trim();}; + +/* Each candidate must EARN its place: the rewrite has to beat the original in + more than one language, or it is just a different guess. */ +const PAIRS=[ + ['Open up to the book of John.', 'Turn to the book of John.'], + ['Circle that in your Bible.', 'Mark that in your Bible.'], + ['Let that sink in.', 'Think carefully about that.'], + ['Walk it out.', 'Put it into practice.'], + ['That will preach.', 'That is worth preaching.'], + ['Let me unpack that for you.', 'Let me explain that to you.'], + ['We are going to camp out here for a minute.','We are going to stay here for a minute.'], + ['Give God a hand of praise.', 'Applaud to praise God.'], + ['Put a hand on your neighbor.', 'Put a hand on the person next to you.'], + ['Drop down to verse ten.', 'Go to verse ten.'], + ['Back up to verse one.', 'Go back to verse one.'], + ['Are you tracking with me.', 'Are you following me.'], + ['I want you to catch this.', 'I want you to understand this.'], + ['God showed up.', 'God was present.'] +]; +const LANGS=['spa_Latn','por_Latn','fra_Latn']; +for (const [before,after] of PAIRS) { + console.log('\n' + before + '\n -> ' + after); + for (const l of LANGS) { + const b=await say(before,l), a=await say(after,l); + console.log(' '+l.slice(0,3)+' was: '+b+'\n now: '+a+(a===b?' (no change)':'')); + } +} diff --git a/app/_probe-short.mjs b/app/_probe-short.mjs new file mode 100644 index 0000000..d002978 --- /dev/null +++ b/app/_probe-short.mjs @@ -0,0 +1,34 @@ +import { pipeline, env } from '@huggingface/transformers'; +import { createRequire } from 'module'; +import os from 'os'; import path from 'path'; import fs from 'fs'; import vm from 'vm'; +const require = createRequire(import.meta.url); +const Liturgy=require('./liturgy.js'), Guard=require('./mt-guard.js'); +const Preach=require('./preach.js'), B=require('./bible-lexicon.js'), Spa=require('./spa-variants.js'); +const esrc=fs.readFileSync('engine.js','utf8'); +const sb={console}; vm.createContext(sb); +vm.runInContext(esrc.slice(esrc.indexOf('const SCRIPT_NO_SPACES'), esrc.indexOf('function applyFixes')), sb); +env.allowLocalModels=false; +env.cacheDir=path.join(os.homedir(),'Library/Application Support/Church Translate Models'); +const pipe=await pipeline('translation','Xenova/nllb-200-distilled-600M',{dtype:'fp32'}); + +async function full(sentence, lang){ + const set=Liturgy.lookup(sentence,lang); if(set) return [set,'liturgy']; + const named=B.wholeUtterance(sentence,lang); if(named) return [named,'name']; + const forMt=Preach.forTranslation(sentence); + const sd=Preach.stripShortPeriod(forMt); + const o=await pipe(sd.text,{src_lang:'eng_Latn',tgt_lang:lang}); + let raw=(Array.isArray(o)?o[0]:o).translation_text.trim(); + raw=Guard.clean(sd.text,raw).text; + raw=Preach.restorePeriod(raw,sd.hadPeriod); + if(lang==='spa_Latn') raw=Spa.toLatinAmerican(Spa.toChurchRegister(raw)); + const bl=B.forLanguage(lang); + if(bl) raw=sb.applyTable(raw,Object.keys(bl).map(f=>({from:f,to:bl[f]}))); + return [raw,'model']; +} + +const CASES=['Hello.','What?','Amen.','Hallelujah.','Peter.','Okay.','Check.','Faith.','Hello?', + 'No.','Jesus.','Yeah.','All right.','Right?','Holy Spirit.','Death.','Yeah?', + 'Praise God.','Grace.','Hope.','Love.','Sin.','Heaven.','Prayer.','Worship.', + 'Testing.','One two.','Mic check.','Good.','Wow.','Listen.','Look.','Come.','Stand.']; +console.log(' English'.padEnd(18)+'now'.padEnd(30)+'via'); +for (const t of CASES){ const [r,how]=await full(t,'spa_Latn'); console.log(' '+t.padEnd(16)+String(r).slice(0,28).padEnd(30)+how); } diff --git a/app/_proto-columns.html b/app/_proto-columns.html new file mode 100644 index 0000000..3629a3a --- /dev/null +++ b/app/_proto-columns.html @@ -0,0 +1,38 @@ + + + + +
+

Languages on the front page

+

+
+ + + +
+
+
On 0
+
+
Off 0
+
+
+ +

+
+ + + + + + diff --git a/app/_static.js b/app/_static.js new file mode 100644 index 0000000..04d9181 --- /dev/null +++ b/app/_static.js @@ -0,0 +1,18 @@ +/* Throwaway static server so the operator-page preview can be looked at in a + browser. Absolute root, no reliance on cwd — the launcher's working + directory is not readable in this sandbox. */ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const ROOT = '/Users/Studio/Documents/church-translate/app'; +const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json' }; +http.createServer((req, res) => { + const rel = decodeURIComponent(req.url.split('?')[0]).replace(/^\/+/, '') || '_preview-langlist.html'; + const file = path.join(ROOT, rel); + if (!file.startsWith(ROOT)) { res.writeHead(403).end('no'); return; } + fs.readFile(file, (err, buf) => { + if (err) { res.writeHead(404).end('not found: ' + rel); return; } + res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream' }); + res.end(buf); + }); +}).listen(4873, '127.0.0.1', () => console.log('preview on http://127.0.0.1:4873')); diff --git a/app/_sweep-batch.mjs b/app/_sweep-batch.mjs new file mode 100644 index 0000000..0b8b0d5 --- /dev/null +++ b/app/_sweep-batch.mjs @@ -0,0 +1,46 @@ +/* Does the batched path have a cliff at N=4..6? Sweep every N through the REAL + service, interleaved A/B so machine load hits both arms equally. */ +import { fork } from 'child_process'; +import os from 'os'; import path from 'path'; +import { fileURLToPath } from 'url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MODELS_DIR = path.join(os.homedir(), 'Library/Application Support/Church Translate Models'); +const LANGS = ['spa_Latn','por_Latn','fra_Latn','zho_Hans','hat_Latn','hin_Deva','kor_Hang','vie_Latn','arb_Arab']; +const TEXT = 'And I want you to understand that God has a plan for your life'; + +const child = fork(path.join(__dirname, 'mt-service.js'), [], { stdio: ['ignore','pipe','pipe','ipc'] }); +child.stderr.on('data', () => {}); +let seq = 0; const pending = new Map(); +child.on('message', (m) => { + if (m.type === 'ready') { pending.get('ready')(); return; } + if (m.type === 'progress' || m.type === 'note') return; + const w = pending.get(m.id); if (!w) return; pending.delete(m.id); + if (m.type === 'error') w.reject(new Error(m.message)); + else w.resolve(m.type === 'multi' ? m.texts : m.text); +}); +const send = (p) => new Promise((resolve, reject) => { const id = ++seq; pending.set(id, {resolve,reject}); child.send({...p, id}); }); +const ready = new Promise(r => pending.set('ready', r)); +child.send({ type:'load', model:'Xenova/nllb-200-distilled-600M', dtype:'fp32', cacheDir: MODELS_DIR }); +await ready; + +const one = (t) => send({ type:'translate', text:TEXT, src:'eng_Latn', tgt:t }); +const many = (ts) => send({ type:'translateMulti', text:TEXT, src:'eng_Latn', tgts:ts }); +await one('spa_Latn'); await many(['spa_Latn','fra_Latn']); + +const REPS = 3; +console.log(' N | serial(med) | batched(med) | speedup | identical'); +console.log('---+-------------+--------------+---------+----------'); +for (let n = 1; n <= 9; n++) { + const langs = LANGS.slice(0, n); + const S = [], B = []; let same = 0, tot = 0; + for (let r = 0; r < REPS; r++) { + let t = Date.now(); const so = {}; for (const l of langs) so[l] = await one(l); S.push(Date.now()-t); + t = Date.now(); const bo = await many(langs); B.push(Date.now()-t); + for (const l of langs) { tot++; if (so[l] === bo[l]) same++; } + } + const med = (a) => a.sort((x,y)=>x-y)[Math.floor(a.length/2)]; + const s = med(S), b = med(B); + console.log(String(n).padStart(2) + ' | ' + (s+'ms').padStart(11) + ' | ' + (b+'ms').padStart(12) + + ' | ' + ((s/b).toFixed(2)+'x').padStart(7) + ' | ' + same + '/' + tot); +} +child.kill(); diff --git a/app/_test-conjugation.js b/app/_test-conjugation.js new file mode 100644 index 0000000..dbcd8e5 --- /dev/null +++ b/app/_test-conjugation.js @@ -0,0 +1,40 @@ +/* Unit-test the Spanish conjugation repairs against MEASURED NLLB output — no model needed. + Every 'from' is a non-word (safe by construction); every real form must pass untouched. */ +const S = require('./spa-variants.js'); +let pass = 0, fail = 0; +const t = (label, input, want) => { + const got = S.toChurchRegister(input); + const ok = got === want; + console.log((ok ? ' PASS ' : ' FAIL ') + label + (ok ? '' : '\n got "' + got + '"\n want "' + want + '"')); + ok ? pass++ : fail++; +}; + +console.log('\nMEASURED NON-WORDS — must be repaired'); +t('bendecemos (transcript 2026-09-13)', 'Te bendecemos, Señor.', 'Te bendecimos, Señor.'); +t('bendicemos (transcript)', 'Bendicemos el nombre de Jesús.', 'Bendecimos el nombre de Jesús.'); +t('bendimos (transcript)', 'Bendimos su nombre.', 'Bendecimos su nombre.'); +t('bendicimos', 'Hoy bendicimos al Señor.', 'Hoy bendecimos al Señor.'); +t('bendico', 'Yo te bendico.', 'Yo te bendigo.'); +t('bendecen', 'Ellos bendecen a Dios.', 'Ellos bendicen a Dios.'); +t('bendiciré', 'Bendiciré al Señor.', 'Bendeciré al Señor.'); +t('benditaremos / bendiciremos', 'Bendiciremos tu nombre. Benditaremos tu nombre.', 'Bendeciremos tu nombre. Bendeciremos tu nombre.'); +t('bendíelo -> 1pl hortative, NOT the tú imperative', '¡Bendíelo!', '¡Bendigámoslo!'); +t('bendíalo', 'Bendíalo, pueblo.', 'Bendigámoslo, pueblo.'); +t('maldiciré', 'No maldiciré.', 'No maldeciré.'); +t('pedamos', 'Pedamos en su nombre.', 'Pidamos en su nombre.'); +t('ungas / undémonos / redémonos', 'Que ungas a tu siervo. Undémonos, Señor. Redémonos, Señor.', 'Que unjas a tu siervo. Úngenos, Señor. Redímenos, Señor.'); +t('dás', 'Tú nos dás vida.', 'Tú nos das vida.'); +t('capital preserved at sentence start', 'Bendecemos tu nombre.', 'Bendecimos tu nombre.'); + +console.log('\nREAL FORMS — must not be touched'); +for (const real of ['Bendecimos su nombre.', 'Dios te bendiga.', 'Bendice, alma mía, al Señor.', 'Bendito sea el nombre del Señor.', + 'Bendiciones para todos.', 'La bendición de Dios.', 'Él nos bendijo.', 'Bendecirá a su pueblo.', 'Bendiciendo al Señor.', + 'Yo bendigo al Señor.', 'Ustedes bendicen a Dios.', 'Pidamos con fe.', 'Nos das vida.', 'Úngenos con tu Espíritu.']) { + t('untouched: ' + real, real, real); +} + +console.log('\nEXISTING REGISTER PASS still runs'); +t('rezar -> orar', 'Vamos a rezar juntos.', 'Vamos a orar juntos.'); + +console.log('\n' + pass + ' passed, ' + fail + ' failed'); +process.exit(fail ? 1 : 0); diff --git a/app/_test-delay.mjs b/app/_test-delay.mjs new file mode 100644 index 0000000..6456b7f --- /dev/null +++ b/app/_test-delay.mjs @@ -0,0 +1,123 @@ +/* + * Does the stream delay hold back the INTERNET without holding back the ROOM? + * + * Starts the real server on a spare port with a throwaway DATA_DIR, connects + * two listeners — one plain (the room), one carrying x-forwarded-for (through + * the tunnel) — plus a fake engine, and times when each one is told things. + */ +import { spawn } from 'child_process'; +import { WebSocket } from 'ws'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { fileURLToPath } from 'url'; + +const DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ct-delay-')); +const PORT = 3199; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const srv = spawn(process.execPath, [path.join(__dirname, 'server', 'server.js')], + { env: { ...process.env, PORT: String(PORT), DATA_DIR: DIR }, stdio: ['ignore', 'pipe', 'pipe'] }); +const logs = []; +srv.stdout.on('data', (d) => logs.push(String(d))); +srv.stderr.on('data', (d) => logs.push('ERR ' + d)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const B = 'http://127.0.0.1:' + PORT; +/* Wait for OUR server, identified by the config it writes into OUR dir — a + stray copy left on this port by an earlier run would answer /healthz and + then be tested instead. */ +const CFG = path.join(DIR, 'config.json'); +for (let i = 0; i < 80; i++) { + if (fs.existsSync(CFG)) { try { await fetch(B + '/healthz'); break; } catch (_) {} } + await sleep(150); +} +if (!fs.existsSync(CFG)) { + console.error('server never started. log:\n' + logs.join('')); + srv.kill(); process.exit(1); +} +const cfg = JSON.parse(fs.readFileSync(CFG, 'utf8')); +const KEY = cfg.controlToken, PIN = cfg.operatorPin, EKEY = cfg.engineKey; +console.log('server up on ' + PORT + '; new PIN is ' + PIN.length + ' digits'); + +const DELAY = 4; +await fetch(B + '/api/control/delay-set?s=' + DELAY + '&key=' + KEY); +console.log('stream delay set to ' + DELAY + 's\n'); + +function listener(label, headers) { + const ws = new WebSocket(B.replace('http', 'ws') + '/ws?role=listener&lang=eng_Latn', { headers }); + const got = []; + ws.on('message', (raw) => { + const m = JSON.parse(raw); + got.push({ t: m.t, at: Date.now(), text: m.text, delaySec: m.delaySec }); + }); + return { label, ws, got, ready: new Promise((r) => ws.on('open', r)) }; +} +const room = listener('room ', {}); +const remote = listener('remote', { 'x-forwarded-for': '203.0.113.7' }); +await Promise.all([room.ready, remote.ready]); + +const eng = new WebSocket(B.replace('http', 'ws') + '/ws?role=engine&key=' + encodeURIComponent(EKEY)); +await new Promise((r) => eng.on('open', r)); +await fetch(B + '/api/control/start?key=' + KEY); +await sleep(300); + +const hello = (l) => l.got.find((g) => g.t === 'hello'); +console.log('hello.delaySec room=' + hello(room).delaySec + ' remote=' + hello(remote).delaySec); + +const t0 = Date.now(); +eng.send(JSON.stringify({ t: 'final', text: 'God has a plan for your life', cid: 'c1', tr: {} })); +await sleep(DELAY * 1000 + 1200); + +let pass = 0, fail = 0; +const check = (label, ok, detail) => { + console.log((ok ? ' ok ' : ' FAIL ') + label + (detail ? ' ' + detail : '')); + ok ? pass++ : fail++; +}; +console.log('\ncaption arrival, measured from the moment the engine sent it:'); +for (const l of [room, remote]) { + const c = l.got.find((g) => g.t === 'caption'); + const dt = c ? (c.at - t0) / 1000 : null; + console.log(' ' + l.label + ' ' + (c ? dt.toFixed(2) + 's "' + c.text + '"' : 'NEVER ARRIVED')); +} +const rc = room.got.find((g) => g.t === 'caption'), xc = remote.got.find((g) => g.t === 'caption'); +check('the room gets it immediately', rc && (rc.at - t0) < 700, rc ? ((rc.at-t0)/1000).toFixed(2) + 's' : 'missing'); +check('the internet waits the delay', xc && (xc.at - t0) >= DELAY * 1000 - 300, xc ? ((xc.at-t0)/1000).toFixed(2) + 's' : 'missing'); +check('both got the same words', rc && xc && rc.text === xc.text); +check('hello tells the room 0', hello(room).delaySec === 0); +check('hello tells the internet ' + DELAY, hello(remote).delaySec === DELAY); + +console.log('\nlate joiner backlog (scrollback in the hello frame):'); +function joiner(headers) { + const ws = new WebSocket(B.replace('http', 'ws') + '/ws?role=listener&lang=eng_Latn', { headers }); + return new Promise((res) => { + ws.on('message', (raw) => { const m = JSON.parse(raw); if (m.t === 'hello') { ws.close(); res(m); } }); + }); +} +const n = (h) => (h.scrollback || []).length; +const jr = await joiner({}); +const jx = await joiner({ 'x-forwarded-for': '203.0.113.8' }); +console.log(' a sentence spoken ' + DELAY + '+s ago:'); +console.log(' room backlog ' + n(jr) + ' · remote backlog ' + n(jx)); +check('the room sees it', n(jr) >= 1); +check('the internet sees it too — it is past their clock', n(jx) >= 1); + +/* Now the case that matters: something said JUST NOW must not reach a remote + joiner's backlog, or the delay is defeated by refreshing the page. */ +const before = n(jx); +eng.send(JSON.stringify({ t: 'final', text: 'a secret just spoken', cid: 'c2', tr: {} })); +await sleep(400); +const jr2 = await joiner({}); +const jx2 = await joiner({ 'x-forwarded-for': '203.0.113.9' }); +const txt = (h) => (h.scrollback || []).map((r) => r.text).join(' | '); +console.log(' a sentence spoken 0.4s ago:'); +console.log(' room backlog: ' + txt(jr2)); +console.log(' remote backlog: ' + txt(jx2)); +check('the room sees the new sentence', txt(jr2).includes('a secret just spoken')); +check('the internet does NOT — refreshing cannot skip the delay', + !txt(jx2).includes('a secret just spoken')); +check('server sized the audio window for the delay', logs.join('').includes('audio window now')); + +console.log('\n--- server log ---'); +console.log(logs.join('').split('\n').filter((l) => /delay|window|START/.test(l)).map((l) => ' ' + l).join('\n')); +console.log('\n' + pass + ' passed, ' + fail + ' failed'); +srv.kill(); fs.rmSync(DIR, { recursive: true, force: true }); +process.exit(fail ? 1 : 0); diff --git a/app/_test-e2e.mjs b/app/_test-e2e.mjs new file mode 100644 index 0000000..8a300f9 --- /dev/null +++ b/app/_test-e2e.mjs @@ -0,0 +1,37 @@ +/* The whole chain as engine.js runs it: split -> liturgy-or-NLLB -> guard. */ +import { pipeline, env } from '@huggingface/transformers'; +import { createRequire } from 'module'; +import os from 'os'; import path from 'path'; import fs from 'fs'; import vm from 'vm'; +const require = createRequire(import.meta.url); +const Liturgy = require('./liturgy.js'); +const Guard = require('./mt-guard.js'); + +const src = fs.readFileSync('engine.js','utf8'); +const sb={console}; vm.createContext(sb); +vm.runInContext(src.match(/^const ABBREV_TAIL = .*$/m)[0], sb); +const grab=(n)=>{const i=src.indexOf('function '+n+'(');let d=0;for(let k=src.indexOf('{',i);k '+JSON.stringify(sb.splitSentences(text))); + for (const lang of LANGS) { + const parts=[]; + for (const s of sb.splitSentences(text)) { + const set=Liturgy.lookup(s,lang); + if (set) { parts.push(set+' [liturgy]'); continue; } + const o=await pipe(s,{src_lang:'eng_Latn',tgt_lang:lang}); + const raw=(Array.isArray(o)?o[0]:o).translation_text.trim(); + const g=Guard.clean(s,raw); + parts.push(g.text + (g.trimmed?' [guard: '+g.reason.slice(0,40)+']':'')); + } + console.log(' '+lang+' '+parts.join(' | ')); + } +} diff --git a/app/_test-e2e2.mjs b/app/_test-e2e2.mjs new file mode 100644 index 0000000..3df7d80 --- /dev/null +++ b/app/_test-e2e2.mjs @@ -0,0 +1,39 @@ +/* The full chain, and specifically: does the ENGLISH the listener sees stay + exactly as spoken while the translation improves? */ +import { pipeline, env } from '@huggingface/transformers'; +import { createRequire } from 'module'; +import os from 'os'; import path from 'path'; import fs from 'fs'; import vm from 'vm'; +const require = createRequire(import.meta.url); +const Liturgy=require('./liturgy.js'), Guard=require('./mt-guard.js'), Preach=require('./preach.js'); +const src=fs.readFileSync('engine.js','utf8'); +const sb={console}; vm.createContext(sb); +vm.runInContext(src.match(/^const ABBREV_TAIL = .*$/m)[0], sb); +const grab=(n)=>{const i=src.indexOf('function '+n+'(');let d=0;for(let k=src.indexOf('{',i);k { + const r = G.clean(SRC[i], tgt); + if (r.text === tgt) return; + touched++; + const strippedDash = tgt.replace(/^\s*[-‐-―−]\s*/, '').trim(); + if (r.text === strippedDash) { dashOnly++; console.log(' dash ' + lang + ' "' + tgt + '" -> "' + r.text + '"'); return; } + wordLoss++; + const allowed = MAY_LOSE_WORDS.has(lang + '|' + i); + console.log((allowed ? ' TRIM ' : ' !!BAD ') + lang + ' "' + tgt + '" -> "' + r.text + '"'); + if (r.reason) console.log(' ' + r.reason); + if (!allowed) bad++; + }); +} +console.log('\n90 measured outputs: ' + touched + ' touched, ' + dashOnly + ' dash-only, ' + + wordLoss + ' word-loss, ' + bad + ' UNEXPECTED'); +process.exit(bad ? 1 : 0); diff --git a/app/_test-guard.js b/app/_test-guard.js new file mode 100644 index 0000000..32b65c4 --- /dev/null +++ b/app/_test-guard.js @@ -0,0 +1,52 @@ +/* Unit-test the guard against MEASURED NLLB output — no model needed. */ +const G = require('./mt-guard.js'); +let pass = 0, fail = 0; +const t = (label, src, tgt, wantText, wantTrim) => { + const r = G.clean(src, tgt); + const ok = r.text === wantText && (wantTrim === undefined || r.trimmed === wantTrim); + console.log((ok ? ' PASS ' : ' FAIL ') + label); + if (!ok) console.log(' got "' + r.text + '" trimmed=' + r.trimmed + + '\n want "' + wantText + '"'); + if (r.reason && ok) console.log(' ' + r.reason); + ok ? pass++ : fail++; +}; + +console.log('\nMEASURED HALLUCINATIONS — must be trimmed'); +t('invented question', 'Donald Trump.', 'Donald Trump. ¿Qué es eso?', 'Donald Trump.', true); +t('subtitle dash', 'Hello, hello.', '- Hola, está bien.', 'Hola, está bien.', true); +/* One English word cannot mean two clauses, so everything after the comma is + invented. (In production liturgy.js answers "Amen." before the guard runs; + this fixture exercises the rule itself.) */ +t('one-word clause trim', 'Amen.', '- Sí, estoy bien. ¿Y tú?', 'Sí.', true); +t('two words keeps its comma', 'God is good.', 'Dios es bueno, muy bueno.', 'Dios es bueno, muy bueno.', false); +t('CJK invented', 'Donald Trump.', '唐纳德·特朗普。那是什么?', '唐纳德·特朗普。', true); +t('Arabic invented', 'Amen.', 'آمين. ما هذا؟', 'آمين.', true); + +console.log('\nMUST NOT BE TOUCHED — real output'); +t('real question kept', 'How many countries are in the world?', '¿Cuántos países hay en el mundo?', + '¿Cuántos países hay en el mundo?', false); +t('unpunctuated tag question', 'Are you with me', '¿Estás conmigo?', '¿Estás conmigo?', false); +t('THE risk case', 'Can I get an amen', '¿Puedo recibir un amén?', '¿Puedo recibir un amén?', false); +t('semicolon scripture', 'The Lord is my shepherd; I shall not want.', + 'El Señor es mi pastor; nada me faltará.', 'El Señor es mi pastor; nada me faltará.', false); +t('two in two out', 'Jesus wept. He loved him.', 'Jesús lloró. Lo amaba.', 'Jesús lloró. Lo amaba.', false); +t('abbreviation', 'Dr. Luke wrote it.', 'El Dr. Lucas lo escribió.', 'El Dr. Lucas lo escribió.', false); +t('decimal', 'Read John 3.16 with me.', 'Lee Juan 3.16 conmigo.', 'Lee Juan 3.16 conmigo.', false); + +console.log('\nLONG SOURCE — clamp must NOT apply (legitimate splitting)'); +t('long splits legitimately', + 'For God so loved the world that he gave his only begotten Son, that whoever believes in him should not perish but have everlasting life.', + 'Porque tanto amó Dios al mundo que dio a su Hijo unigénito. Para que todo el que crea en él no se pierda. Sino que tenga vida eterna.', + 'Porque tanto amó Dios al mundo que dio a su Hijo unigénito. Para que todo el que crea en él no se pierda. Sino que tenga vida eterna.', false); +t('long keeps dash strip', + 'And God said let there be light and there was light in the whole world today.', + '- Y dijo Dios: que haya luz.', 'Y dijo Dios: que haya luz.', true); + +console.log('\nEDGE CASES'); +t('empty target', 'Amen.', '', '', false); +t('target is only a dash', 'Amen.', '-', '-', false); +t('no source punctuation', 'Turn with me', 'Ven conmigo. ¿Qué es eso?', 'Ven conmigo.', true); +t('CJK no spaces in target', 'Praise the Lord.', '赞美主。', '赞美主。', false); + +console.log('\n' + pass + ' passed, ' + fail + ' failed'); +process.exit(fail ? 1 : 0); diff --git a/app/_test-lex-all.js b/app/_test-lex-all.js new file mode 100644 index 0000000..fbacdef --- /dev/null +++ b/app/_test-lex-all.js @@ -0,0 +1,22 @@ +const fs=require('fs'), vm=require('vm'); +const B=require('./bible-lexicon.js'); +const src=fs.readFileSync('engine.js','utf8'); +const sb={console};vm.createContext(sb); +vm.runInContext(src.slice(src.indexOf('const SCRIPT_NO_SPACES'), src.indexOf('function applyFixes')), sb); +const rows=require('/tmp/all-corpus.json'); +for (const lang of B.languages()) { + const bl=B.forLanguage(lang)||{}, fx=B.fixesFor(lang)||{}; + const p1=Object.keys(bl).map(f=>({from:f,to:bl[f]})); + const p2=Object.keys(fx).map(f=>({from:f,to:fx[f]})); + let n=0,tot=0; const seen={}; + for (const r of rows){ + const t=String((r.tr&&r.tr[lang])||''); if(!t) continue; tot++; + let out=sb.applyTable(t,p1); if(p2.length) out=sb.applyTable(out,p2); + if(out===t) continue; n++; + const a=t.split(/\s+/),b=out.split(/\s+/),d=[]; + for(let i=0;ib[1]-a[1]).slice(0,10).forEach(([d,c])=>console.log(' '+String(c).padStart(3)+'x '+d)); +} diff --git a/app/_test-lex-fra.js b/app/_test-lex-fra.js new file mode 100644 index 0000000..a773050 --- /dev/null +++ b/app/_test-lex-fra.js @@ -0,0 +1,25 @@ +const fs=require('fs'), vm=require('vm'); +const B=require('./bible-lexicon.js'); +const src=fs.readFileSync('engine.js','utf8'); +const sb={console};vm.createContext(sb); +vm.runInContext(src.slice(src.indexOf('const SCRIPT_NO_SPACES'), src.indexOf('function applyFixes')), sb); +const rows=require('/tmp/all-corpus.json'); + +for (const lang of ['fra_Latn','spa_Latn']) { + const bl=B.forLanguage(lang), fx=B.fixesFor(lang)||{}; + const pairs=Object.keys(bl).map(from=>({from,to:bl[from]})); + const fpairs=Object.keys(fx).map(from=>({from,to:fx[from]})); + let n=0,tot=0; const seen={}; + for (const r of rows){ + const t=String((r.tr&&r.tr[lang])||''); if(!t) continue; tot++; + let out=sb.applyTable(t,pairs); + if(fpairs.length) out=sb.applyTable(out,fpairs); + if(out===t) continue; n++; + const a=t.split(/\s+/),b=out.split(/\s+/),d=[]; + for(let i=0;i '+(b[i]||'')); + const k=d.join(' | '); seen[k]=(seen[k]||0)+1; + } + console.log('\n=== '+lang+' — '+tot+' real rows, '+n+' changed ('+(n/tot*100).toFixed(2)+'%) ==='); + Object.entries(seen).sort((a,b)=>b[1]-a[1]).slice(0,28).forEach(([d,c])=>console.log(' '+String(c).padStart(3)+'x '+d)); + if (Object.keys(seen).length>28) console.log(' … '+(Object.keys(seen).length-28)+' more distinct changes'); +} diff --git a/app/_test-lexicon.js b/app/_test-lexicon.js new file mode 100644 index 0000000..65a080f --- /dev/null +++ b/app/_test-lexicon.js @@ -0,0 +1,22 @@ +/* Run the lexicon over 5,954 rows of real Spanish output and inspect EVERY + change. A lexicon that corrupts correct Spanish is worse than no lexicon. */ +const fs=require('fs'), vm=require('vm'); +const B=require('./bible-lexicon.js'); +const src=fs.readFileSync('engine.js','utf8'); +const sb={console};vm.createContext(sb); +vm.runInContext(src.slice(src.indexOf('const SCRIPT_NO_SPACES'), src.indexOf('function applyFixes')), sb); +const rows=require('/tmp/es-corpus.json'); +const bl=B.forLanguage('spa_Latn'); +const pairs=Object.keys(bl).map(from=>({from,to:bl[from]})); +let n=0; const seen={}; +for (const r of rows){ + const es=String((r.tr&&r.tr.spa_Latn)||''); if(!es) continue; + const out=sb.applyTable(es,pairs); + if(out===es) continue; n++; + const a=es.split(/\s+/),b=out.split(/\s+/),d=[]; + for(let i=0;i '+(b[i]||'')); + const k=d.join(' | '); seen[k]=(seen[k]||0)+1; +} +console.log('rows changed by the lexicon: '+n+' ('+(n/rows.length*100).toFixed(2)+'%)\n'); +console.log('EVERY change:'); +Object.entries(seen).sort((a,b)=>b[1]-a[1]).forEach(([d,c])=>console.log(' '+String(c).padStart(3)+'x '+d)); diff --git a/app/_test-matcher.js b/app/_test-matcher.js new file mode 100644 index 0000000..9d7b5dc --- /dev/null +++ b/app/_test-matcher.js @@ -0,0 +1,40 @@ +const fs=require('fs'),vm=require('vm'); +const src=fs.readFileSync('engine.js','utf8'); +const sb={console};vm.createContext(sb); +vm.runInContext(src.slice(src.indexOf('const SCRIPT_NO_SPACES'), src.indexOf('function applyFixes')), sb); +let pass=0,fail=0; +const t=(label,text,pairs,want)=>{const got=sb.applyTable(text,pairs);const ok=got===want; + console.log((ok?' PASS ':' FAIL ')+label); if(!ok)console.log(' got "'+got+'"\n want "'+want+'"'); + ok?pass++:fail++;}; + +console.log('\nEXISTING BEHAVIOUR — must not regress'); +t('lowercase forgiving','have a back up plan',[{from:'have a back up',to:'Habakkuk'}],'Habakkuk plan'); +t('capital is exact','I finally got the job I wanted',[{from:'Job',to:'JOB-BOOK'}],'I finally got the job I wanted'); +t('capital matches capital','Turn to Job chapter 1',[{from:'Job',to:'JOB-BOOK'}],'Turn to JOB-BOOK chapter 1'); +t('term ending in period','read 1 Cor. today',[{from:'1 Cor.',to:'First Corinthians'}],'read First Corinthians today'); +t('parenthesised term','sing (Selah) now',[{from:'(Selah)',to:'SELAH'}],'sing SELAH now'); +t('longest first','first Corinthians here',[{from:'first Corinthians',to:'1 Corinthians'},{from:'Corinthians',to:'X'}],'1 Corinthians here'); +t('live CJK override','向哈巴古书第一章',[{from:'哈巴古书',to:'哈巴谷书'}],'向哈巴谷书第一章'); +t('CJK 2-char name FIRES','约翰说了',[{from:'约翰',to:'約翰'}],'約翰说了'); +t('CJK 2-char name in compound','马太福音第一章',[{from:'马太',to:'馬太'}],'馬太福音第一章'); +/* ACCEPTED COST of lowering the guard to one character: a two-character COMMON + noun now reaches inside a longer word again. Pinned here so it is a decision + on the record, not a surprise. */ +t('accepted: 2-char common noun bleeds','구원자가 왔다',[{from:'구원',to:'救い'}],'救い자가 왔다'); +/* ...and the remedy, which the table already supports: longest match wins. */ +t('remedy: longer entry wins','구원자가 왔다', + [{from:'구원',to:'救い'},{from:'구원자',to:'救い主'}],'救い主가 왔다'); + +console.log('\nNEWLY FIXED — non-Latin boundaries'); +t('Cyrillic no inside-match','Маркса продали',[{from:'Марк',to:'X'}],'Маркса продали'); +t('Cyrillic real match','Марк написал',[{from:'Марк',to:'Маркос'}],'Маркос написал'); +t('Arabic no inside-match','الربا حرام',[{from:'الرب',to:'X'}],'الربا حرام'); +t('Arabic real match','الرب راعي',[{from:'الرب',to:'يهوه'}],'يهوه راعي'); +t('accented capital exact','el éxodo de los jóvenes',[{from:'Éxodo',to:'X'}],'el éxodo de los jóvenes'); +t('accented capital matches','leemos Éxodo 20',[{from:'Éxodo',to:'EXO'}],'leemos EXO 20'); +t('accented lowercase forgiving','el éxodo grande',[{from:'éxodo',to:'salida'}],'el salida grande'); +t('Devanagari boundary','यूहन्नाकी बात',[{from:'यूहन्ना',to:'X'}],'यूहन्नाकी बात'); +t('Vietnamese tone marks','Phêrô đã nói',[{from:'Phêrô',to:'Phi-e-rơ'}],'Phi-e-rơ đã nói'); + +console.log('\n'+pass+' passed, '+fail+' failed'); +process.exit(fail?1:0); diff --git a/app/_test-micfeed.mjs b/app/_test-micfeed.mjs new file mode 100644 index 0000000..0b8a1df --- /dev/null +++ b/app/_test-micfeed.mjs @@ -0,0 +1,80 @@ +/* Drive the REAL live-audio module the way the mic feed does, and see whether + the stream it produces is continuous. */ +import { execFileSync } from 'child_process'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +const { LiveAudio } = require('./server/live-audio.js'); + +const RATE = 16000, CHUNK = 11200; // MIC_CHUNK +const d = fs.mkdtempSync(path.join(os.tmpdir(), 'micfeed-')); + +function encode(samples, hz) { + const pcm = Buffer.alloc(samples * 2); + for (let i = 0; i < samples; i++) pcm.writeInt16LE(Math.round(9000 * Math.sin(i / hz)), i * 2); + const wav = Buffer.alloc(44 + pcm.length); + wav.write('RIFF', 0); wav.writeUInt32LE(36 + pcm.length, 4); + wav.write('WAVE', 8); wav.write('fmt ', 12); + wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); + wav.writeUInt32LE(RATE, 24); wav.writeUInt32LE(RATE * 2, 28); + wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); + wav.write('data', 36); wav.writeUInt32LE(pcm.length, 40); + pcm.copy(wav, 44); + const w = path.join(d, 'c.wav'), a = path.join(d, 'c.aac'); + fs.writeFileSync(w, wav); + if (fs.existsSync(a)) fs.unlinkSync(a); + execFileSync('/usr/bin/afconvert', ['-f', 'adts', '-d', 'aac@' + RATE, '-b', '48000', w, a]); + return fs.readFileSync(a); +} + +const live = new LiveAudio((m) => console.log(' [live] ' + m)); +const st = live.ensure('eng_Latn', RATE); +st.start(); +/* Count silence frames the pump invents because the queue ran dry. */ +const SIL = require('./server/live-audio.js').silenceFrame(RATE); +let underrun = 0, real = 0, counting = false; +const origClose = st.closeSegment.bind(st); +st.closeSegment = function () { + if (counting) for (const f of this.building) { if (f === SIL) underrun++; else real++; } + return origClose(); +}; +console.log('pushing 700ms chunks every 700ms for 14s, exactly as the mic feed does…\n'); + +const clip = encode(CHUNK, 9); +let pushed = 0; +const t0 = Date.now(); +let stalls = 0; +function schedule(delay) { setTimeout(tickPush, delay); } +function tickPush() { + st.pushContinuous(clip); + pushed++; + if (pushed === 2) counting = true; // skip the startup seed + const backlogMs = (st.pendingFrames * 1024) / RATE * 1000; + const wall = Date.now() - t0; + const audioIn = pushed * 700; + console.log(' chunk ' + String(pushed).padStart(2) + + ' wall ' + String(wall).padStart(5) + 'ms' + + ' audio pushed ' + String(audioIn).padStart(5) + 'ms' + + ' backlog ' + backlogMs.toFixed(0).padStart(4) + 'ms' + + ' segments ' + st.segments.length); + /* Two deliberate stalls: 500ms and 900ms late, modelling a blocked event + loop or two chunks bunched by the socket. */ + let nextIn = 700; + if (pushed === 8) { nextIn = 1200; stalls++; } + if (pushed === 14) { nextIn = 1600; stalls++; } + if (pushed < 20) { schedule(nextIn); return; } + if (pushed >= 20) { + const held = st.segments.length * (st.framesPerSegment * 1024 / RATE); + console.log('\n produced ' + st.segments.length + ' segments (' + held.toFixed(1) + 's held)'); + console.log(' real audio frames : ' + real); + console.log(' SILENCE frames the pump invented on underrun : ' + underrun + + ' (' + (underrun * 64) + ' ms of gaps)'); + console.log(' audio in: ' + (pushed * 0.7).toFixed(1) + 's wall: ' + ((Date.now()-t0)/1000).toFixed(1) + 's'); + console.log(' deliberate stalls injected: ' + stalls + ' (one 500ms late, one 900ms late)'); + console.log(' UNDERRUNS reported by the stream: ' + (st.underruns || 0)); + fs.rmSync(d, { recursive: true, force: true }); + process.exit(0); + } +} +schedule(700); + diff --git a/app/_test-multi.mjs b/app/_test-multi.mjs new file mode 100644 index 0000000..3c1cdbe --- /dev/null +++ b/app/_test-multi.mjs @@ -0,0 +1,68 @@ +/* Integration test: fork the REAL mt-service.js the way main.js does and prove + the batched path returns the same text as the one-at-a-time path. */ +import { fork } from 'child_process'; +import os from 'os'; import path from 'path'; +import { fileURLToPath } from 'url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MODELS_DIR = path.join(os.homedir(), 'Library/Application Support/Church Translate Models'); + +const LANGS = ['spa_Latn','por_Latn','fra_Latn','zho_Hans','hat_Latn','hin_Deva','kor_Hang','vie_Latn','arb_Arab']; +const TEXTS = [ + 'And I want you to understand that God has a plan for your life', + 'Turn with me to the book of Romans chapter eight', + 'He was faithful even when it cost him everything, and that is the kind of faith we are called to', + 'Amen', +]; + +const child = fork(path.join(__dirname, 'mt-service.js'), [], { stdio: ['ignore','pipe','pipe','ipc'] }); +child.stderr.on('data', d => process.stderr.write('[mt] ' + d)); +let seq = 0; const pending = new Map(); +child.on('message', (m) => { + if (m.type === 'ready') { pending.get('ready')?.(); return; } + if (m.type === 'note') { console.log(' note: ' + m.message); return; } + if (m.type === 'progress') return; + const w = pending.get(m.id); if (!w) return; pending.delete(m.id); + if (m.type === 'error') w.reject(new Error(m.message)); + else w.resolve(m.type === 'multi' ? m.texts : m.text); +}); +const send = (payload) => new Promise((resolve, reject) => { + const id = ++seq; pending.set(id, { resolve, reject }); + child.send({ ...payload, id }); +}); + +const ready = new Promise(r => pending.set('ready', r)); +child.send({ type: 'load', model: 'Xenova/nllb-200-distilled-600M', dtype: 'fp32', cacheDir: MODELS_DIR }); +const t0 = Date.now(); +await ready; +console.log('model ready in ' + ((Date.now()-t0)/1000).toFixed(1) + 's\n'); + +const one = (text, tgt) => send({ type:'translate', text, src:'eng_Latn', tgt }); +const many = (text, tgts) => send({ type:'translateMulti', text, src:'eng_Latn', tgts }); + +await one(TEXTS[0], 'spa_Latn'); await many(TEXTS[0], ['spa_Latn','fra_Latn']); // warm + +let mismatches = 0, checks = 0; +console.log('text N | serial | batched | speedup | match'); +console.log('-----------------------------+----------+----------+---------+------'); +for (const text of TEXTS) { + for (const n of [3, 9]) { + const langs = LANGS.slice(0, n); + let s = Date.now(); const so = {}; for (const l of langs) so[l] = await one(text, l); s = Date.now()-s; + let b = Date.now(); const bo = await many(text, langs); b = Date.now()-b; + let same = 0; for (const l of langs) { checks++; if (so[l] === bo[l]) same++; else { mismatches++; } } + console.log( + (text.slice(0,26)).padEnd(27) + String(n).padStart(2) + ' | ' + + (s+'ms').padStart(8) + ' | ' + (b+'ms').padStart(8) + ' | ' + + ((s/b).toFixed(2)+'x').padStart(7) + ' | ' + same + '/' + n); + if (same !== n) for (const l of langs) if (so[l] !== bo[l]) + console.log(' ' + l + '\n serial : ' + so[l] + '\n batched: ' + bo[l]); + } +} +console.log('\n' + (checks - mismatches) + '/' + checks + ' identical across ' + TEXTS.length + ' texts'); + +/* Edge cases */ +console.log('\nedge cases:'); +console.log(' one target :', JSON.stringify(await many('God is good', ['spa_Latn']))); +console.log(' zero targets :', JSON.stringify(await many('God is good', []))); +console.log(' duplicate :', JSON.stringify(await many('God is good', ['spa_Latn','spa_Latn']))); +child.kill(); diff --git a/app/_test-names-e2e.mjs b/app/_test-names-e2e.mjs new file mode 100644 index 0000000..83c4cbb --- /dev/null +++ b/app/_test-names-e2e.mjs @@ -0,0 +1,43 @@ +import { pipeline, env } from '@huggingface/transformers'; +import { createRequire } from 'module'; +import os from 'os'; import path from 'path'; import fs from 'fs'; import vm from 'vm'; +const require = createRequire(import.meta.url); +const Liturgy = require('./liturgy.js'), Guard = require('./mt-guard.js'); +const Preach = require('./preach.js'), B = require('./bible-lexicon.js'); +const Spa = require('./spa-variants.js'); + +const src = fs.readFileSync('engine.js', 'utf8'); +const sb = { console }; vm.createContext(sb); +vm.runInContext(src.slice(src.indexOf('const SCRIPT_NO_SPACES'), src.indexOf('function applyFixes')), sb); + +env.allowLocalModels = false; +env.cacheDir = path.join(os.homedir(), 'Library/Application Support/Church Translate Models'); +const pipe = await pipeline('translation', 'Xenova/nllb-200-distilled-600M', { dtype: 'fp32' }); + +/* The pipeline exactly as engine.js now runs it. */ +async function translate(sentence, lang) { + const set = Liturgy.lookup(sentence, lang); + if (set) return set + ' [liturgy]'; + const named = B.wholeUtterance(sentence, lang); + if (named) return named + ' [name lookup]'; + const forMt = Preach.forTranslation(sentence); + const sd = Preach.stripShortPeriod(forMt); + const o = await pipe(sd.text, { src_lang: 'eng_Latn', tgt_lang: lang }); + let raw = (Array.isArray(o) ? o[0] : o).translation_text.trim(); + raw = Guard.clean(sd.text, raw).text; + raw = Preach.restorePeriod(raw, sd.hadPeriod); + if (lang === 'spa_Latn') raw = Spa.toLatinAmerican(Spa.toChurchRegister(raw)); + const bl = B.forLanguage(lang); + if (bl) raw = sb.applyTable(raw, Object.keys(bl).map((f) => ({ from: f, to: bl[f] }))); + const fx = B.fixesFor(lang); + if (fx) raw = sb.applyTable(raw, Object.keys(fx).map((f) => ({ from: f, to: fx[f] }))); + return raw; +} + +const CASES = ['Peter.','John.','Matthew.','Jesus.','Mary.','Noah.','Moses.','Habakkuk.', + 'Simon Peter.','Donald Trump.','Amen.','Peter denied him three times.', + 'His name was John.','Open up to the book of John.']; +for (const lang of ['spa_Latn','fra_Latn','por_Latn','zho_Hans']) { + console.log('\n=== ' + lang + ' ==='); + for (const t of CASES) console.log(' ' + t.padEnd(32) + await translate(t, lang)); +} diff --git a/app/_test-oplocal.mjs b/app/_test-oplocal.mjs new file mode 100644 index 0000000..50cb9e3 --- /dev/null +++ b/app/_test-oplocal.mjs @@ -0,0 +1,61 @@ +/* The operator desk must be unreachable from the internet — page, API and + socket — and completely normal on the LAN. */ +import { spawn } from 'child_process'; +import { WebSocket } from 'ws'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { fileURLToPath } from 'url'; + +const DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ct-oplocal-')); +const PORT = 3198; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const srv = spawn(process.execPath, [path.join(__dirname, 'server', 'server.js')], + { env: { ...process.env, PORT: String(PORT), DATA_DIR: DIR }, stdio: ['ignore', 'pipe', 'pipe'] }); +const logs = []; srv.stdout.on('data', d => logs.push(String(d))); srv.stderr.on('data', d => logs.push(String(d))); +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const B = 'http://127.0.0.1:' + PORT; +const CFG = path.join(DIR, 'config.json'); +for (let i = 0; i < 80; i++) { if (fs.existsSync(CFG)) { try { await fetch(B + '/healthz'); break; } catch (_) {} } await sleep(150); } +const cfg = JSON.parse(fs.readFileSync(CFG, 'utf8')); +const PIN = cfg.operatorPin, KEY = cfg.controlToken; +console.log('new-install PIN: ' + PIN.length + ' digits (' + PIN + ')\n'); + +let pass = 0, fail = 0; +const check = (l, ok, d) => { console.log((ok ? ' ok ' : ' FAIL ') + l + (d ? ' ' + d : '')); ok ? pass++ : fail++; }; +const TUN = { 'x-forwarded-for': '203.0.113.7' }; + +const code = async (p, h) => (await fetch(B + p, { headers: h || {} })).status; + +console.log('from the LAN (correct PIN):'); +check('/operator page loads', await code('/operator') === 200); +check('/api/config accepts the PIN', await code('/api/config?pin=' + PIN) === 200); + +console.log('\nfrom the internet (same correct PIN):'); +check('/operator page is 404', await code('/operator', TUN) === 404, 'not even the login box'); +const c = await code('/api/config?pin=' + PIN, TUN); +check('/api/config is refused', c === 401 || c === 429, 'HTTP ' + c); + +console.log('\nthe listener page must still work from the internet:'); +check('/ is served', await code('/', TUN) === 200); +const lm = await code('/live/eng_Latn.m3u8', TUN); +check('/live/*.m3u8 answers', lm === 200 || lm === 404, 'HTTP ' + lm + ' (404 = nothing playing yet)'); + +console.log('\noperator WEBSOCKET:'); +const tryWs = (headers) => new Promise((res) => { + const ws = new WebSocket(B.replace('http','ws') + '/ws?role=operator&pin=' + PIN, { headers }); + const t = setTimeout(() => { try { ws.close(); } catch (_) {} res('timeout'); }, 3000); + ws.on('message', () => { clearTimeout(t); ws.close(); res('accepted'); }); + ws.on('close', (c, r) => { clearTimeout(t); res('closed: ' + String(r || c)); }); + ws.on('error', () => {}); +}); +check('LAN socket is accepted', await tryWs({}) === 'accepted'); +const w = await tryWs(TUN); +check('tunnel socket is closed', w.startsWith('closed'), w); + +console.log('\ncontrol API (Stream Deck) is unaffected — it uses a long random token:'); +check('control works from the LAN', await code('/api/control/status?key=' + KEY) === 200); + +console.log('\n--- log ---'); +console.log(logs.join('').split('\n').filter(l => /refused|operator/.test(l)).map(l => ' ' + l).join('\n')); +console.log('\n' + pass + ' passed, ' + fail + ' failed'); +srv.kill(); fs.rmSync(DIR, { recursive: true, force: true }); +process.exit(fail ? 1 : 0); diff --git a/app/_test-pacing.js b/app/_test-pacing.js new file mode 100644 index 0000000..bebdb4a --- /dev/null +++ b/app/_test-pacing.js @@ -0,0 +1,63 @@ +/* Model the cut rules with the SHIPPED constants and drive them with synthetic + speech patterns. The question is: how long does a segment get, per style? */ +const fs=require('fs'); +const src=fs.readFileSync('engine.js','utf8'); +const g=(n)=>Number(src.match(new RegExp('const '+n+'\\s*=\\s*Math\\.round\\((\\d+) / FRAME_MS\\)'))[1]); +const FRAME=Number(src.match(/const FRAME\s*=\s*(\d+)/)[1]); +const SAMPLE_RATE=Number(src.match(/const SAMPLE_RATE\s*=\s*(\d+)/)[1]); +const FRAME_MS=(FRAME/SAMPLE_RATE)*1000; +const F=(n)=>Math.round(g(n)/FRAME_MS); // source stores ms; the loop counts frames +const C={SOFT_CUT:F('SOFT_CUT'),SOFT_DIP:F('SOFT_DIP'),HARD_CUT:F('HARD_CUT'), + HARD_CUT_MAX:F('HARD_CUT_MAX'),EARLY_CLOSE:F('EARLY_CLOSE'), + EARLY_MIN_SEG:F('EARLY_MIN_SEG'),SILENCE_TO_END:F('SILENCE_TO_END')}; +console.log('FRAME_MS='+FRAME_MS+' constants (ms): '+ + Object.entries(C).map(([k,v])=>k+'='+Math.round(v*FRAME_MS)).join(' ')+'\n'); + +/* pattern: fn(frameIndex) -> {speech:bool, dipish:bool, sentenceEnded:bool} */ +function run(name, pattern, maxFrames){ + let len=0,silence=0,dip=0,cuts=[]; + for (let i=0;i=C.HARD_CUT_MAX) why='ceiling'; + else if (silence>=C.SILENCE_TO_END) why='silence'; + else if (len>=C.SOFT_CUT && dip>=C.SOFT_DIP) why=(len>=C.HARD_CUT?'hard':'soft'); + else if (p.sentenceEnded && len>=C.EARLY_MIN_SEG && silence>=C.EARLY_CLOSE) why='early'; + else if (p.sentenceEnded && len>=C.SOFT_CUT) why='sentence'; + if (why){ cuts.push({ms:len*FRAME_MS,why}); len=0;silence=0;dip=0; } + } + const avg=cuts.length?Math.round(cuts.reduce((a,c)=>a+c.ms,0)/cuts.length):0; + const worst=cuts.length?Math.max(...cuts.map(c=>c.ms)):0; + console.log(name); + console.log(' segments: '+cuts.length+' avg '+avg+'ms worst '+worst+'ms'); + console.log(' reasons : '+JSON.stringify(cuts.reduce((a,c)=>(a[c.why]=(a[c.why]||0)+1,a),{}))); + return {avg,worst}; +} + +const S=(ms)=>Math.round(ms/FRAME_MS); +/* A fast preacher: continuous speech, a sentence boundary every ~4s, and no + pause anywhere long enough for the old rules to notice. */ +run('FAST preacher — no pauses, sentence every 4s', (i)=>({ + speech:true, dipish:false, sentenceEnded: i%S(4000)===S(3999) +}), S(60000)); + +/* Same speaker but Whisper never reports a sentence end (worst case). */ +run('FAST preacher — no pauses, no punctuation at all', (i)=>({ + speech:true, dipish:false, sentenceEnded:false +}), S(60000)); + +/* A normal speaker: 150ms micro-gaps between phrases every 2s. */ +run('NORMAL speaker — 150ms gap every 2s', (i)=>{ + const inGap = (i%S(2000)) >= S(1850); + return { speech:!inGap, dipish:inGap, sentenceEnded:false }; +}, S(60000)); + +/* Someone who stops properly at the end of each sentence. */ +run('DELIBERATE speaker — 600ms full stop every 5s', (i)=>{ + const inGap = (i%S(5000)) >= S(4400); + return { speech:!inGap, dipish:inGap, sentenceEnded:false }; +}, S(60000)); diff --git a/app/_test-split.js b/app/_test-split.js new file mode 100644 index 0000000..2a9774c --- /dev/null +++ b/app/_test-split.js @@ -0,0 +1,36 @@ +const fs=require('fs'),vm=require('vm'); +const src=fs.readFileSync('engine.js','utf8'); +const grab=(n)=>{const i=src.indexOf('function '+n+'(');let d=0;for(let k=src.indexOf('{',i);k{const got=sb.splitSentences(input);const ok=JSON.stringify(got)===JSON.stringify(want); + console.log((ok?' PASS ':' FAIL ')+label); + if(!ok)console.log(' got '+JSON.stringify(got)+'\n want '+JSON.stringify(want)); + ok?pass++:fail++;}; + +console.log('\nAMEN MUST SURVIVE'); +t('amen after a sentence','God is good. Amen.',['God is good.','Amen.']); +t('amen alone','Amen.',['Amen.']); +t('two short sentences','Jesus wept. Amen.',['Jesus wept.','Amen.']); +t('hallelujah trailing','God is faithful. Hallelujah!',['God is faithful.','Hallelujah!']); +t('three shorts','Yes. Amen. Hallelujah.',['Yes.','Amen.','Hallelujah.']); + +console.log('\nWHAT THE GLUE RULE WAS PROTECTING'); +t('leading abbreviation','Dr. King said God is good.',['Dr. King said God is good.']); +t('mid abbreviation','We heard Dr. King speak today.',['We heard Dr. King speak today.']); +t('single initial','C. S. Lewis wrote that.',['C. S. Lewis wrote that.']); +t('decimal rejoined','It cost 3.5 million.',['It cost 3.5 million.']); +t('verse decimal','Read John 3.16 today.',['Read John 3.16 today.']); +t('saint','St. Paul wrote it.',['St. Paul wrote it.']); + +console.log('\nORDINARY TEXT UNCHANGED'); +t('one sentence','Today we are going to talk about Peter in the Bible.', + ['Today we are going to talk about Peter in the Bible.']); +t('two full sentences','God is love. He gave His only Son.',['God is love.','He gave His only Son.']); +t('question','Do you believe that? I do.',['Do you believe that?','I do.']); +t('semicolon stays','The Lord is my shepherd; I shall not want.', + ['The Lord is my shepherd; I shall not want.']); +console.log('\n'+pass+' passed, '+fail+' failed'); +process.exit(fail?1:0); diff --git a/app/_test-zh.js b/app/_test-zh.js new file mode 100644 index 0000000..50d06c5 --- /dev/null +++ b/app/_test-zh.js @@ -0,0 +1,35 @@ +/* Load and speak with every Chinese bundle the picker offers, plus a Piper + voice to prove the espeak path did not regress. */ +const path=require('path'), os=require('os'); +const voices=require('./voices.js'); +const sherpa=require('sherpa-onnx-node'); +const DIR=path.join(os.homedir(),'Library/Application Support/Church Translate Voices'); +const C=require('./voice-catalog.js'); + +const TEXT='耶稣说,我就是道路、真理、生命。'; +const cases=C.forLanguage('zho_Hans').filter(v=>v.engine==='builtin') + .map(v=>({bundle:v.bundle,model:v.model,text:TEXT})); +cases.push({bundle:'vits-piper-de_DE-thorsten-medium',model:'de_DE-thorsten-medium.onnx', + text:'Jesus sprach: Ich bin der Weg.'}); + +let ok=0,bad=0; +for (const c of cases) { + const dir=path.join(DIR,c.bundle); + try { + const cfg=voices.ttsConfig(dir,{model:c.model}); + const which=cfg.model.vits.lexicon?'lexicon':'espeak'; + const t=new sherpa.OfflineTts(cfg); + const a=t.generate({text:c.text,sid:0,speed:1.0}); + const secs=a.samples.length/a.sampleRate; + /* Silence means it "worked" but produced nothing — that is still a failure. */ + let peak=0; for(let i=0;ipeak)peak=v;} + const dead=peak<0.01; + console.log((dead?' SILENT ':' OK ')+c.bundle.padEnd(38)+which.padEnd(9)+ + secs.toFixed(1)+'s peak '+peak.toFixed(3)+' @'+a.sampleRate); + dead?bad++:ok++; + } catch(e) { + console.log(' FAIL '+c.bundle.padEnd(38)+String(e.message).split('\n')[0].slice(0,70)); + bad++; + } +} +console.log('\n'+ok+' usable, '+bad+' not'); diff --git a/app/_verify-audit.js b/app/_verify-audit.js new file mode 100644 index 0000000..24fc58f --- /dev/null +++ b/app/_verify-audit.js @@ -0,0 +1,38 @@ +/* Verify the audit's claims against the REAL functions, not by reading. */ +const fs=require('fs'), vm=require('vm'); +const src=fs.readFileSync('engine.js','utf8'); +const grab=(n)=>{const i=src.indexOf('function '+n+'(');let d=0;for(let k=src.indexOf('{',i);k /' + re.source + '/' + re.flags); +} +console.log(' consequence:'); +for (const [term,hay] of [['Марк','Маркса продали'],['Иоанн','Иоанном говорил'],['الرب','الربا حرام']]) { + const re=sb.termMatcher(term); re.lastIndex=0; + const hit = sb.applyTable(hay,[{from:term,to:'<>'}]); + console.log(' "'+hay+'" -> "'+hit+'"'); +} + +console.log('\nCLAIM A2 — accented capitals fall through to case-INSENSITIVE'); +for (const t of ['Éxodo','Ámos','Exodus']) { + const re=sb.termMatcher(t); + console.log(' '+t.padEnd(8)+' flags='+re.flags+(re.flags.includes('i')?' <-- matches lowercase too':'')); +} +console.log(' "el éxodo de los jóvenes" -> "' + sb.applyTable('el éxodo de los jóvenes',[{from:'Éxodo',to:'Exodo-BOOK'}]) + '"'); + +console.log('\nCLAIM A3 — 2-char CJK entries are unmatchable in running text'); +for (const [term,hay] of [['约翰','约翰说了'],['摩西','摩西带领'],['哈巴谷书','向哈巴谷书第一章']]) { + const out = sb.applyTable(hay,[{from:term,to:'<>'}]); + console.log(' '+term.padEnd(6)+' in "'+hay+'" -> "'+out+'"'+(out===hay?' <-- DID NOT FIRE':'')); +} + +console.log('\nCLAIM C1 — splitSentences glues short trailing sentences'); +for (const s of ['God is good. Amen.','It cost 3.5 million.','Dr. King said God is good.','Jesus wept. Amen.']) { + console.log(' "'+s+'"\n -> '+JSON.stringify(sb.splitSentences(s))); +} diff --git a/app/app-log.js b/app/app-log.js new file mode 100644 index 0000000..1f03021 --- /dev/null +++ b/app/app-log.js @@ -0,0 +1,75 @@ +/* + * app-log.js — the persistent log this app never had. + * + * Every child (voice process, congregation server, translation model) writes + * to stdout, and main.js forwards that to ITS stdout — which, for an app + * launched from the Finder, is nowhere. So the morning a service went wrong + * there was a caption transcript and not one line saying what the voice + * process had decided to do. This module tees everything to + * /logs/app.log, rotated so it can never fill a disk. + * + * Deliberately boring: synchronous appends (a crash must not lose the line + * that explains it), a size cap, three generations, and every failure + * swallowed — logging must never be the thing that takes the service down. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const MAX_BYTES = 2 * 1024 * 1024; // rotate past 2 MB +const KEEP = 3; // app.log, app.log.1, app.log.2 + +function createAppLog(dir, name) { + name = name || 'app.log'; + const file = path.join(dir, name); + let size = 0; + try { fs.mkdirSync(dir, { recursive: true }); } catch (_) {} + try { size = fs.statSync(file).size; } catch (_) { size = 0; } + + function rotate() { + try { + for (let i = KEEP - 1; i >= 1; i--) { + const from = file + (i === 1 ? '' : '.' + (i - 1)); + const to = file + '.' + i; + try { fs.renameSync(from, to); } catch (_) {} + } + } catch (_) {} + size = 0; + } + + function stamp() { + const d = new Date(); + const p = (n, w) => String(n).padStart(w || 2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()) + '.' + p(d.getMilliseconds(), 3); + } + + /* Write one or more lines under a source tag. Multi-line chunks from a + child's stdout are split so every line carries its own stamp and tag. */ + function write(tag, chunk) { + try { + const text = String(chunk == null ? '' : chunk); + if (!text.trim()) return; + const lines = text.replace(/\r/g, '').split('\n').filter((l) => l.length); + const out = lines.map((l) => stamp() + ' [' + tag + '] ' + l).join('\n') + '\n'; + /* Bytes, not characters: accented Spanish and CJK log lines are 2-3 + bytes a character, and a character count let a generation reach 6 MB. */ + const bytes = Buffer.byteLength(out, 'utf8'); + if (size + bytes > MAX_BYTES) rotate(); + fs.appendFileSync(file, out); + size += bytes; + } catch (_) { /* never let logging take anything down */ } + } + + /* Attach to a child process: tee stdout/stderr to the file. Returns the + child for chaining. The caller may still forward to its own stdout. */ + function tee(child, tag) { + if (child && child.stdout) child.stdout.on('data', (d) => write(tag, d)); + if (child && child.stderr) child.stderr.on('data', (d) => write(tag + ':err', d)); + return child; + } + + return { write, tee, file, rotate }; +} + +module.exports = { createAppLog, MAX_BYTES, KEEP }; diff --git a/app/bible-lexicon.js b/app/bible-lexicon.js new file mode 100644 index 0000000..5b122e8 --- /dev/null +++ b/app/bible-lexicon.js @@ -0,0 +1,1058 @@ +/* + * bible-lexicon.js — GENERATED from bible/lexicon-*.json by _gen-lexicon.js. + * Do not hand-edit; edit the JSON and regenerate. + * + * TWO TABLES, and the difference is the whole safety argument. + * + * ENGLISH[] maps an ENGLISH term to the local form. It only ever fires on + * English text that survived translation, so there is no target-language + * homograph to collide with — the trap that makes a target-side lexicon + * dangerous (a Spanish entry for "Marca" would turn "la marca de la bestia" + * into "la Marcos de la bestia"). No CJK word-boundary problem either, and + * no noun gender or grammatical case to agree with. + * + * FIXES[] is target-side and therefore restricted to forms MEASURED wrong + * against a named published edition, each one checked for not being an + * ordinary word in that language. Never add to it by intuition. + * + * Naming authorities: arb_Arab = Van Dyck / فان دايك (public domain); fra_Latn = Louis Segond 1910 (public domain); hat_Latn = Bib La, Haitian Creole Version (HAT); hin_Deva = IRV Hindi 2019; kor_Hang = Korean Bible — apostle forms verified against Korean Living Bible (KLB) text; por_Latn = João Ferreira de Almeida Revista e Corrigida 2009 (ARC); por_PT = Almeida Revista e Corrigida, Portugal — divergences from the Brazilian table only; spa_Latn = Reina-Valera 1960 (RVR1960); vie_Latn = Kinh Thánh 1925/1926 Bản Truyền Thống (VIE1925/BTT); zho_Hans = 和合本 Chinese Union Version, Simplified (CUVS); zho_Hant = 和合本 Chinese Union Version, Traditional (CUV). + * Verse TEXT from any copyrighted edition appears nowhere in this app; proper + * nouns and book titles are naming conventions, not copyrightable subject + * matter. + */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.BibleLexicon = factory(); +}(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + const ENGLISH = { + "arb_Arab": { + "Jesus": "يسوع", + "Christ": "المسيح", + "David": "داود", + "Abraham": "إبراهيم", + "Isaac": "إسحاق", + "Jacob": "يعقوب", + "Judah": "يهوذا", + "Tamar": "ثامار", + "Perez": "فارص", + "Zerah": "زارح", + "Hezron": "حصرون", + "Amminadab": "عميناداب", + "Nahshon": "نحشون", + "Salmon": "سلمون", + "Boaz": "بوعز", + "Rahab": "راحاب", + "Obed": "عوبيد", + "Ruth": "راعوث", + "Jesse": "يسى", + "Solomon": "سليمان", + "Uriah": "أوريا", + "Rehoboam": "رحبعام", + "Abijah": "أبيا", + "Asa": "آسا", + "Jehoshaphat": "يهوشافاط", + "Joram": "يورام", + "Uzziah": "عزيا", + "Jotham": "يوثام", + "Ahaz": "أحاز", + "Hezekiah": "حزقيا", + "Manasseh": "منسى", + "Amon": "آمون", + "Josiah": "يوشيا", + "Jechoniah": "يكنيا", + "Shealtiel": "شألتئيل", + "Zerubbabel": "زربابل", + "Abiud": "أبيهود", + "Eliakim": "ألياقيم", + "Azor": "عازور", + "Zadok": "صادوق", + "Achim": "أخيم", + "Eliud": "أليود", + "Eleazar": "أليعازر", + "Matthan": "متان", + "Joseph": "يوسف", + "Mary": "مريم", + "Simon": "سمعان", + "Peter": "بطرس", + "Andrew": "أندراوس", + "James": "يعقوب", + "Zebedee": "زبدي", + "John": "يوحنا", + "Philip": "فيلبس", + "Bartholomew": "برثولماوس", + "Thomas": "توما", + "Matthew": "متى", + "Alphaeus": "حلفى", + "Thaddaeus": "تداوس", + "Judas": "يهوذا", + "Moses": "موسى", + "Elijah": "إيليا", + "Ahab": "أخآب", + "Isaiah": "إشعياء", + "Jeremiah": "إرميا", + "Daniel": "دانيآل", + "Noah": "نوح", + "Adam": "آدم", + "Eve": "حواء", + "Joshua": "يشوع", + "Samuel": "صموئيل", + "Saul": "شاول", + "Paul": "بولس", + "Stephen": "استفانوس", + "Barnabas": "برنابا", + "Silas": "سيلا", + "Timothy": "تيموثاوس", + "Titus": "تيطس", + "Luke": "لوقا", + "Mark": "مرقس", + "Lazarus": "لعازر", + "Nicodemus": "نيقوديموس", + "Pilate": "بيلاطس", + "Herod": "هيرودس", + "Babylon": "بابل", + "Jerusalem": "أورشليم", + "Bethlehem": "بيت لحم", + "Nazareth": "ناصرة", + "Galilee": "الجليل", + "Judea": "اليهودية", + "Egypt": "مصر", + "Corinth": "كورنثوس", + "Ephesus": "أفسس", + "Rome": "رومية", + "Zion": "صهيون", + "Jordan": "الأردن", + "Bethany": "بيت عنيا", + "Athens": "أثينا" + }, + "fra_Latn": { + "Genesis": "Genèse", + "Exodus": "Exode", + "Leviticus": "Lévitique", + "Numbers": "Nombres", + "Deuteronomy": "Deutéronome", + "Joshua": "Josué", + "Judges": "Juges", + "Kings": "Rois", + "Chronicles": "Chroniques", + "Ezra": "Esdras", + "Nehemiah": "Néhémie", + "Psalms": "Psaumes", + "Psalm": "Psaume", + "Proverbs": "Proverbes", + "Ecclesiastes": "Ecclésiaste", + "Song of Solomon": "Cantique des cantiques", + "Isaiah": "Ésaïe", + "Jeremiah": "Jérémie", + "Ezekiel": "Ézéchiel", + "Hosea": "Osée", + "Joel": "Joël", + "Obadiah": "Abdias", + "Jonah": "Jonas", + "Micah": "Michée", + "Habakkuk": "Habacuc", + "Zephaniah": "Sophonie", + "Haggai": "Aggée", + "Zechariah": "Zacharie", + "Malachi": "Malachie", + "Matthew": "Matthieu", + "Mark": "Marc", + "Luke": "Luc", + "John": "Jean", + "Acts": "Actes", + "Romans": "Romains", + "Corinthians": "Corinthiens", + "Galatians": "Galates", + "Ephesians": "Éphésiens", + "Philippians": "Philippiens", + "Colossians": "Colossiens", + "Thessalonians": "Thessaloniciens", + "Timothy": "Timothée", + "Titus": "Tite", + "Philemon": "Philémon", + "Hebrews": "Hébreux", + "James": "Jacques", + "Peter": "Pierre", + "Revelation": "Apocalypse", + "Jesus": "Jésus", + "Jesus Christ": "Jésus-Christ", + "Holy Spirit": "Saint-Esprit", + "Eve": "Ève", + "Noah": "Noé", + "Sarah": "Sara", + "Esau": "Ésaü", + "Moses": "Moïse", + "Gideon": "Gédéon", + "Saul": "Saül", + "Solomon": "Salomon", + "Elijah": "Élie", + "Elisha": "Élisée", + "Naomi": "Noémi", + "Mary": "Marie", + "Martha": "Marthe", + "Elizabeth": "Élisabeth", + "John the Baptist": "Jean-Baptiste", + "Simon Peter": "Simon Pierre", + "Andrew": "André", + "Philip": "Philippe", + "Bartholomew": "Barthélemy", + "Thaddaeus": "Thaddée", + "Judas Iscariot": "Judas l'Iscariot", + "Stephen": "Étienne", + "Priscilla": "Priscille", + "Lazarus": "Lazare", + "Nicodemus": "Nicodème", + "Zacchaeus": "Zachée", + "Herod": "Hérode", + "Caesar": "César", + "Cornelius": "Corneille", + "Lydia": "Lydie", + "Tamar": "Thamar", + "Hezekiah": "Ézéchias", + "Josiah": "Josias", + "Manasseh": "Manassé", + "Zerubbabel": "Zorobabel", + "Jerusalem": "Jérusalem", + "Bethlehem": "Bethléhem", + "Galilee": "Galilée", + "Judea": "Judée", + "Samaria": "Samarie", + "Jericho": "Jéricho", + "Bethany": "Béthanie", + "Capernaum": "Capernaüm", + "Gethsemane": "Gethsémané", + "Calvary": "Calvaire", + "Mount Sinai": "mont Sinaï", + "Mount of Olives": "mont des Oliviers", + "Egypt": "Égypte", + "Babylon": "Babylone", + "Assyria": "Assyrie", + "Damascus": "Damas", + "Antioch": "Antioche", + "Corinth": "Corinthe", + "Ephesus": "Éphèse", + "Philippi": "Philippes", + "Thessalonica": "Thessalonique", + "Colossae": "Colosses", + "Galatia": "Galatie", + "Athens": "Athènes", + "Macedonia": "Macédoine", + "Zion": "Sion", + "Jordan River": "Jourdain", + "Red Sea": "mer Rouge", + "Sea of Galilee": "mer de Galilée", + "Eden": "Éden", + "gospel": "évangile", + "covenant": "alliance", + "apostle": "apôtre", + "Pharisee": "pharisien", + "Sadducee": "sadducéen", + "Gentile": "païen", + "Sabbath": "sabbat", + "Passover": "Pâque", + "Pentecost": "Pentecôte", + "Messiah": "Messie", + "salvation": "salut", + "grace": "grâce", + "mercy": "miséricorde", + "redemption": "rédemption", + "atonement": "expiation", + "righteousness": "justice", + "resurrection": "résurrection", + "kingdom of God": "royaume de Dieu", + "kingdom of heaven": "royaume des cieux", + "parable": "parabole", + "prophet": "prophète", + "prophecy": "prophétie", + "Scripture": "Écriture", + "the Word": "la Parole", + "altar": "autel", + "priest": "sacrificateur", + "high priest": "souverain sacrificateur", + "baptism": "baptême", + "the Lord's Supper": "la Cène", + "eternal life": "vie éternelle", + "the devil": "le diable" + }, + "hat_Latn": { + "Jesus": "Jezi", + "Christ": "Kris", + "Abraham": "Abraram", + "Isaac": "Izarak", + "Jacob": "Jakòb", + "Judah": "Jida", + "Tamar": "Tama", + "Perez": "Perèz", + "Zerah": "Zara", + "Hezron": "Eswòm", + "Amminadab": "Amenadab", + "Nahshon": "Naason", + "Rahab": "Raab", + "Obed": "Obèd", + "Ruth": "Rit", + "Solomon": "Salomon", + "Uriah": "Ouri", + "Rehoboam": "Woboram", + "Abijah": "Abya", + "Jehoshaphat": "Jozafa", + "Uzziah": "Ozyas", + "Jotham": "Joatam", + "Ahaz": "Akaz", + "Hezekiah": "Ezekyas", + "Manasseh": "Manase", + "Josiah": "Jozyas", + "Jechoniah": "Jekonyas", + "Shealtiel": "Salatyèl", + "Zerubbabel": "Zowobabèl", + "Abiud": "Abiyoud", + "Eliakim": "Elyakim", + "Azor": "Azò", + "Zadok": "Sadòk", + "Achim": "Akim", + "Eliud": "Eliyoud", + "Eleazar": "Eleaza", + "Matthan": "Matan", + "Joseph": "Jozèf", + "Mary": "Mari", + "Peter": "Pyè", + "Andrew": "Andre", + "James": "Jak", + "Zebedee": "Zebede", + "John": "Jan", + "Philip": "Filip", + "Bartholomew": "Batèlmi", + "Thomas": "Toma", + "Matthew": "Matye", + "Alphaeus": "Alfe", + "Thaddaeus": "Tade", + "Judas": "Jida", + "Moses": "Moyiz", + "Elijah": "Eli", + "Ahab": "Akab", + "Isaiah": "Ezayi", + "Jeremiah": "Jeremi", + "Daniel": "Danyèl", + "Noah": "Noe", + "Adam": "Adan", + "Eve": "Ev", + "Joshua": "Jozye", + "Samuel": "Samyèl", + "Paul": "Pòl", + "Stephen": "Etyèn", + "Barnabas": "Banabas", + "Timothy": "Timote", + "Titus": "Tit", + "Luke": "Lik", + "Mark": "Mak", + "Lazarus": "Laza", + "Nicodemus": "Nikodèm", + "Pilate": "Pilat", + "Herod": "Ewòd", + "Babylon": "Babilòn", + "Jerusalem": "Jerizalèm", + "Bethlehem": "Betleyèm", + "Nazareth": "Nazarèt", + "Galilee": "Galile", + "Judea": "Jide", + "Egypt": "Lejip", + "Corinth": "Korent", + "Ephesus": "Efèz", + "Rome": "Wòm", + "Zion": "Siyon", + "Jordan": "Jouden", + "Bethany": "Betani", + "Athens": "Atèn" + }, + "hin_Deva": { + "Jesus": "यीशु", + "Christ": "मसीह", + "David": "दाऊद", + "Isaac": "इसहाक", + "Jacob": "याकूब", + "Judah": "यहूदा", + "Tamar": "तामार", + "Amminadab": "अम्मीनादाब", + "Nahshon": "नहशोन", + "Salmon": "सलमोन", + "Boaz": "बोअज", + "Rahab": "राहाब", + "Obed": "ओबेद", + "Ruth": "रूत", + "Jesse": "यिशै", + "Solomon": "सुलैमान", + "Uriah": "ऊरिय्याह", + "Rehoboam": "रहबाम", + "Abijah": "अबिय्याह", + "Asa": "आसा", + "Jehoshaphat": "यहोशाफात", + "Joram": "योराम", + "Jotham": "योताम", + "Ahaz": "आहाज", + "Hezekiah": "हिजकिय्याह", + "Amon": "आमोन", + "Josiah": "योशिय्याह", + "Jechoniah": "यकुन्याह", + "Shealtiel": "शालतीएल", + "Zerubbabel": "जरुब्बाबेल", + "Abiud": "अबीहूद", + "Eliakim": "एलयाकीम", + "Azor": "अजोर", + "Zadok": "सादोक", + "Achim": "अखीम", + "Eliud": "एलीहूद", + "Eleazar": "एलीआजर", + "Matthan": "मत्तान", + "Joseph": "यूसुफ", + "Mary": "मरियम", + "Simon": "शमौन", + "Peter": "पतरस", + "Andrew": "अन्द्रियास", + "James": "याकूब", + "Zebedee": "जब्दी", + "John": "यूहन्ना", + "Philip": "फिलिप्पुस", + "Bartholomew": "बरतुल्मै", + "Thomas": "थोमा", + "Matthew": "मत्ती", + "Alphaeus": "हलफईस", + "Thaddaeus": "तद्दै", + "Judas": "यहूदा", + "Moses": "मूसा", + "Elijah": "एलिय्याह", + "Ahab": "अहाब", + "Isaiah": "यशायाह", + "Jeremiah": "यिर्मयाह", + "Daniel": "दानिय्येल", + "Noah": "नूह", + "Adam": "आदम", + "Eve": "हव्वा", + "Joshua": "यहोशू", + "Samuel": "शमूएल", + "Saul": "शाऊल", + "Paul": "पौलुस", + "Stephen": "स्तिफनुस", + "Barnabas": "बरनबास", + "Silas": "सीलास", + "Timothy": "तीमुथियुस", + "Titus": "तीतुस", + "Luke": "लूका", + "Mark": "मरकुस", + "Lazarus": "लाज़र", + "Nicodemus": "नीकुदेमुस", + "Pilate": "पिलातुस", + "Herod": "हेरोदेस", + "Jerusalem": "यरूशलेम", + "Bethlehem": "बैतलहम", + "Nazareth": "नासरत", + "Galilee": "गलील", + "Judea": "यहूदिया", + "Egypt": "मिस्र", + "Corinth": "कुरिन्थुस", + "Ephesus": "इफिसुस", + "Rome": "रोम", + "Zion": "सिय्योन", + "Jordan": "यरदन", + "Bethany": "बैतनिय्याह" + }, + "kor_Hang": { + "Jesus": "예수", + "Christ": "그리스도", + "Simon": "시몬", + "Peter": "베드로", + "Andrew": "안드레", + "James": "야고보", + "John": "요한", + "Philip": "빌립", + "Bartholomew": "바돌로매", + "Thomas": "도마", + "Matthew": "마태", + "Thaddaeus": "다대오", + "Judas": "유다", + "Zebedee": "세베대", + "Alphaeus": "알패오" + }, + "por_Latn": { + "Christ": "Cristo", + "David": "Davi", + "Abraham": "Abraão", + "Isaac": "Isaque", + "Jacob": "Jacó", + "Judah": "Judá", + "Zerah": "Zerá", + "Hezron": "Esrom", + "Amminadab": "Aminadabe", + "Nahshon": "Naassom", + "Salmon": "Salmom", + "Rahab": "Raabe", + "Obed": "Obede", + "Ruth": "Rute", + "Jesse": "Jessé", + "Solomon": "Salomão", + "Uriah": "Urias", + "Rehoboam": "Roboão", + "Abijah": "Abias", + "Jehoshaphat": "Josafá", + "Joram": "Jorão", + "Uzziah": "Uzias", + "Jotham": "Jotão", + "Ahaz": "Acaz", + "Hezekiah": "Ezequias", + "Manasseh": "Manassés", + "Amon": "Amom", + "Josiah": "Josias", + "Jechoniah": "Jeconias", + "Shealtiel": "Salatiel", + "Zerubbabel": "Zorobabel", + "Abiud": "Abiúde", + "Eliakim": "Eliaquim", + "Zadok": "Sadoque", + "Achim": "Aquim", + "Eliud": "Eliúde", + "Matthan": "Matã", + "Joseph": "José", + "Mary": "Maria", + "Simon": "Simão", + "Peter": "Pedro", + "Andrew": "André", + "James": "Tiago", + "Zebedee": "Zebedeu", + "John": "João", + "Philip": "Filipe", + "Bartholomew": "Bartolomeu", + "Thomas": "Tomé", + "Matthew": "Mateus", + "Alphaeus": "Alfeu", + "Thaddaeus": "Tadeu", + "Moses": "Moisés", + "Elijah": "Elias", + "Ahab": "Acabe", + "Isaiah": "Isaías", + "Jeremiah": "Jeremias", + "Noah": "Noé", + "Adam": "Adão", + "Eve": "Eva", + "Joshua": "Josué", + "Paul": "Paulo", + "Stephen": "Estêvão", + "Barnabas": "Barnabé", + "Timothy": "Timóteo", + "Titus": "Tito", + "Luke": "Lucas", + "Mark": "Marcos", + "Lazarus": "Lázaro", + "Nicodemus": "Nicodemos", + "Pilate": "Pilatos", + "Herod": "Herodes", + "Babylon": "Babilônia", + "Jerusalem": "Jerusalém", + "Bethlehem": "Belém", + "Nazareth": "Nazaré", + "Galilee": "Galileia", + "Judea": "Judeia", + "Egypt": "Egito", + "Corinth": "Corinto", + "Ephesus": "Éfeso", + "Rome": "Roma", + "Zion": "Sião", + "Jordan": "Jordão", + "Bethany": "Betânia", + "Athens": "Atenas" + }, + "spa_Latn": { + "Genesis": "Génesis", + "Exodus": "Éxodo", + "Leviticus": "Levítico", + "Numbers": "Números", + "Deuteronomy": "Deuteronomio", + "Joshua": "Josué", + "Judges": "Jueces", + "Ruth": "Rut", + "Kings": "Reyes", + "Chronicles": "Crónicas", + "Ezra": "Esdras", + "Nehemiah": "Nehemías", + "Esther": "Ester", + "Psalms": "Salmos", + "Psalm": "Salmo", + "Proverbs": "Proverbios", + "Ecclesiastes": "Eclesiastés", + "Song of Solomon": "Cantares", + "Song of Songs": "Cantares", + "Isaiah": "Isaías", + "Jeremiah": "Jeremías", + "Lamentations": "Lamentaciones", + "Ezekiel": "Ezequiel", + "Hosea": "Oseas", + "Amos": "Amós", + "Obadiah": "Abdías", + "Jonah": "Jonás", + "Micah": "Miqueas", + "Nahum": "Nahúm", + "Habakkuk": "Habacuc", + "Zephaniah": "Sofonías", + "Haggai": "Hageo", + "Zechariah": "Zacarías", + "Malachi": "Malaquías", + "Matthew": "Mateo", + "Mark": "Marcos", + "Luke": "Lucas", + "John": "Juan", + "Acts": "Hechos", + "Romans": "Romanos", + "Corinthians": "Corintios", + "Galatians": "Gálatas", + "Ephesians": "Efesios", + "Philippians": "Filipenses", + "Colossians": "Colosenses", + "Thessalonians": "Tesalonicenses", + "Timothy": "Timoteo", + "Titus": "Tito", + "Philemon": "Filemón", + "Hebrews": "Hebreos", + "James": "Santiago", + "Peter": "Pedro", + "Jude": "Judas", + "Revelation": "Apocalipsis", + "Jesus": "Jesús", + "Jesus Christ": "Jesucristo", + "Christ": "Cristo", + "Holy Spirit": "Espíritu Santo", + "Son of God": "Hijo de Dios", + "Adam": "Adán", + "Eve": "Eva", + "Noah": "Noé", + "Sarah": "Sara", + "Rebekah": "Rebeca", + "Esau": "Esaú", + "Rachel": "Raquel", + "Leah": "Lea", + "Joseph": "José", + "Moses": "Moisés", + "Aaron": "Aarón", + "Miriam": "María", + "Gideon": "Gedeón", + "Samson": "Sansón", + "Saul": "Saúl", + "Solomon": "Salomón", + "Elijah": "Elías", + "Elisha": "Eliseo", + "Naomi": "Noemí", + "Boaz": "Booz", + "Mary": "María", + "Martha": "Marta", + "Elizabeth": "Elisabet", + "John the Baptist": "Juan el Bautista", + "Simon Peter": "Simón Pedro", + "Simon": "Simón", + "Andrew": "Andrés", + "Philip": "Felipe", + "Bartholomew": "Bartolomé", + "Thomas": "Tomás", + "Thaddaeus": "Tadeo", + "Judas Iscariot": "Judas Iscariote", + "Matthias": "Matías", + "Paul": "Pablo", + "Barnabas": "Bernabé", + "Stephen": "Esteban", + "Apollos": "Apolos", + "Priscilla": "Priscila", + "Lazarus": "Lázaro", + "Nicodemus": "Nicodemo", + "Zacchaeus": "Zaqueo", + "Pilate": "Pilato", + "Herod": "Herodes", + "Caesar": "César", + "Cornelius": "Cornelio", + "Lydia": "Lidia", + "Jehovah": "Jehová", + "Jerusalem": "Jerusalén", + "Bethlehem": "Belén", + "Nazareth": "Nazaret", + "Galilee": "Galilea", + "Jericho": "Jericó", + "Bethany": "Betania", + "Gethsemane": "Getsemaní", + "Golgotha": "Gólgota", + "Calvary": "Calvario", + "Mount Sinai": "monte Sinaí", + "Mount of Olives": "monte de los Olivos", + "Egypt": "Egipto", + "Babylon": "Babilonia", + "Assyria": "Asiria", + "Damascus": "Damasco", + "Antioch": "Antioquía", + "Corinth": "Corinto", + "Ephesus": "Éfeso", + "Philippi": "Filipos", + "Thessalonica": "Tesalónica", + "Colossae": "Colosas", + "Galatia": "Galacia", + "Rome": "Roma", + "Athens": "Atenas", + "Zion": "Sion", + "Canaan": "Canaán", + "Jordan River": "río Jordán", + "Red Sea": "Mar Rojo", + "Sea of Galilee": "mar de Galilea", + "Eden": "Edén", + "gospel": "evangelio", + "covenant": "pacto", + "new covenant": "nuevo pacto", + "apostle": "apóstol", + "disciple": "discípulo", + "Pharisee": "fariseo", + "Sadducee": "saduceo", + "Gentile": "gentil", + "Sabbath": "día de reposo", + "Passover": "pascua", + "Pentecost": "Pentecostés", + "Messiah": "Mesías", + "salvation": "salvación", + "grace": "gracia", + "mercy": "misericordia", + "repentance": "arrepentimiento", + "redemption": "redención", + "atonement": "expiación", + "righteousness": "justicia", + "justification": "justificación", + "sanctification": "santificación", + "propitiation": "propiciación", + "resurrection": "resurrección", + "crucifixion": "crucifixión", + "ascension": "ascensión", + "kingdom of God": "reino de Dios", + "kingdom of heaven": "reino de los cielos", + "parable": "parábola", + "prophet": "profeta", + "prophecy": "profecía", + "epistle": "epístola", + "Scripture": "Escritura", + "the Word": "el Verbo", + "tabernacle": "tabernáculo", + "temple": "templo", + "sacrifice": "sacrificio", + "priest": "sacerdote", + "high priest": "sumo sacerdote", + "baptism": "bautismo", + "the Lord's Supper": "la Cena del Señor", + "eternal life": "vida eterna", + "Satan": "Satanás", + "the devil": "el diablo", + "cherubim": "querubines", + "seraphim": "serafines" + }, + "vie_Latn": { + "Jesus": "Jêsus", + "David": "Đa-vít", + "Abraham": "Áp-ra-ham", + "Isaac": "Y-sác", + "Jacob": "Gia-cốp", + "Judah": "Giu-đa", + "Tamar": "Tha-ma", + "Perez": "Pha-rê", + "Zerah": "Xa-ra", + "Hezron": "Ếch-rôm", + "Amminadab": "A-mi-na-đáp", + "Nahshon": "Na-ách-son", + "Salmon": "Sanh-môn", + "Boaz": "Bô-ô", + "Rahab": "Ra-háp", + "Obed": "Ô-bết", + "Ruth": "Ru-tơ", + "Solomon": "Sa-lô-môn", + "Uriah": "U-ri", + "Rehoboam": "Rô-bô-am", + "Abijah": "A-bi-gia", + "Asa": "A-sa", + "Jehoshaphat": "Giô-sa-phát", + "Joram": "Giô-ram", + "Uzziah": "Ô-xia", + "Jotham": "Giô-tam", + "Ahaz": "A-cha", + "Hezekiah": "Ê-xê-chia", + "Manasseh": "Ma-na-sê", + "Amon": "A-môn", + "Josiah": "Giô-si-a", + "Jechoniah": "Giê-chô-nia", + "Shealtiel": "Sa-la-thi-ên", + "Zerubbabel": "Xô-rô-ba-bên", + "Abiud": "A-bi-út", + "Eliakim": "Ê-li-a-kim", + "Azor": "A-xô", + "Zadok": "Sa-đốc", + "Achim": "A-chim", + "Eliud": "Ê-li-út", + "Eleazar": "Ê-lê-a-xa", + "Matthan": "Ma-than", + "Joseph": "Giô-sép", + "Mary": "Ma-ri", + "Simon": "Si-môn", + "Peter": "Phi-e-rơ", + "Andrew": "Anh-rê", + "James": "Gia-cơ", + "Zebedee": "Xê-bê-đê", + "John": "Giăng", + "Philip": "Phi-líp", + "Bartholomew": "Ba-tê-lê-my", + "Thomas": "Thô-ma", + "Matthew": "Ma-thi-ơ", + "Alphaeus": "A-phê", + "Thaddaeus": "Tha-đê", + "Judas": "Giu-đa", + "Moses": "Môi-se", + "Elijah": "Ê-li", + "Ahab": "A-háp", + "Isaiah": "Ê-sai", + "Jeremiah": "Giê-rê-mi", + "Daniel": "Đa-ni-ên", + "Noah": "Nô-ê", + "Adam": "A-đam", + "Eve": "Ê-va", + "Joshua": "Giô-suê", + "Samuel": "Sa-mu-ên", + "Saul": "Sau-lơ", + "Paul": "Phao-lô", + "Stephen": "Ê-tiên", + "Barnabas": "Ba-na-ba", + "Silas": "Si-la", + "Timothy": "Ti-mô-thê", + "Titus": "Tít", + "Luke": "Lu-ca", + "Mark": "Mác", + "Lazarus": "La-xa-rơ", + "Nicodemus": "Ni-cô-đem", + "Pilate": "Phi-lát", + "Herod": "Hê-rốt", + "Babylon": "Ba-by-lôn", + "Jerusalem": "Giê-ru-sa-lem", + "Bethlehem": "Bết-lê-hem", + "Nazareth": "Na-xa-rét", + "Galilee": "Ga-li-lê", + "Judea": "Giu-đê", + "Egypt": "Ê-díp-tô", + "Corinth": "Cô-rinh-tô", + "Ephesus": "Ê-phê-sô", + "Rome": "Rô-ma", + "Zion": "Si-ôn", + "Jordan": "Giô-đanh", + "Bethany": "Bê-tha-ni", + "Athens": "A-thên" + }, + "zho_Hans": { + "Jesus": "耶稣", + "Jesus Christ": "耶稣基督", + "Christ": "基督", + "Simon": "西门", + "Simon Peter": "西门彼得", + "Peter": "彼得", + "Andrew": "安得烈", + "James": "雅各", + "John": "约翰", + "Philip": "腓力", + "Bartholomew": "巴多罗买", + "Thomas": "多马", + "Matthew": "马太", + "Thaddaeus": "达太", + "Judas": "犹大", + "Zebedee": "西庇太", + "Abraham": "亚伯拉罕", + "David": "大卫", + "Isaac": "以撒", + "Jacob": "雅各", + "Judah": "犹大", + "Tamar": "他玛", + "Perez": "法勒斯", + "Zerah": "谢拉", + "Hezron": "希斯仑", + "Amminadab": "亚米拿达", + "Nahshon": "拿顺", + "Salmon": "撒门", + "Rahab": "喇合", + "Boaz": "波阿斯", + "Ruth": "路得", + "Obed": "俄备得", + "Jesse": "耶西", + "Uriah": "乌利亚", + "Solomon": "所罗门", + "Moses": "摩西", + "Paul": "保罗", + "Joshua": "约书亚", + "Noah": "挪亚" + }, + "zho_Hant": { + "Jesus": "耶穌", + "Jesus Christ": "耶穌基督", + "Christ": "基督", + "Simon": "西門", + "Peter": "彼得", + "Andrew": "安得烈", + "James": "雅各", + "John": "約翰", + "Philip": "腓力", + "Bartholomew": "巴多羅買", + "Thomas": "多馬", + "Matthew": "馬太", + "Thaddaeus": "達太", + "Judas": "猶大", + "Zebedee": "西庇太", + "Abraham": "亞伯拉罕", + "David": "大衛", + "Isaac": "以撒", + "Jacob": "雅各", + "Judah": "猶大" + }, + "por_PT": { + "Christ": "Cristo", + "Abraham": "Abraão", + "Isaac": "Isaque", + "Jacob": "Jacó", + "Judah": "Judá", + "Zerah": "Zerá", + "Hezron": "Esrom", + "Amminadab": "Aminadabe", + "Nahshon": "Naassom", + "Salmon": "Salmom", + "Rahab": "Raabe", + "Obed": "Obede", + "Ruth": "Rute", + "Jesse": "Jessé", + "Solomon": "Salomão", + "Uriah": "Urias", + "Rehoboam": "Roboão", + "Abijah": "Abias", + "Jehoshaphat": "Josafá", + "Joram": "Jorão", + "Uzziah": "Uzias", + "Jotham": "Jotão", + "Ahaz": "Acaz", + "Hezekiah": "Ezequias", + "Manasseh": "Manassés", + "Amon": "Amom", + "Josiah": "Josias", + "Jechoniah": "Jeconias", + "Shealtiel": "Salatiel", + "Zerubbabel": "Zorobabel", + "Abiud": "Abiúde", + "Eliakim": "Eliaquim", + "Zadok": "Sadoque", + "Achim": "Aquim", + "Eliud": "Eliúde", + "Matthan": "Matã", + "Joseph": "José", + "Mary": "Maria", + "Simon": "Simão", + "Peter": "Pedro", + "Andrew": "André", + "James": "Tiago", + "Zebedee": "Zebedeu", + "John": "João", + "Philip": "Filipe", + "Bartholomew": "Bartolomeu", + "Thomas": "Tomé", + "Matthew": "Mateus", + "Alphaeus": "Alfeu", + "Thaddaeus": "Tadeu", + "Moses": "Moisés", + "Elijah": "Elias", + "Ahab": "Acabe", + "Isaiah": "Isaías", + "Jeremiah": "Jeremias", + "Noah": "Noé", + "Adam": "Adão", + "Eve": "Eva", + "Joshua": "Josué", + "Paul": "Paulo", + "Stephen": "Estêvão", + "Barnabas": "Barnabé", + "Timothy": "Timóteo", + "Titus": "Tito", + "Luke": "Lucas", + "Mark": "Marcos", + "Lazarus": "Lázaro", + "Nicodemus": "Nicodemos", + "Pilate": "Pilatos", + "Herod": "Herodes", + "Babylon": "Babilônia", + "Jerusalem": "Jerusalém", + "Bethlehem": "Belém", + "Nazareth": "Nazaré", + "Galilee": "Galileia", + "Judea": "Judeia", + "Egypt": "Egito", + "Corinth": "Corinto", + "Ephesus": "Éfeso", + "Rome": "Roma", + "Zion": "Sião", + "Jordan": "Jordão", + "Bethany": "Betânia", + "Athens": "Atenas" + } + }; + + const FIXES = { + "fra_Latn": { + "Éliya": "Élie", + "Stéphanas": "Étienne", + "Isaïe": "Ésaïe" + }, + "kor_Hang": { + "매튜": "마태" + }, + "zho_Hans": { + "诺亚": "挪亚", + "马修": "马太" + }, + "zho_Hant": { + "諾亞": "挪亞", + "馬修": "馬太" + } + }; + + /* A regional variant inherits its base language. Canonical list: NLLB_ALIAS. */ + const VARIANT_OF = { spa_ES: 'spa_Latn', spa_MX: 'spa_Latn', por_PT: 'por_Latn', zho_Hant: 'zho_Hans' }; + const base = (lang) => (ENGLISH[lang] ? lang : VARIANT_OF[lang]); + + function forLanguage(lang) { return ENGLISH[base(lang)] || null; } + + /* + * WHOLE-UTTERANCE NAME LOOKUP — the deterministic guarantee. + * + * A bare name is the worst input this model receives. MEASURED into + * Spanish: "Peter." came back as "¿Qué es eso?" and "Jesus." as + * "¿Qué quieres?" — the name GONE, replaced by invented dialogue. + * Into French, "Habakkuk." became "Habakuk est mort" (Habakkuk is dead). + * A preacher listing names one at a time produced a run of questions with + * no names in them at all. + * + * The cause is ONE CHARACTER: the terminal period. Without it the model + * translates the term ("Peter" -> "Pedro"); with it, it reads a complete + * conversational turn and invents a reply. preach.js strips the period for + * short utterances, which fixes most of it — this is the guarantee for the + * rest, because a name we know should never depend on the model at all. + * + * Matched on the bare word, punctuation and case stripped, and ONLY when + * the utterance is that name and nothing else — an ordinary sentence + * containing the name is untouched. + */ + function wholeUtterance(text, lang) { + const t = ENGLISH[base(lang)]; + if (!t) return null; + const bare = String(text == null ? '' : text) + .replace(/^[\s"\u2018\u201c(\[\']+/, '') + .replace(/[\s.!?,;:"\u2019\u201d)\]\']+$/, '') + .trim(); + if (!bare) return null; + if (Object.prototype.hasOwnProperty.call(t, bare)) return t[bare]; + const lower = bare.toLowerCase(); + for (const k of Object.keys(t)) if (k.toLowerCase() === lower) return t[k]; + return null; + } + + function fixesFor(lang) { return FIXES[base(lang)] || null; } + function languages() { return Object.keys(ENGLISH); } + + return { forLanguage: forLanguage, fixesFor: fixesFor, + wholeUtterance: wholeUtterance, languages: languages, + ENGLISH: ENGLISH, FIXES: FIXES }; +})); \ No newline at end of file diff --git a/app/bible/english-inventory.json b/app/bible/english-inventory.json new file mode 100644 index 0000000..0937135 --- /dev/null +++ b/app/bible/english-inventory.json @@ -0,0 +1,58 @@ +{ + "_note": "English-side inventory. The canonical spellings a preacher's transcript should contain, and the vocabulary whose TARGET-side rendering has to be checked. This file asserts nothing about other languages.", + + "books": [ + "Genesis","Exodus","Leviticus","Numbers","Deuteronomy","Joshua","Judges","Ruth", + "1 Samuel","2 Samuel","1 Kings","2 Kings","1 Chronicles","2 Chronicles","Ezra", + "Nehemiah","Esther","Job","Psalms","Proverbs","Ecclesiastes","Song of Solomon", + "Isaiah","Jeremiah","Lamentations","Ezekiel","Daniel","Hosea","Joel","Amos", + "Obadiah","Jonah","Micah","Nahum","Habakkuk","Zephaniah","Haggai","Zechariah","Malachi", + "Matthew","Mark","Luke","John","Acts","Romans","1 Corinthians","2 Corinthians", + "Galatians","Ephesians","Philippians","Colossians","1 Thessalonians","2 Thessalonians", + "1 Timothy","2 Timothy","Titus","Philemon","Hebrews","James","1 Peter","2 Peter", + "1 John","2 John","3 John","Jude","Revelation" + ], + + "people": [ + "Jesus","Christ","Jesus Christ","God","Holy Spirit","Lord","Father","Son of God", + "Adam","Eve","Noah","Abraham","Sarah","Isaac","Rebekah","Jacob","Esau","Rachel","Leah", + "Joseph","Moses","Aaron","Miriam","Joshua","Caleb","Gideon","Samson","Samuel","Saul", + "David","Solomon","Elijah","Elisha","Isaiah","Jeremiah","Ezekiel","Daniel","Jonah", + "Nehemiah","Ezra","Esther","Ruth","Naomi","Boaz","Job", + "Mary","Martha","Elizabeth","Zechariah","John the Baptist","Simon","Simon Peter","Peter", + "Andrew","James","John","Philip","Bartholomew","Thomas","Matthew","Thaddaeus", + "Judas","Judas Iscariot","Matthias","Paul","Saul of Tarsus","Barnabas","Silas", + "Timothy","Titus","Philemon","Stephen","Luke","Mark","Apollos","Priscilla","Aquila", + "Lazarus","Nicodemus","Zacchaeus","Pilate","Herod","Caesar","Cornelius","Lydia" + ], + + "places": [ + "Jerusalem","Bethlehem","Nazareth","Galilee","Judea","Samaria","Jericho","Bethany", + "Capernaum","Gethsemane","Golgotha","Calvary","Mount Sinai","Mount of Olives", + "Egypt","Babylon","Assyria","Damascus","Antioch","Corinth","Ephesus","Philippi", + "Thessalonica","Colossae","Galatia","Rome","Athens","Macedonia","Patmos", + "Zion","Canaan","Jordan River","Red Sea","Sea of Galilee","Eden" + ], + + "terms": [ + "gospel","covenant","new covenant","apostle","disciple","Pharisee","Sadducee", + "Gentile","Sabbath","Passover","Pentecost","Messiah","salvation","grace","mercy", + "repentance","redemption","atonement","righteousness","justification","sanctification", + "propitiation","resurrection","crucifixion","ascension","kingdom of God", + "kingdom of heaven","parable","prophet","prophecy","psalm","epistle","scripture", + "the Word","tabernacle","temple","altar","sacrifice","priest","high priest", + "baptism","communion","the Lord's Supper","faith","hope","love","sin","forgiveness", + "eternal life","heaven","hell","Satan","the devil","angel","cherubim","seraphim" + ], + + "liturgical": { + "_note": "Whole-utterance constants. These are the highest-frequency short lines in preaching and the ones NLLB corrupts worst — measured: 'Amen.' becomes 'Yes, I am fine.' in Spanish. Matched only as an ENTIRE utterance, never as a substring.", + "keys": [ + "amen","hallelujah","alleluia","hosanna","praise God","praise the Lord", + "thank you Jesus","glory to God","come on","let us pray","in Jesus name", + "in Jesus' name","god bless you","god is good","hello","hello hello", + "good morning","good evening","welcome","turn with me","open your Bibles", + "let me read that again","are you with me","can I get an amen" + ] + } +} diff --git a/app/bible/lexicon-arb.json b/app/bible/lexicon-arb.json new file mode 100644 index 0000000..509b071 --- /dev/null +++ b/app/bible/lexicon-arb.json @@ -0,0 +1,107 @@ +{ + "language": "arb_Arab", + "reference": "Van Dyck / فان دايك (public domain)", + "_verified": "Verified from the full ebible.org Van Dyck corpus, every form grepped back against it. THE DECISIVE FINDING: Van Dyck uses يسوع for Jesus 973 times and عيسى (the Quranic form) ZERO times; God is الله, 2240 times, with no competitor. Machine translation reaches for عيسى, which is why this entry exists. Diacritics mechanically stripped (U+064B-U+0652, U+0670; ٱ->ا) to match what NLLB emits; the printed form is يَسُوع.", + "confidence": "high — every form read from published text and grepped back against the source corpus", + "people": { + "Jesus": "يسوع", + "Christ": "المسيح", + "David": "داود", + "Abraham": "إبراهيم", + "Isaac": "إسحاق", + "Jacob": "يعقوب", + "Judah": "يهوذا", + "Tamar": "ثامار", + "Perez": "فارص", + "Zerah": "زارح", + "Hezron": "حصرون", + "Amminadab": "عميناداب", + "Nahshon": "نحشون", + "Salmon": "سلمون", + "Boaz": "بوعز", + "Rahab": "راحاب", + "Obed": "عوبيد", + "Ruth": "راعوث", + "Jesse": "يسى", + "Solomon": "سليمان", + "Uriah": "أوريا", + "Rehoboam": "رحبعام", + "Abijah": "أبيا", + "Asa": "آسا", + "Jehoshaphat": "يهوشافاط", + "Joram": "يورام", + "Uzziah": "عزيا", + "Jotham": "يوثام", + "Ahaz": "أحاز", + "Hezekiah": "حزقيا", + "Manasseh": "منسى", + "Amon": "آمون", + "Josiah": "يوشيا", + "Jechoniah": "يكنيا", + "Shealtiel": "شألتئيل", + "Zerubbabel": "زربابل", + "Abiud": "أبيهود", + "Eliakim": "ألياقيم", + "Azor": "عازور", + "Zadok": "صادوق", + "Achim": "أخيم", + "Eliud": "أليود", + "Eleazar": "أليعازر", + "Matthan": "متان", + "Joseph": "يوسف", + "Mary": "مريم", + "Simon": "سمعان", + "Peter": "بطرس", + "Andrew": "أندراوس", + "James": "يعقوب", + "Zebedee": "زبدي", + "John": "يوحنا", + "Philip": "فيلبس", + "Bartholomew": "برثولماوس", + "Thomas": "توما", + "Matthew": "متى", + "Alphaeus": "حلفى", + "Thaddaeus": "تداوس", + "Judas": "يهوذا", + "Moses": "موسى", + "Elijah": "إيليا", + "Ahab": "أخآب", + "Isaiah": "إشعياء", + "Jeremiah": "إرميا", + "Daniel": "دانيآل", + "Noah": "نوح", + "Adam": "آدم", + "Eve": "حواء", + "Joshua": "يشوع", + "Samuel": "صموئيل", + "Saul": "شاول", + "Paul": "بولس", + "Stephen": "استفانوس", + "Barnabas": "برنابا", + "Silas": "سيلا", + "Timothy": "تيموثاوس", + "Titus": "تيطس", + "Luke": "لوقا", + "Mark": "مرقس", + "Lazarus": "لعازر", + "Nicodemus": "نيقوديموس", + "Pilate": "بيلاطس", + "Herod": "هيرودس" + }, + "places": { + "Babylon": "بابل", + "Jerusalem": "أورشليم", + "Bethlehem": "بيت لحم", + "Nazareth": "ناصرة", + "Galilee": "الجليل", + "Judea": "اليهودية", + "Egypt": "مصر", + "Corinth": "كورنثوس", + "Ephesus": "أفسس", + "Rome": "رومية", + "Zion": "صهيون", + "Jordan": "الأردن", + "Bethany": "بيت عنيا", + "Athens": "أثينا" + } +} \ No newline at end of file diff --git a/app/bible/lexicon-fra.json b/app/bible/lexicon-fra.json new file mode 100644 index 0000000..28d51a8 --- /dev/null +++ b/app/bible/lexicon-fra.json @@ -0,0 +1,97 @@ +{ + "language": "fra_Latn", + "reference": "Louis Segond 1910 (public domain)", + "_verified": "Name forms read directly from Louis Segond text: Matthieu 1:1-17 (the genealogy, ~47 names in fixed order) and Matthieu 10:2-4 (the twelve apostles), fetched from BibleGateway. Nothing here is from memory.", + "_measured": "Checked NLLB's French output against those forms for 51 names: 48 correct, 3 wrong. The English-side entries below are INSURANCE, not corrections — NLLB is inconsistent by context (in Spanish it renders 'Turn to John' correctly but leaves 'His name was John' in English), so pinning the English form costs nothing and closes that gap.", + "confidence": "high", + + "books": { + "Genesis": "Genèse", "Exodus": "Exode", "Leviticus": "Lévitique", + "Numbers": "Nombres", "Deuteronomy": "Deutéronome", "Joshua": "Josué", + "Judges": "Juges", "Ruth": "Ruth", "Samuel": "Samuel", "Kings": "Rois", + "Chronicles": "Chroniques", "Ezra": "Esdras", "Nehemiah": "Néhémie", + "Esther": "Esther", "Job": "Job", "Psalms": "Psaumes", "Psalm": "Psaume", + "Proverbs": "Proverbes", "Ecclesiastes": "Ecclésiaste", + "Song of Solomon": "Cantique des cantiques", + "Isaiah": "Ésaïe", "Jeremiah": "Jérémie", "Lamentations": "Lamentations", + "Ezekiel": "Ézéchiel", "Daniel": "Daniel", "Hosea": "Osée", "Joel": "Joël", + "Amos": "Amos", "Obadiah": "Abdias", "Jonah": "Jonas", "Micah": "Michée", + "Nahum": "Nahum", "Habakkuk": "Habacuc", "Zephaniah": "Sophonie", + "Haggai": "Aggée", "Zechariah": "Zacharie", "Malachi": "Malachie", + "Matthew": "Matthieu", "Mark": "Marc", "Luke": "Luc", "John": "Jean", + "Acts": "Actes", "Romans": "Romains", "Corinthians": "Corinthiens", + "Galatians": "Galates", "Ephesians": "Éphésiens", "Philippians": "Philippiens", + "Colossians": "Colossiens", "Thessalonians": "Thessaloniciens", + "Timothy": "Timothée", "Titus": "Tite", "Philemon": "Philémon", + "Hebrews": "Hébreux", "James": "Jacques", "Peter": "Pierre", + "Jude": "Jude", "Revelation": "Apocalypse" + }, + + "people": { + "Jesus": "Jésus", "Jesus Christ": "Jésus-Christ", "Christ": "Christ", + "Holy Spirit": "Saint-Esprit", + "Adam": "Adam", "Eve": "Ève", "Noah": "Noé", "Abraham": "Abraham", + "Sarah": "Sara", "Isaac": "Isaac", "Jacob": "Jacob", "Esau": "Ésaü", + "Joseph": "Joseph", "Moses": "Moïse", "Aaron": "Aaron", "Joshua": "Josué", + "Gideon": "Gédéon", "Samson": "Samson", "Samuel": "Samuel", "Saul": "Saül", + "David": "David", "Solomon": "Salomon", "Elijah": "Élie", "Elisha": "Élisée", + "Isaiah": "Ésaïe", "Jeremiah": "Jérémie", "Ezekiel": "Ézéchiel", + "Daniel": "Daniel", "Jonah": "Jonas", "Nehemiah": "Néhémie", "Ezra": "Esdras", + "Esther": "Esther", "Ruth": "Ruth", "Naomi": "Noémi", "Boaz": "Boaz", + "Mary": "Marie", "Martha": "Marthe", "Elizabeth": "Élisabeth", + "John the Baptist": "Jean-Baptiste", "Simon Peter": "Simon Pierre", + "Simon": "Simon", "Peter": "Pierre", "Andrew": "André", "John": "Jean", + "Philip": "Philippe", "Bartholomew": "Barthélemy", "Thomas": "Thomas", + "Matthew": "Matthieu", "Thaddaeus": "Thaddée", + "Judas Iscariot": "Judas l'Iscariot", "Matthias": "Matthias", + "Paul": "Paul", "Barnabas": "Barnabas", "Silas": "Silas", + "Timothy": "Timothée", "Titus": "Tite", "Stephen": "Étienne", + "Luke": "Luc", "Mark": "Marc", "Apollos": "Apollos", "Priscilla": "Priscille", + "Lazarus": "Lazare", "Nicodemus": "Nicodème", "Zacchaeus": "Zachée", + "Pilate": "Pilate", "Herod": "Hérode", "Caesar": "César", + "Cornelius": "Corneille", "Lydia": "Lydie", "Tamar": "Thamar", + "Rahab": "Rahab", "Hezekiah": "Ézéchias", "Josiah": "Josias", + "Manasseh": "Manassé", "Zerubbabel": "Zorobabel" + }, + + "places": { + "Jerusalem": "Jérusalem", "Bethlehem": "Bethléhem", "Nazareth": "Nazareth", + "Galilee": "Galilée", "Judea": "Judée", "Samaria": "Samarie", + "Jericho": "Jéricho", "Bethany": "Béthanie", "Capernaum": "Capernaüm", + "Gethsemane": "Gethsémané", "Golgotha": "Golgotha", "Calvary": "Calvaire", + "Mount Sinai": "mont Sinaï", "Mount of Olives": "mont des Oliviers", + "Egypt": "Égypte", "Babylon": "Babylone", "Assyria": "Assyrie", + "Damascus": "Damas", "Antioch": "Antioche", "Corinth": "Corinthe", + "Ephesus": "Éphèse", "Philippi": "Philippes", "Thessalonica": "Thessalonique", + "Colossae": "Colosses", "Galatia": "Galatie", "Rome": "Rome", + "Athens": "Athènes", "Macedonia": "Macédoine", "Patmos": "Patmos", + "Zion": "Sion", "Canaan": "Canaan", "Jordan River": "Jourdain", + "Red Sea": "mer Rouge", "Sea of Galilee": "mer de Galilée", "Eden": "Éden" + }, + + "terms": { + "gospel": "évangile", "covenant": "alliance", "apostle": "apôtre", + "disciple": "disciple", "Pharisee": "pharisien", "Sadducee": "sadducéen", + "Gentile": "païen", "Sabbath": "sabbat", "Passover": "Pâque", + "Pentecost": "Pentecôte", "Messiah": "Messie", "salvation": "salut", + "grace": "grâce", "mercy": "miséricorde", "repentance": "repentance", + "redemption": "rédemption", "atonement": "expiation", + "righteousness": "justice", "justification": "justification", + "sanctification": "sanctification", "resurrection": "résurrection", + "crucifixion": "crucifixion", "ascension": "ascension", + "kingdom of God": "royaume de Dieu", "kingdom of heaven": "royaume des cieux", + "parable": "parabole", "prophet": "prophète", "prophecy": "prophétie", + "Scripture": "Écriture", "the Word": "la Parole", "tabernacle": "tabernacle", + "temple": "temple", "altar": "autel", "sacrifice": "sacrifice", + "priest": "sacrificateur", "high priest": "souverain sacrificateur", + "baptism": "baptême", "the Lord's Supper": "la Cène", + "eternal life": "vie éternelle", "Satan": "Satan", "the devil": "le diable" + }, + + "_target_fixes": { + "_note": "TARGET-side corrections — the only three names NLLB got wrong in French, measured. Narrow and safe: none of these strings is an ordinary French word. 'Stéphanas' is not a variant of Étienne at all, it is a DIFFERENT biblical person (1 Cor 1:16), so leaving it would name the wrong man. 'Isaïe' is the Catholic/Jerusalem form; Segond and the Protestant tradition use Ésaïe, and this church is Protestant.", + "Éliya": "Élie", + "Stéphanas": "Étienne", + "Isaïe": "Ésaïe" + } +} diff --git a/app/bible/lexicon-hat.json b/app/bible/lexicon-hat.json new file mode 100644 index 0000000..49ef919 --- /dev/null +++ b/app/bible/lexicon-hat.json @@ -0,0 +1,105 @@ +{ + "language": "hat_Latn", + "reference": "Bib La, Haitian Creole Version (HAT)", + "_verified": "Verified from the full ebible.org Bib La corpus. SAUL OMITTED: Haitian splits it by person, Sayil (the king) vs Sòl (of Tarsus). Note Jesse=Izayi and Isaiah=Ezayi differ by one letter — distinct English keys, so no collision here, but do not hand-edit them carelessly.", + "confidence": "high — every form read from published text and grepped back against the source corpus", + "people": { + "Jesus": "Jezi", + "Christ": "Kris", + "David": "David", + "Abraham": "Abraram", + "Isaac": "Izarak", + "Jacob": "Jakòb", + "Judah": "Jida", + "Tamar": "Tama", + "Perez": "Perèz", + "Zerah": "Zara", + "Hezron": "Eswòm", + "Amminadab": "Amenadab", + "Nahshon": "Naason", + "Salmon": "Salmon", + "Boaz": "Boaz", + "Rahab": "Raab", + "Obed": "Obèd", + "Ruth": "Rit", + "Solomon": "Salomon", + "Uriah": "Ouri", + "Rehoboam": "Woboram", + "Abijah": "Abya", + "Asa": "Asa", + "Jehoshaphat": "Jozafa", + "Joram": "Joram", + "Uzziah": "Ozyas", + "Jotham": "Joatam", + "Ahaz": "Akaz", + "Hezekiah": "Ezekyas", + "Manasseh": "Manase", + "Amon": "Amon", + "Josiah": "Jozyas", + "Jechoniah": "Jekonyas", + "Shealtiel": "Salatyèl", + "Zerubbabel": "Zowobabèl", + "Abiud": "Abiyoud", + "Eliakim": "Elyakim", + "Azor": "Azò", + "Zadok": "Sadòk", + "Achim": "Akim", + "Eliud": "Eliyoud", + "Eleazar": "Eleaza", + "Matthan": "Matan", + "Joseph": "Jozèf", + "Mary": "Mari", + "Simon": "Simon", + "Peter": "Pyè", + "Andrew": "Andre", + "James": "Jak", + "Zebedee": "Zebede", + "John": "Jan", + "Philip": "Filip", + "Bartholomew": "Batèlmi", + "Thomas": "Toma", + "Matthew": "Matye", + "Alphaeus": "Alfe", + "Thaddaeus": "Tade", + "Judas": "Jida", + "Moses": "Moyiz", + "Elijah": "Eli", + "Ahab": "Akab", + "Isaiah": "Ezayi", + "Jeremiah": "Jeremi", + "Daniel": "Danyèl", + "Noah": "Noe", + "Adam": "Adan", + "Eve": "Ev", + "Joshua": "Jozye", + "Samuel": "Samyèl", + "Paul": "Pòl", + "Stephen": "Etyèn", + "Barnabas": "Banabas", + "Silas": "Silas", + "Timothy": "Timote", + "Titus": "Tit", + "Luke": "Lik", + "Mark": "Mak", + "Lazarus": "Laza", + "Nicodemus": "Nikodèm", + "Pilate": "Pilat", + "Herod": "Ewòd" + }, + "places": { + "Babylon": "Babilòn", + "Jerusalem": "Jerizalèm", + "Bethlehem": "Betleyèm", + "Nazareth": "Nazarèt", + "Galilee": "Galile", + "Judea": "Jide", + "Egypt": "Lejip", + "Corinth": "Korent", + "Ephesus": "Efèz", + "Rome": "Wòm", + "Zion": "Siyon", + "Jordan": "Jouden", + "Bethany": "Betani", + "Athens": "Atèn" + } +} \ No newline at end of file diff --git a/app/bible/lexicon-hin.json b/app/bible/lexicon-hin.json new file mode 100644 index 0000000..4290070 --- /dev/null +++ b/app/bible/lexicon-hin.json @@ -0,0 +1,99 @@ +{ + "language": "hin_Deva", + "reference": "IRV Hindi 2019", + "_verified": "Verified from the ebible.org IRV corpus. SIX NAMES DELIBERATELY OMITTED because IRV and the older HHBD/Old Version disagree on them — Abraham (अब्राहम vs इब्राहीम), Perez, Zerah, Hezron, Manasseh, Uzziah. If this congregation reads the Old Version, IRV's forms would look wrong to them, so only the forms both editions share are pinned. Ask before adding the six.", + "confidence": "high — every form read from published text and grepped back against the source corpus", + "people": { + "Jesus": "यीशु", + "Christ": "मसीह", + "David": "दाऊद", + "Isaac": "इसहाक", + "Jacob": "याकूब", + "Judah": "यहूदा", + "Tamar": "तामार", + "Amminadab": "अम्मीनादाब", + "Nahshon": "नहशोन", + "Salmon": "सलमोन", + "Boaz": "बोअज", + "Rahab": "राहाब", + "Obed": "ओबेद", + "Ruth": "रूत", + "Jesse": "यिशै", + "Solomon": "सुलैमान", + "Uriah": "ऊरिय्याह", + "Rehoboam": "रहबाम", + "Abijah": "अबिय्याह", + "Asa": "आसा", + "Jehoshaphat": "यहोशाफात", + "Joram": "योराम", + "Jotham": "योताम", + "Ahaz": "आहाज", + "Hezekiah": "हिजकिय्याह", + "Amon": "आमोन", + "Josiah": "योशिय्याह", + "Jechoniah": "यकुन्याह", + "Shealtiel": "शालतीएल", + "Zerubbabel": "जरुब्बाबेल", + "Abiud": "अबीहूद", + "Eliakim": "एलयाकीम", + "Azor": "अजोर", + "Zadok": "सादोक", + "Achim": "अखीम", + "Eliud": "एलीहूद", + "Eleazar": "एलीआजर", + "Matthan": "मत्तान", + "Joseph": "यूसुफ", + "Mary": "मरियम", + "Simon": "शमौन", + "Peter": "पतरस", + "Andrew": "अन्द्रियास", + "James": "याकूब", + "Zebedee": "जब्दी", + "John": "यूहन्ना", + "Philip": "फिलिप्पुस", + "Bartholomew": "बरतुल्मै", + "Thomas": "थोमा", + "Matthew": "मत्ती", + "Alphaeus": "हलफईस", + "Thaddaeus": "तद्दै", + "Judas": "यहूदा", + "Moses": "मूसा", + "Elijah": "एलिय्याह", + "Ahab": "अहाब", + "Isaiah": "यशायाह", + "Jeremiah": "यिर्मयाह", + "Daniel": "दानिय्येल", + "Noah": "नूह", + "Adam": "आदम", + "Eve": "हव्वा", + "Joshua": "यहोशू", + "Samuel": "शमूएल", + "Saul": "शाऊल", + "Paul": "पौलुस", + "Stephen": "स्तिफनुस", + "Barnabas": "बरनबास", + "Silas": "सीलास", + "Timothy": "तीमुथियुस", + "Titus": "तीतुस", + "Luke": "लूका", + "Mark": "मरकुस", + "Lazarus": "लाज़र", + "Nicodemus": "नीकुदेमुस", + "Pilate": "पिलातुस", + "Herod": "हेरोदेस" + }, + "places": { + "Jerusalem": "यरूशलेम", + "Bethlehem": "बैतलहम", + "Nazareth": "नासरत", + "Galilee": "गलील", + "Judea": "यहूदिया", + "Egypt": "मिस्र", + "Corinth": "कुरिन्थुस", + "Ephesus": "इफिसुस", + "Rome": "रोम", + "Zion": "सिय्योन", + "Jordan": "यरदन", + "Bethany": "बैतनिय्याह" + } +} \ No newline at end of file diff --git a/app/bible/lexicon-kor.json b/app/bible/lexicon-kor.json new file mode 100644 index 0000000..1feb35a --- /dev/null +++ b/app/bible/lexicon-kor.json @@ -0,0 +1,29 @@ +{ + "language": "kor_Hang", + "reference": "Korean Bible — apostle forms verified against Korean Living Bible (KLB) text", + "_verified": "Read directly from KLB Matthew 10:2-4 on BibleGateway. These proper-noun forms are shared with 개역개정 (the dominant Korean Protestant edition); KLB was used because it was the accessible text. Flagged as such rather than claimed as 개역개정.", + "_measured": "The archive holds only 6 Korean rows, so nothing could be measured. The English-side table is therefore insurance, not a correction of anything observed.", + "confidence": "medium — forms verified from text, but the edition is a paraphrase and no archive data exists to confirm what NLLB actually does in Korean", + "people": { + "Jesus": "예수", + "Christ": "그리스도", + "Simon": "시몬", + "Peter": "베드로", + "Andrew": "안드레", + "James": "야고보", + "John": "요한", + "Philip": "빌립", + "Bartholomew": "바돌로매", + "Thomas": "도마", + "Matthew": "마태", + "Thaddaeus": "다대오", + "Judas": "유다", + "Zebedee": "세베대", + "Alphaeus": "알패오" + }, + "_target_fixes": { + "_note": "Predicted from the earlier probe, which showed NLLB producing the English-phonetic 매튜 rather than the Bible form 마태. NOT confirmed against archive data — there are only 6 Korean rows.", + "_removed_jon": "존 -> 요한 was written and then REMOVED. 존 is a SINGLE Hangul syllable, and termMatcher guards 1-character CJK terms with a lookaround precisely so they cannot match inside a word — unguarded it would corrupt 존재 (existence) and 존경 (respect). Guarded, it can never fire in running Korean, because the next character is always Hangul. So the entry would have shipped as a silent no-op. A 1-character CJK correction is not expressible with this matcher; it needs longer context (e.g. 존이 / 존은) to be both safe and effective.", + "매튜": "마태" + } +} \ No newline at end of file diff --git a/app/bible/lexicon-por.json b/app/bible/lexicon-por.json new file mode 100644 index 0000000..01d4200 --- /dev/null +++ b/app/bible/lexicon-por.json @@ -0,0 +1,106 @@ +{ + "language": "por_Latn", + "reference": "João Ferreira de Almeida Revista e Corrigida 2009 (ARC)", + "_verified": "Verified against BibleGateway ARC. RAM OMITTED: ARC renders Ram as 'Arão', which is the SAME spelling it uses for Aaron — the map would conflate two people. SAUL OMITTED: Portuguese splits it by person, Saul (the king) vs Saulo (of Tarsus), and one key cannot carry both. ARA is a different revision and was NOT checked.", + "confidence": "high — every form read from published text and grepped back against the source corpus", + "people": { + "Jesus": "Jesus", + "Christ": "Cristo", + "David": "Davi", + "Abraham": "Abraão", + "Isaac": "Isaque", + "Jacob": "Jacó", + "Judah": "Judá", + "Tamar": "Tamar", + "Perez": "Perez", + "Zerah": "Zerá", + "Hezron": "Esrom", + "Amminadab": "Aminadabe", + "Nahshon": "Naassom", + "Salmon": "Salmom", + "Boaz": "Boaz", + "Rahab": "Raabe", + "Obed": "Obede", + "Ruth": "Rute", + "Jesse": "Jessé", + "Solomon": "Salomão", + "Uriah": "Urias", + "Rehoboam": "Roboão", + "Abijah": "Abias", + "Asa": "Asa", + "Jehoshaphat": "Josafá", + "Joram": "Jorão", + "Uzziah": "Uzias", + "Jotham": "Jotão", + "Ahaz": "Acaz", + "Hezekiah": "Ezequias", + "Manasseh": "Manassés", + "Amon": "Amom", + "Josiah": "Josias", + "Jechoniah": "Jeconias", + "Shealtiel": "Salatiel", + "Zerubbabel": "Zorobabel", + "Abiud": "Abiúde", + "Eliakim": "Eliaquim", + "Azor": "Azor", + "Zadok": "Sadoque", + "Achim": "Aquim", + "Eliud": "Eliúde", + "Eleazar": "Eleazar", + "Matthan": "Matã", + "Joseph": "José", + "Mary": "Maria", + "Simon": "Simão", + "Peter": "Pedro", + "Andrew": "André", + "James": "Tiago", + "Zebedee": "Zebedeu", + "John": "João", + "Philip": "Filipe", + "Bartholomew": "Bartolomeu", + "Thomas": "Tomé", + "Matthew": "Mateus", + "Alphaeus": "Alfeu", + "Thaddaeus": "Tadeu", + "Judas": "Judas", + "Moses": "Moisés", + "Elijah": "Elias", + "Ahab": "Acabe", + "Isaiah": "Isaías", + "Jeremiah": "Jeremias", + "Daniel": "Daniel", + "Noah": "Noé", + "Adam": "Adão", + "Eve": "Eva", + "Joshua": "Josué", + "Samuel": "Samuel", + "Paul": "Paulo", + "Stephen": "Estêvão", + "Barnabas": "Barnabé", + "Silas": "Silas", + "Timothy": "Timóteo", + "Titus": "Tito", + "Luke": "Lucas", + "Mark": "Marcos", + "Lazarus": "Lázaro", + "Nicodemus": "Nicodemos", + "Pilate": "Pilatos", + "Herod": "Herodes" + }, + "places": { + "Babylon": "Babilônia", + "Jerusalem": "Jerusalém", + "Bethlehem": "Belém", + "Nazareth": "Nazaré", + "Galilee": "Galileia", + "Judea": "Judeia", + "Egypt": "Egito", + "Corinth": "Corinto", + "Ephesus": "Éfeso", + "Rome": "Roma", + "Zion": "Sião", + "Jordan": "Jordão", + "Bethany": "Betânia", + "Athens": "Atenas" + } +} \ No newline at end of file diff --git a/app/bible/lexicon-por_PT.json b/app/bible/lexicon-por_PT.json new file mode 100644 index 0000000..45cf6b5 --- /dev/null +++ b/app/bible/lexicon-por_PT.json @@ -0,0 +1,8 @@ +{ + "language": "por_PT", + "inherits": "por_Latn", + "reference": "Almeida Revista e Corrigida, Portugal — divergences from the Brazilian table only", + "_verified": "Portugal keeps 'David' where Brazil writes 'Davi'. Measured: the shared table fired David->Davi 6 times on real output, which would be wrong for a Portuguese congregation. Listing it as identical to the English removes the entry rather than replacing it, so the name is simply left alone — which is already correct European Portuguese.", + "_gap": "This file contains ONE verified divergence. Others certainly exist (European Portuguese also differs on Egipto/Egito and Babilónia/Babilônia, though those are handled by pt-variants.js orthography). Nothing else was verified against a Portugal edition, so nothing else is claimed.", + "overrides": { "David": "David" } +} diff --git a/app/bible/lexicon-spa.json b/app/bible/lexicon-spa.json new file mode 100644 index 0000000..b41cf68 --- /dev/null +++ b/app/bible/lexicon-spa.json @@ -0,0 +1,123 @@ +{ + "language": "spa_Latn", + "reference": "Reina-Valera 1960 (RVR1960)", + "_copyright": "RVR1960 verse TEXT is copyright Sociedades Biblicas Unidas and is NOT included here or anywhere in this app. This file contains only proper nouns and book titles, which are naming conventions shared across Spanish Bibles and are not copyrightable subject matter. If verse text is ever used, it is for offline validation only and never ships.", + "_use": "Applied to NLLB's SPANISH OUTPUT, and only to rescue an English form that survived untranslated (measured: 'Se llamaba John.'). It does NOT renormalize names NLLB already localized -- that would fight the model and risk breaking inflection.", + "confidence": "high", + + "books": { + "Genesis": "Génesis", "Exodus": "Éxodo", "Leviticus": "Levítico", + "Numbers": "Números", "Deuteronomy": "Deuteronomio", "Joshua": "Josué", + "Judges": "Jueces", "Ruth": "Rut", "Samuel": "Samuel", "Kings": "Reyes", + "Chronicles": "Crónicas", "Ezra": "Esdras", "Nehemiah": "Nehemías", + "Esther": "Ester", "Job": "Job", "Psalms": "Salmos", "Psalm": "Salmo", + "Proverbs": "Proverbios", "Ecclesiastes": "Eclesiastés", + "Song of Solomon": "Cantares", "Song of Songs": "Cantares", + "Isaiah": "Isaías", "Jeremiah": "Jeremías", "Lamentations": "Lamentaciones", + "Ezekiel": "Ezequiel", "Daniel": "Daniel", "Hosea": "Oseas", "Joel": "Joel", + "Amos": "Amós", "Obadiah": "Abdías", "Jonah": "Jonás", "Micah": "Miqueas", + "Nahum": "Nahúm", "Habakkuk": "Habacuc", "Zephaniah": "Sofonías", + "Haggai": "Hageo", "Zechariah": "Zacarías", "Malachi": "Malaquías", + "Matthew": "Mateo", "Mark": "Marcos", "Luke": "Lucas", "John": "Juan", + "Acts": "Hechos", "Romans": "Romanos", "Corinthians": "Corintios", + "Galatians": "Gálatas", "Ephesians": "Efesios", "Philippians": "Filipenses", + "Colossians": "Colosenses", "Thessalonians": "Tesalonicenses", + "Timothy": "Timoteo", "Titus": "Tito", "Philemon": "Filemón", + "Hebrews": "Hebreos", "James": "Santiago", "Peter": "Pedro", + "Jude": "Judas", "Revelation": "Apocalipsis" + }, + + "_books_note": "RVR1960 titles. 'Revelation' is Apocalipsis, never 'Revelación'. Song of Solomon is Cantares. Numbered books (1 Samuel, 2 Corinthians) are handled by the stem, since the numeral passes through untouched.", + + "people": { + "Jesus": "Jesús", "Jesus Christ": "Jesucristo", "Christ": "Cristo", + "Holy Spirit": "Espíritu Santo", "Son of God": "Hijo de Dios", + "Adam": "Adán", "Eve": "Eva", "Noah": "Noé", + "Abraham": "Abraham", "Sarah": "Sara", "Isaac": "Isaac", "Rebekah": "Rebeca", + "Jacob": "Jacob", "Esau": "Esaú", "Rachel": "Raquel", "Leah": "Lea", + "Joseph": "José", "Moses": "Moisés", "Aaron": "Aarón", "Miriam": "María", + "Joshua": "Josué", "Caleb": "Caleb", "Gideon": "Gedeón", "Samson": "Sansón", + "Samuel": "Samuel", "Saul": "Saúl", "David": "David", "Solomon": "Salomón", + "Elijah": "Elías", "Elisha": "Eliseo", "Isaiah": "Isaías", + "Jeremiah": "Jeremías", "Ezekiel": "Ezequiel", "Daniel": "Daniel", + "Jonah": "Jonás", "Nehemiah": "Nehemías", "Ezra": "Esdras", + "Esther": "Ester", "Ruth": "Rut", "Naomi": "Noemí", "Boaz": "Booz", + "Mary": "María", "Martha": "Marta", "Elizabeth": "Elisabet", + "John the Baptist": "Juan el Bautista", "Simon Peter": "Simón Pedro", + "Simon": "Simón", "Peter": "Pedro", "Andrew": "Andrés", + "John": "Juan", "Philip": "Felipe", "Bartholomew": "Bartolomé", + "Thomas": "Tomás", "Matthew": "Mateo", "Thaddaeus": "Tadeo", + "Judas Iscariot": "Judas Iscariote", "Matthias": "Matías", + "Paul": "Pablo", "Barnabas": "Bernabé", "Silas": "Silas", + "Timothy": "Timoteo", "Titus": "Tito", "Stephen": "Esteban", + "Luke": "Lucas", "Mark": "Marcos", "Apollos": "Apolos", + "Priscilla": "Priscila", "Aquila": "Aquila", "Lazarus": "Lázaro", + "Nicodemus": "Nicodemo", "Zacchaeus": "Zaqueo", "Pilate": "Pilato", + "Herod": "Herodes", "Caesar": "César", "Cornelius": "Cornelio", + "Lydia": "Lidia", "Jehovah": "Jehová" + }, + + "_people_note": "RVR1960 renders Moses' sister Miriam as Maria, and Elizabeth as Elisabet (no -h). Jehovah is included because RVR1960 uses Jehova for the divine name where other Spanish versions use SENOR -- but plain 'Lord' still maps to Senor, not Jehova, since a preacher saying 'the Lord' is not invoking the tetragrammaton.", + + "_james_problem": "RVR1960 splits English 'James': the EPISTLE is Santiago, but the APOSTLE (son of Zebedee) is Jacobo. One English word, two correct Spanish words, decided by context this app does not reliably have. 'James' is therefore in books{} only -- a book reference is the far more common use in preaching ('the book of James'). A bare personal 'James' is left to NLLB rather than guessed wrong.", + + "places": { + "Jerusalem": "Jerusalén", "Bethlehem": "Belén", "Nazareth": "Nazaret", + "Galilee": "Galilea", "Judea": "Judea", "Samaria": "Samaria", + "Jericho": "Jericó", "Bethany": "Betania", "Capernaum": "Capernaum", + "Gethsemane": "Getsemaní", "Golgotha": "Gólgota", "Calvary": "Calvario", + "Mount Sinai": "monte Sinaí", "Mount of Olives": "monte de los Olivos", + "Egypt": "Egipto", "Babylon": "Babilonia", "Assyria": "Asiria", + "Damascus": "Damasco", "Antioch": "Antioquía", "Corinth": "Corinto", + "Ephesus": "Éfeso", "Philippi": "Filipos", "Thessalonica": "Tesalónica", + "Colossae": "Colosas", "Galatia": "Galacia", "Rome": "Roma", + "Athens": "Atenas", "Macedonia": "Macedonia", "Patmos": "Patmos", + "Zion": "Sion", "Canaan": "Canaán", "Jordan River": "río Jordán", + "Red Sea": "Mar Rojo", "Sea of Galilee": "mar de Galilea", "Eden": "Edén" + }, + + "terms": { + "gospel": "evangelio", "covenant": "pacto", "new covenant": "nuevo pacto", + "apostle": "apóstol", "disciple": "discípulo", "Pharisee": "fariseo", + "Sadducee": "saduceo", "Gentile": "gentil", "Sabbath": "día de reposo", + "Passover": "pascua", "Pentecost": "Pentecostés", "Messiah": "Mesías", + "salvation": "salvación", "grace": "gracia", "mercy": "misericordia", + "repentance": "arrepentimiento", "redemption": "redención", + "atonement": "expiación", "righteousness": "justicia", + "justification": "justificación", "sanctification": "santificación", + "propitiation": "propiciación", "resurrection": "resurrección", + "crucifixion": "crucifixión", "ascension": "ascensión", + "kingdom of God": "reino de Dios", "kingdom of heaven": "reino de los cielos", + "parable": "parábola", "prophet": "profeta", "prophecy": "profecía", + "epistle": "epístola", "Scripture": "Escritura", "the Word": "el Verbo", + "tabernacle": "tabernáculo", "temple": "templo", "altar": "altar", + "sacrifice": "sacrificio", "priest": "sacerdote", "high priest": "sumo sacerdote", + "baptism": "bautismo", "the Lord's Supper": "la Cena del Señor", + "eternal life": "vida eterna", "Satan": "Satanás", "the devil": "el diablo", + "cherubim": "querubines", "seraphim": "serafines" + }, + + "_terms_note": "RVR1960 distinctives kept deliberately: Sabbath is 'dia de reposo' (not 'sabado'), and the Word of John 1:1 is 'el Verbo' (not 'la Palabra'). Ordinary vocabulary that NLLB already translates correctly and unambiguously (faith, hope, love, sin, heaven, hell, angel) is omitted -- pinning those would add risk with no benefit.", + + "liturgical": { + "_use": "Matched as a WHOLE UTTERANCE only, bypassing NLLB entirely. Measured: 'Amen.' currently becomes '- Si, estoy bien.' = 'Yes, I am fine.'", + "amen": "Amén.", + "hallelujah": "¡Aleluya!", + "alleluia": "¡Aleluya!", + "hosanna": "¡Hosanna!", + "praise god": "¡Alabado sea Dios!", + "praise the lord": "¡Alabado sea el Señor!", + "glory to god": "¡Gloria a Dios!", + "thank you jesus": "¡Gracias, Jesús!", + "god is good": "Dios es bueno.", + "god bless you": "Que Dios le bendiga.", + "let us pray": "Oremos.", + "in jesus name": "En el nombre de Jesús.", + "in jesus' name": "En el nombre de Jesús.", + "good morning": "Buenos días.", + "good evening": "Buenas noches.", + "welcome": "Bienvenidos.", + "open your bibles": "Abran sus Biblias.", + "turn with me": "Vayan conmigo." + } +} diff --git a/app/bible/lexicon-vie.json b/app/bible/lexicon-vie.json new file mode 100644 index 0000000..205d0bc --- /dev/null +++ b/app/bible/lexicon-vie.json @@ -0,0 +1,106 @@ +{ + "language": "vie_Latn", + "reference": "Kinh Thánh 1925/1926 Bản Truyền Thống (VIE1925/BTT)", + "_verified": "Verified against bible.com VIE1925 and wordproject. JESSE OMITTED: this edition uses two different forms — Y-sai in 1 Samuel 16:1 but Gie-sê in Matthew 1:5-6 — so any single mapping is wrong in one testament. NOTE ebible.org's vie1934 is ENCODING-CORRUPT (Á appears 0 times, Aù 680) and must never be used.", + "confidence": "high — every form read from published text and grepped back against the source corpus", + "people": { + "Jesus": "Jêsus", + "Christ": "Christ", + "David": "Đa-vít", + "Abraham": "Áp-ra-ham", + "Isaac": "Y-sác", + "Jacob": "Gia-cốp", + "Judah": "Giu-đa", + "Tamar": "Tha-ma", + "Perez": "Pha-rê", + "Zerah": "Xa-ra", + "Hezron": "Ếch-rôm", + "Amminadab": "A-mi-na-đáp", + "Nahshon": "Na-ách-son", + "Salmon": "Sanh-môn", + "Boaz": "Bô-ô", + "Rahab": "Ra-háp", + "Obed": "Ô-bết", + "Ruth": "Ru-tơ", + "Solomon": "Sa-lô-môn", + "Uriah": "U-ri", + "Rehoboam": "Rô-bô-am", + "Abijah": "A-bi-gia", + "Asa": "A-sa", + "Jehoshaphat": "Giô-sa-phát", + "Joram": "Giô-ram", + "Uzziah": "Ô-xia", + "Jotham": "Giô-tam", + "Ahaz": "A-cha", + "Hezekiah": "Ê-xê-chia", + "Manasseh": "Ma-na-sê", + "Amon": "A-môn", + "Josiah": "Giô-si-a", + "Jechoniah": "Giê-chô-nia", + "Shealtiel": "Sa-la-thi-ên", + "Zerubbabel": "Xô-rô-ba-bên", + "Abiud": "A-bi-út", + "Eliakim": "Ê-li-a-kim", + "Azor": "A-xô", + "Zadok": "Sa-đốc", + "Achim": "A-chim", + "Eliud": "Ê-li-út", + "Eleazar": "Ê-lê-a-xa", + "Matthan": "Ma-than", + "Joseph": "Giô-sép", + "Mary": "Ma-ri", + "Simon": "Si-môn", + "Peter": "Phi-e-rơ", + "Andrew": "Anh-rê", + "James": "Gia-cơ", + "Zebedee": "Xê-bê-đê", + "John": "Giăng", + "Philip": "Phi-líp", + "Bartholomew": "Ba-tê-lê-my", + "Thomas": "Thô-ma", + "Matthew": "Ma-thi-ơ", + "Alphaeus": "A-phê", + "Thaddaeus": "Tha-đê", + "Judas": "Giu-đa", + "Moses": "Môi-se", + "Elijah": "Ê-li", + "Ahab": "A-háp", + "Isaiah": "Ê-sai", + "Jeremiah": "Giê-rê-mi", + "Daniel": "Đa-ni-ên", + "Noah": "Nô-ê", + "Adam": "A-đam", + "Eve": "Ê-va", + "Joshua": "Giô-suê", + "Samuel": "Sa-mu-ên", + "Saul": "Sau-lơ", + "Paul": "Phao-lô", + "Stephen": "Ê-tiên", + "Barnabas": "Ba-na-ba", + "Silas": "Si-la", + "Timothy": "Ti-mô-thê", + "Titus": "Tít", + "Luke": "Lu-ca", + "Mark": "Mác", + "Lazarus": "La-xa-rơ", + "Nicodemus": "Ni-cô-đem", + "Pilate": "Phi-lát", + "Herod": "Hê-rốt" + }, + "places": { + "Babylon": "Ba-by-lôn", + "Jerusalem": "Giê-ru-sa-lem", + "Bethlehem": "Bết-lê-hem", + "Nazareth": "Na-xa-rét", + "Galilee": "Ga-li-lê", + "Judea": "Giu-đê", + "Egypt": "Ê-díp-tô", + "Corinth": "Cô-rinh-tô", + "Ephesus": "Ê-phê-sô", + "Rome": "Rô-ma", + "Zion": "Si-ôn", + "Jordan": "Giô-đanh", + "Bethany": "Bê-tha-ni", + "Athens": "A-thên" + } +} \ No newline at end of file diff --git a/app/bible/lexicon-zho.json b/app/bible/lexicon-zho.json new file mode 100644 index 0000000..2cd2d75 --- /dev/null +++ b/app/bible/lexicon-zho.json @@ -0,0 +1,28 @@ +{ + "language": "zho_Hans", + "reference": "和合本 Chinese Union Version, Simplified (CUVS)", + "_verified": "Forms read directly from CUVS text on BibleGateway: Matthew 10:2-4 (the twelve apostles) and Matthew 1:1-6 (the genealogy). Nothing from memory. BibleGateway typesets CUV with a space between every character; the spaces were stripped.", + "_measured": "Checked against 4,636 real Chinese rows in this church's own transcript archive. ZERO English names survive in Chinese output, so the English-side table below is pure insurance. The real defects are target-side and there are exactly two.", + "_god": "The archive shows NLLB already rendering God as 上帝, which matches the 上帝版 edition of the Union Version. No change made — flagging it because the CUV ships in both a 神版 and a 上帝版 and they differ on exactly this.", + "confidence": "high", + + "people": { + "Jesus": "耶稣", "Jesus Christ": "耶稣基督", "Christ": "基督", + "Simon": "西门", "Simon Peter": "西门彼得", "Peter": "彼得", + "Andrew": "安得烈", "James": "雅各", "John": "约翰", "Philip": "腓力", + "Bartholomew": "巴多罗买", "Thomas": "多马", "Matthew": "马太", + "Thaddaeus": "达太", "Judas": "犹大", "Zebedee": "西庇太", + "Abraham": "亚伯拉罕", "David": "大卫", "Isaac": "以撒", "Jacob": "雅各", + "Judah": "犹大", "Tamar": "他玛", "Perez": "法勒斯", "Zerah": "谢拉", + "Hezron": "希斯仑", "Amminadab": "亚米拿达", "Nahshon": "拿顺", + "Salmon": "撒门", "Rahab": "喇合", "Boaz": "波阿斯", "Ruth": "路得", + "Obed": "俄备得", "Jesse": "耶西", "Uriah": "乌利亚", "Solomon": "所罗门", + "Moses": "摩西", "Paul": "保罗", "Joshua": "约书亚", "Noah": "挪亚" + }, + + "_target_fixes": { + "_note": "MEASURED wrong against the archive, and every occurrence inspected to rule out a compound-boundary false positive — 36 instances checked, all genuinely the name, each confirmed by its English source line. Noah is the serious one: NLLB used the SECULAR transliteration 诺亚 in 33 of 39 occurrences, against the Bible form 挪亚 in only 6.", + "诺亚": "挪亚", + "马修": "马太" + } +} diff --git a/app/bible/lexicon-zho_Hant.json b/app/bible/lexicon-zho_Hant.json new file mode 100644 index 0000000..8bd089f --- /dev/null +++ b/app/bible/lexicon-zho_Hant.json @@ -0,0 +1,22 @@ +{ + "language": "zho_Hant", + "reference": "和合本 Chinese Union Version, Traditional (CUV)", + "_verified": "Forms read directly from CUV Traditional text on BibleGateway: Matthew 10:2-4 and Matthew 1:1-2. Traditional characters were READ, not converted from the Simplified table — a mechanical conversion would have been a guess.", + "confidence": "high", + + "people": { + "Jesus": "耶穌", "Jesus Christ": "耶穌基督", "Christ": "基督", + "Simon": "西門", "Peter": "彼得", "Andrew": "安得烈", "James": "雅各", + "John": "約翰", "Philip": "腓力", "Bartholomew": "巴多羅買", + "Thomas": "多馬", "Matthew": "馬太", "Thaddaeus": "達太", + "Judas": "猶大", "Zebedee": "西庇太", + "Abraham": "亞伯拉罕", "David": "大衛", "Isaac": "以撒", "Jacob": "雅各", + "Judah": "猶大" + }, + + "_target_fixes": { + "_note": "Traditional counterparts of the two Simplified defects. 諾亞/馬修 are the secular-phonetic forms; 挪亞/馬太 are the Union Version's.", + "諾亞": "挪亞", + "馬修": "馬太" + } +} diff --git a/app/bible/test-corpus.json b/app/bible/test-corpus.json new file mode 100644 index 0000000..0717f26 --- /dev/null +++ b/app/bible/test-corpus.json @@ -0,0 +1,58 @@ +{ + "_note": "Realistic preaching utterances for validating the guard layers. 'expect' records properties that must hold in EVERY target language, since no one here can read most of them. Anything asserting specific target text is marked needsNativeReview.", + + "_carrier": "Name coverage is measured IN A CARRIER SENTENCE, not in isolation. An isolated proper noun is exactly the short-input regime where NLLB hallucinates, so testing bare terms would measure the wrong failure. Carrier: 'We read about NAME in the Bible.'", + + "hallucination_cases": [ + { "en": "Donald Trump.", "expect": { "noNewQuestion": true, "maxSentences": 1 }, "seen": "spa: 'Donald Trump. ¿Qué es eso?'" }, + { "en": "Amen.", "expect": { "noNewQuestion": true, "maxSentences": 1 }, "seen": "spa: '- Sí, estoy bien.' (= 'Yes, I am fine.')" }, + { "en": "Hello, hello.", "expect": { "noLeadingDash": true, "maxSentences": 1 }, "seen": "spa: '- Hola, está bien.'" }, + { "en": "Hallelujah.", "expect": { "noNewQuestion": true, "maxSentences": 1 } }, + { "en": "Praise the Lord.", "expect": { "noNewQuestion": true, "maxSentences": 1 } }, + { "en": "Come on.", "expect": { "noNewQuestion": true, "maxSentences": 1 } }, + { "en": "Yes.", "expect": { "noNewQuestion": true, "maxSentences": 1 } }, + { "en": "Okay.", "expect": { "noNewQuestion": true, "maxSentences": 1 } } + ], + + "must_not_be_stripped": [ + { "en": "How many countries are in the world?", "expect": { "keepsQuestion": true }, "why": "source has ? — the target question is real" }, + { "en": "What does the Bible say about that?", "expect": { "keepsQuestion": true } }, + { "en": "Do you believe that?", "expect": { "keepsQuestion": true } }, + { "en": "Who do you say that I am?", "expect": { "keepsQuestion": true }, "why": "Matthew 16:15" }, + { "en": "Where, O death, is your victory?", "expect": { "keepsQuestion": true }, "why": "1 Cor 15:55" }, + { "en": "Are you with me", "expect": { "note": "Whisper drops the ? on questions like this — a correct target may legitimately add one. THE RISK CASE for the invented-question rule." } }, + { "en": "Can I get an amen", "expect": { "note": "same risk class" } }, + { "en": "You know what I'm saying", "expect": { "note": "tag question, no ? in source" } } + ], + + "name_fidelity": [ + { "en": "His name was John.", "seen": "spa: 'Se llamaba John.' — untranslated" }, + { "en": "Open up to the book, Matthew.", "seen": "spa: 'Abre al libro, Matthew.' — untranslated" }, + { "en": "Turn with me to John chapter 3.", "seen": "spa: 'Vuelva conmigo a Juan capítulo 3.' — correct" }, + { "en": "Today we are going to talk about Peter in the Bible.", "seen": "spa: '...de Pedro en la Biblia.' — correct" }, + { "en": "Paul wrote to the church at Corinth." }, + { "en": "Peter denied him three times." }, + { "en": "James, the brother of Jesus, wrote this letter." }, + { "en": "Mark tells us the same story." }, + { "en": "Luke was a physician." }, + { "en": "We are reading from Habakkuk chapter 2." } + ], + + "homonym_traps": [ + { "en": "I finally got the job I wanted.", "expect": { "mustNotBecome": "Job (the book)" } }, + { "en": "He did a mark on the paper.", "expect": { "mustNotBecome": "Mark (the apostle)" } }, + { "en": "She will call him tomorrow.", "expect": { "mustNotBecome": "a proper noun" } }, + { "en": "That was an act of kindness.", "expect": { "mustNotBecome": "Acts" } }, + { "en": "The romans built roads.", "expect": { "note": "lowercase 'romans' — people, not the epistle" } }, + { "en": "Give me a hope and a future.", "expect": { "mustNotBecome": "a book title" } }, + { "en": "My son Mark is here today.", "expect": { "note": "a real person named Mark, not the evangelist — a name lexicon must not 'fix' this" } }, + { "en": "Amen was not his name.", "expect": { "note": "'Amen' inside a sentence must NOT hit the liturgical phrasebook" } } + ], + + "long_form": [ + { "en": "And God said, let there be light, and there was light." }, + { "en": "For God so loved the world that he gave his only begotten Son, that whoever believes in him should not perish but have everlasting life." }, + { "en": "The Lord is my shepherd; I shall not want." }, + { "en": "Blessed are the poor in spirit, for theirs is the kingdom of heaven." } + ] +} diff --git a/app/capture-worklet.js b/app/capture-worklet.js new file mode 100644 index 0000000..cf0857d --- /dev/null +++ b/app/capture-worklet.js @@ -0,0 +1,121 @@ +/* + * capture-worklet.js — pulls audio off the graph in fixed frames. + * + * Runs on the audio thread, so it does the least possible work: pick the + * channels that carry a microphone, average them, fill a frame, hand it over. + * All judgement about what is speech happens on the main thread where it + * cannot cause a dropout. + */ + +/* An empty pick means "mix everything", which is right for a two-channel + console feed. A specific set is right for Dante or SoundGrid, where the + device presents 64 channels and the voice is on one or two of them — mixing + those would bury the speaker under 62 silent channels and make a perfectly + good feed look like silence. */ +function normalize(list) { + if (!Array.isArray(list)) return null; + const out = []; + for (const v of list) { + const n = Number(v); + if (Number.isInteger(n) && n >= 0 && out.indexOf(n) === -1) out.push(n); + } + return out.length ? out.sort((a, b) => a - b) : null; +} + +/* Per-channel levels, posted about ten times a second. They are what makes a + 64-channel device usable at all: without them, choosing the right channel is + guesswork against a list of identical names, and the only feedback is + whether the whole app appears broken. With them you watch for the one that + moves when someone talks. */ +const LEVEL_MS = 100; + +class CaptureProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const opts = (options && options.processorOptions) || {}; + this._buf = new Float32Array(opts.frameSize || 1024); + this._n = 0; + this._pick = normalize(opts.channels); + this._reported = -1; + + this._sumSq = null; // per-channel accumulator for the level meters + this._sumN = 0; + this._lastLevel = 0; + + /* Changing channel used to mean tearing the capture graph down and + building it again, which is not something to do mid-service. A message + is, so the picker now works while translation is running. */ + this.port.onmessage = (e) => { + const d = e.data; + if (d && d.t === 'channels') this._pick = normalize(d.channels); + }; + } + + process(inputs) { + const input = inputs[0]; + if (!input || input.length === 0) return true; + + const chans = input.length; + const len = input[0].length; + + /* Tell the main thread how many channels actually arrived, once. */ + if (this._reported !== chans) { + this._reported = chans; + this._sumSq = new Float32Array(chans); + this._sumN = 0; + this.port.postMessage({ channels: chans }); + } + + /* + * Resolve the pick ONCE per render quantum, not once per sample. A channel + * index that no longer exists (the device changed underneath us) falls back + * to mixing rather than reading past the end of the array. + */ + let picks = null; + if (this._pick) { + picks = []; + for (let k = 0; k < this._pick.length; k++) { + if (this._pick[k] < chans) picks.push(this._pick[k]); + } + if (!picks.length) picks = null; + } + const np = picks ? picks.length : chans; + + for (let i = 0; i < len; i++) { + let sum = 0; + if (picks) for (let k = 0; k < np; k++) sum += input[picks[k]][i]; + else for (let c = 0; c < chans; c++) sum += input[c][i]; + this._buf[this._n++] = sum / np; + + if (this._n === this._buf.length) { + this.port.postMessage(this._buf.slice(0)); + this._n = 0; + } + } + + /* Levels for EVERY channel, including the ones not selected — the whole + point is to find the one that is moving. */ + if (this._sumSq && this._sumSq.length === chans) { + for (let c = 0; c < chans; c++) { + const ch = input[c]; + let s = 0; + for (let i = 0; i < len; i++) s += ch[i] * ch[i]; + this._sumSq[c] += s; + } + this._sumN += len; + + const now = currentTime * 1000; + if (now - this._lastLevel >= LEVEL_MS && this._sumN > 0) { + this._lastLevel = now; + const rms = new Float32Array(chans); + for (let c = 0; c < chans; c++) rms[c] = Math.sqrt(this._sumSq[c] / this._sumN); + this.port.postMessage({ levels: rms }); + this._sumSq.fill(0); + this._sumN = 0; + } + } + return true; + } +} + +registerProcessor('capture', CaptureProcessor); diff --git a/app/engine.css b/app/engine.css new file mode 100644 index 0000000..2000af6 --- /dev/null +++ b/app/engine.css @@ -0,0 +1,702 @@ +/* + * engine.css — the backstage app. + * + * Nobody wants to look at this during a service. It exists to answer three + * questions fast: is it hearing audio, is it connected to the church server, + * and are the words right. + */ + +/* + * 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; + font-size: 14px; +} + +h1 { font-size: 1.5rem; margin: 0 0 .375rem; font-weight: 700; } +h2 { font-size: .9375rem; margin: 0 0 .25rem; font-weight: 650; } +p { margin: 0 0 .75rem; line-height: 1.5; } +code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .75rem; + background: var(--raised); padding: .0625rem .3125rem; border-radius: 4px; } +hr { border: 0; border-top: 1px solid var(--line); margin: 1.25rem 0; } + +.muted { color: var(--muted); font-size: .8125rem; } +.hint { color: var(--muted); font-size: .75rem; line-height: 1.5; margin: .375rem 0 0; } + +/* ---- header ---- */ +.head { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; justify-content: space-between; + padding: .75rem 1.25rem; background: rgba(11,13,18,.92); + backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); + -webkit-app-region: drag; +} +.brand { display: flex; align-items: center; gap: .5rem; font-size: .875rem; padding-left: 4.5rem; } +.brand b { font-weight: 700; } +.mark { width: .625rem; height: .625rem; border-radius: 3px; background: var(--accent); } +.head-right { display: flex; align-items: center; gap: .4375rem; -webkit-app-region: no-drag; } +.pip { width: .5rem; height: .5rem; border-radius: 50%; background: var(--muted); } +.pip.ok { background: var(--go); } +.pip.bad { background: var(--stop); } +.pip-label { font-size: .75rem; color: var(--muted); } + +.build { + font-size: .6875rem; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + border: 1px solid var(--line); border-radius: 999px; padding: .0625rem .4375rem; margin-left: .125rem; +} + +/* ---- page ---- + Hero across the top, settings in a column of the same width they have always + had, and the congregation card parked in a rail on the right where it stays + put while the settings scroll under it. Narrow windows fold back to one + column and the rail returns to the top of the flow, where it is still the + first thing you need. */ +.page { + max-width: 72rem; margin: 0 auto; padding: 1.25rem 1.25rem 3rem; + display: grid; gap: 0 1rem; justify-content: center; + grid-template-columns: minmax(0, 44rem) minmax(17rem, 21rem); + align-items: start; +} +.page > .hero { grid-column: 1 / -1; } +.col-main { grid-column: 1; min-width: 0; } +.col-rail { grid-column: 2; position: sticky; top: 4rem; min-width: 0; } +/* + * One column. The order is deliberate and it is NOT source order: the hero + * stays first — it is the button you came to press — then the congregation + * card, then the settings. `order: -1` on the rail alone put the QR ABOVE the + * hero, which is the one arrangement that is wrong. + */ +@media (max-width: 68rem) { + .page { grid-template-columns: minmax(0, 44rem); } + .col-main, .col-rail { grid-column: 1; } + .page > .hero { order: 1; } + .col-rail { position: static; order: 2; } + .col-main { order: 3; } +} + +.card { + background: var(--surface); border: 1px solid var(--line); + border-radius: 14px; padding: 1.125rem; margin-bottom: .875rem; +} + +/* ---- collapsible groups ---- */ +.group { + background: var(--surface); border: 1px solid var(--line); + border-radius: 14px; margin-bottom: .875rem; overflow: hidden; +} +.group-head { + display: flex; align-items: center; gap: .625rem; width: 100%; + appearance: none; background: transparent; border: 0; color: var(--text); + padding: 1rem 1.125rem; cursor: pointer; text-align: left; font: inherit; + transition: background .16s ease; +} +.group-head:hover { background: var(--raised); } +.group-head h2 { margin: 0; } +/* Clipped by CSS, not by slicing the string — slicing cut mid-word and left + things like "Dante Virtual Soundcard (Vir" with the bracket hanging open. */ +.group-sub { + margin-left: auto; font-size: .75rem; color: var(--muted); + max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.chev { + width: .5rem; height: .5rem; flex: none; + border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted); + transform: rotate(-45deg); transition: transform .32s cubic-bezier(.2,.7,.3,1); +} +.group[data-open="true"] .chev { transform: rotate(45deg); } + +/* + * 0fr -> 1fr, not a pixel height: the content of these sections changes size + * after they open (a 105-row language list, a live meter, a QR that loads), + * and any measured max-height would be wrong the moment it did. + */ +.group-body { + display: grid; grid-template-rows: 0fr; + transition: grid-template-rows .34s cubic-bezier(.2,.7,.3,1), opacity .22s ease; + opacity: 0; +} +.group[data-open="true"] .group-body { grid-template-rows: 1fr; opacity: 1; } +.group-body > .group-inner { overflow: hidden; min-height: 0; padding: 0 1.125rem; } +.group[data-open="true"] > .group-body > .group-inner { padding-bottom: 1.125rem; } +@media (prefers-reduced-motion: reduce) { + .group-body { transition: none; } + .chev { transition: none; } +} + +.subhead2 { margin: 1rem 0 .25rem; font-size: .75rem; color: var(--muted); font-weight: 650; } + +/* ---- right rail ---- */ +.rail-card .qr { display: block; margin: 0 auto .625rem; } +/* The rail is a third of the width the settings column has, and an operator + URL does not fit in it. Let it wrap rather than clip — a truncated address + is worse than a two-line one. */ +.rail-card .codeblock { flex-wrap: wrap; } +.rail-card .codeblock code { + flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; white-space: normal; +} +.rail-card .codeblock .btn { margin-left: auto; } +.railbtns { display: grid; gap: .375rem; margin-top: .625rem; } +.railwide { width: 100%; margin-top: .375rem; } + +/* ---- output routing matrix ---- + One row per language, one button per physical channel. The grid is the whole + point: you should be able to see at a glance that nothing is doubled up and + nothing is unrouted. */ +/* Sized for 64 channels as readily as for 2: the rows scroll rather than the + page, and the buttons shrink a step once there are more than sixteen so a + 64-wide row still fits without becoming a horizontal scroll hunt. */ +.outmap { display: grid; gap: .375rem; margin-top: .5rem; max-height: 22rem; overflow-y: auto; } +.outrow { display: flex; align-items: flex-start; gap: .5rem; } +.outrow > b { + flex: none; width: 9.5rem; padding-top: .25rem; + font-size: .8125rem; font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.outchans { display: flex; flex-wrap: wrap; gap: .1875rem; flex: 1; min-width: 0; } +.outch { + appearance: none; cursor: pointer; font: inherit; font-size: .75rem; + min-width: 1.875rem; padding: .1875rem .375rem; + background: var(--raised); border: 1px solid var(--line); color: var(--muted); + border-radius: 6px; font-variant-numeric: tabular-nums; + transition: border-color .14s ease, background .14s ease, color .14s ease; +} +.outmap.many .outch { min-width: 1.5rem; padding: .125rem .1875rem; font-size: .6875rem; } + +/* Adjacent selected channels fuse into one pill, so a stereo pair reads as a + pair rather than as two numbers that happen to be lit. This is the "link" + made visible — the shape says they belong together. */ +.outch.on:has(+ .outch.on) { border-top-right-radius: 0; border-bottom-right-radius: 0; } +.outch.on + .outch.on { + border-top-left-radius: 0; border-bottom-left-radius: 0; + margin-left: -.1875rem; border-left-color: rgba(110,168,254,.35); +} +.outmap.many .outch.on + .outch.on { margin-left: -.1875rem; } + +.outtag { + flex: none; width: 8rem; padding-top: .25rem; text-align: right; + font-size: .6875rem; color: var(--muted); font-variant-numeric: tabular-nums; +} +.outrow.none .outtag { opacity: .6; } + +.outtrim { flex: none; width: 5.5rem; margin: 0; accent-color: var(--accent); } +.outtrim:disabled { opacity: .3; } +.outdb { + flex: none; width: 3.25rem; padding-top: .25rem; text-align: right; + font-size: .6875rem; color: var(--muted); font-variant-numeric: tabular-nums; +} +.outch:hover { border-color: var(--muted); } +.outch.on { border-color: var(--accent); background: rgba(110,168,254,.16); color: var(--text); } +.outrow.none > b { color: var(--muted); font-weight: 500; } + +/* ---- channel picker ---- + A grid of numbered buttons, each with its own live level. Finding the + speaker on a 64-channel Dante device is a matter of watching for the bar + that moves, so the meter is the point and the number is the label. */ +.chanbox { margin: .75rem 0; } +.chanhead { display: flex; align-items: center; gap: .5rem; margin-bottom: .375rem; } +.chanhead .btn { margin-left: auto; } +.chanlist { + display: grid; gap: .3125rem; + grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr)); + max-height: 13rem; overflow-y: auto; padding: .125rem; +} +.chanrow { + display: flex; align-items: center; gap: .375rem; + appearance: none; cursor: pointer; font: inherit; + background: var(--raised); border: 1px solid var(--line); color: var(--muted); + border-radius: 7px; padding: .3125rem .4375rem; + transition: border-color .16s ease, background .16s ease, color .16s ease; +} +.chanrow:hover { border-color: var(--muted); } +.chanrow b { font-size: .75rem; min-width: 1.25rem; text-align: right; font-variant-numeric: tabular-nums; } +.chanrow.on { border-color: var(--accent); color: var(--text); background: rgba(110,168,254,.12); } +.chanbar { flex: 1; height: .375rem; border-radius: 999px; background: var(--bg); overflow: hidden; } +.chanbar-fill { + display: block; height: 100%; width: 0; border-radius: 999px; + background: var(--muted); transition: width .08s linear; +} +.chanbar-fill.hot { background: var(--go); } +.chanrow.on .chanbar-fill { background: var(--accent); } +.chanrow.on .chanbar-fill.hot { background: var(--go); } + +/* ---- hero ---- */ +.hero { + background: var(--surface); border: 1px solid var(--line); + border-radius: 14px; padding: 1.75rem; margin-bottom: .875rem; text-align: center; +} +.hero[data-state="live"] { border-color: var(--go); background: linear-gradient(180deg, rgba(47,191,113,.10), var(--surface)); } +.hero[data-state="ready"] { border-color: #2c4a7a; } +.hero[data-state="loading"] { border-color: var(--accent); } +.hero[data-state="error"] { border-color: var(--stop); background: linear-gradient(180deg, rgba(229,72,77,.10), var(--surface)); } + +.hero-icon { width: 2.75rem; height: 2.75rem; margin: 0 auto .75rem; border-radius: 50%; background: var(--raised); position: relative; } +.hero[data-state="live"] .hero-icon { background: var(--go); animation: pulse 2s ease-in-out infinite; } +.hero[data-state="loading"] .hero-icon { background: var(--accent); animation: pulse 1.4s ease-in-out infinite; } +.hero[data-state="error"] .hero-icon { background: var(--stop); } +/* A voice falling back to built-in is amber, on the hero, in a sentence — the + one place whoever is standing at the Mac is actually looking. */ +.voice-warn { margin: .5rem 0 0; padding: .5rem .75rem; border-radius: 8px; font-size: .8125rem; + color: #fbbf24; background: rgba(251,191,36,.10); border: 1px solid rgba(251,191,36,.35); } +@keyframes pulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(.86); opacity: .55; } } + +.hero p { color: var(--muted); font-size: .875rem; max-width: 30rem; margin-left: auto; margin-right: auto; } + +.bar { height: .4375rem; background: var(--raised); border-radius: 999px; overflow: hidden; margin: .875rem auto .5rem; max-width: 24rem; } +.bar-fill { height: 100%; width: 0; background: var(--accent); border-radius: 999px; transition: width .25s ease; } +.prog-note { font-size: .75rem; color: var(--muted); font-family: ui-monospace, Menlo, monospace; } + +/* ---- controls ---- */ +label { display: block; font-size: .75rem; color: var(--muted); margin: .875rem 0 .375rem; + text-transform: uppercase; letter-spacing: .07em; font-weight: 600; } +label:first-of-type { margin-top: 0; } + +input, select { + width: 100%; padding: .625rem .75rem; + background: var(--raised); color: var(--text); + border: 1px solid var(--line); border-radius: 9px; + font: inherit; font-size: .875rem; outline: none; +} +input:focus, select:focus { border-color: var(--accent); } +input[type=range] { padding: 0; background: transparent; border: 0; accent-color: var(--accent); } + +.row { display: flex; gap: .5rem; align-items: center; margin-top: .5rem; flex-wrap: wrap; } +.row select, .row input { flex: 1; min-width: 10rem; } +.row .btn { flex: none; } + +.btn { + appearance: none; border: 1px solid var(--line); background: var(--raised); color: var(--text); + padding: .5625rem 1rem; border-radius: 9px; font: inherit; font-size: .875rem; + font-weight: 550; cursor: pointer; +} +.btn:hover { background: #262d3d; } +.btn-primary { background: var(--accent); border-color: var(--accent); color: #08101f; font-weight: 650; } +.btn:disabled { opacity: .5; cursor: not-allowed; } + +/* ---- level meter ---- */ +.meter-row { display: flex; align-items: center; gap: .75rem; margin-top: .875rem; } +.meter { + position: relative; flex: 1; height: .875rem; + background: var(--raised); border-radius: 5px; overflow: hidden; +} +.meter-fill { height: 100%; width: 0; background: var(--muted); border-radius: 5px; transition: width .06s linear, background .15s ease; } +.meter-fill.speech { background: var(--go); } +.meter-fill.hot { background: var(--warn); } +.meter-fill.clip { background: var(--stop); } +.meter-thresh { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--text); opacity: .65; left: 0; transition: left .2s ease; } +.meter-label { font-family: ui-monospace, Menlo, monospace; font-size: .75rem; color: var(--muted); width: 5.5rem; text-align: right; } +/* The gate control sits under the meter it moves, and lines its readout up + with the meter's own so the two numbers can be compared at a glance. */ +.threshrow { display: flex; align-items: center; gap: .75rem; margin-top: .5rem; } +.threshrow .oprow { flex: 1 1 auto; } +.threshrow .outtrim { flex: none; } + +/* ---- monitor ---- */ +.monitor { + background: var(--raised); border-radius: 10px; padding: .875rem; + height: 9rem; overflow-y: auto; font-size: .875rem; line-height: 1.55; + display: flex; flex-direction: column; gap: .5rem; +} +.mon-empty { color: var(--muted); font-size: .8125rem; margin: auto; } +.mon-line { color: var(--text); } +.mon-line.partial { color: var(--muted); font-style: italic; } +.mon-line small { display: block; color: var(--muted); font-size: .6875rem; margin-top: .1875rem; } +/* English, then what each language was actually told. The operator reads this + to verify the service, so the two must be visually distinct but adjacent. */ +.mon-line + .mon-line { margin-top: .625rem; } +.mon-en { color: var(--text); } +.mon-tr { margin: .1875rem 0 0 .875rem; font-size: .8125rem; color: var(--muted); + display: flex; gap: .5rem; align-items: baseline; } +.mon-tr b { flex: none; font-size: .625rem; font-weight: 700; letter-spacing: .03em; + text-transform: uppercase; color: var(--accent); opacity: .85; } +.mon-tr.dropped .mon-tr-text { color: var(--warn); font-style: italic; } +.mon-wait { margin: .1875rem 0 0 .875rem; font-size: .75rem; color: var(--muted); opacity: .5; font-style: italic; } + +/* ---- details ---- */ +.details summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: .5rem; } +.details summary::-webkit-details-marker { display: none; } +.details summary::before { content: "▸"; color: var(--muted); font-size: .75rem; } +.details[open] summary::before { content: "▾"; } +.details summary h2 { margin: 0; } + +/* ---- log ---- */ +.log { + background: var(--raised); border-radius: 10px; padding: .75rem; + height: 8rem; overflow-y: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .6875rem; line-height: 1.6; + color: var(--muted); white-space: pre-wrap; word-break: break-word; +} +.log .err { color: var(--stop); } +.log .ok { color: var(--go); } +/* The guard trimming an invented clause is not an error and not a success — + it is the model being caught. Amber so a run of them is visible at a glance, + because a spike is the only signal that the guard has started misfiring. */ +.log .warn { color: #d8a23a; } + +/* ---- the address the congregation uses ---- */ +.sharerow { display: flex; gap: 1.25rem; flex-wrap: wrap; align-items: flex-start; margin-top: .875rem; } +.qr { width: 9.5rem; height: 9.5rem; background: #fff; border-radius: 10px; padding: .4375rem; flex: none; } +.shareside { flex: 1; min-width: 15rem; } + +.addrlist { display: flex; flex-direction: column; gap: .375rem; margin-bottom: .875rem; } +.addr { + display: flex; align-items: baseline; gap: .625rem; + background: var(--raised); border: 1px solid var(--line); + border-radius: 9px; padding: .5625rem .75rem; +} +.addr b { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: .9375rem; font-weight: 500; color: var(--text); word-break: break-all; +} +.addr span { font-size: .6875rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; flex: none; } +.addrlist:empty::before { content: "No network connection"; color: var(--muted); font-size: .8125rem; } +.addr { cursor: pointer; width: 100%; text-align: left; font: inherit; transition: border-color .15s ease; } +.addr:hover { border-color: var(--accent); } +.addr.is-chosen { border-color: var(--accent); } +.addr.is-chosen span { color: var(--accent); } + +/* ---- who is listening right now ---- */ +.listenbox { + display: flex; align-items: baseline; flex-wrap: wrap; gap: .5rem .75rem; + background: var(--raised); border: 1px solid var(--line); + border-radius: 10px; padding: .75rem .9375rem; margin-bottom: .875rem; +} +.listenbox[hidden] { display: none; } +.listenbox > b { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1; } +.listenbox > span { font-size: .8125rem; color: var(--muted); } +.listenbreak { display: flex; flex-wrap: wrap; gap: .375rem; width: 100%; } +.listenbreak span { + font-size: .75rem; color: var(--text); + background: var(--line); border-radius: 999px; padding: .1875rem .5625rem; +} +.listenbreak b { font-variant-numeric: tabular-nums; } + +/* Which devices are connected — so a forgotten tab can be found. */ +.listenwho { display: flex; flex-direction: column; gap: .25rem; width: 100%; margin-top: .5rem; } +.listenwho:empty { display: none; } +.wholine { + display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; + font-size: .75rem; padding: .3125rem 0; + border-top: 1px solid var(--line); +} +.wholine b { font-weight: 600; color: var(--text); } +.wholine span { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .6875rem; } + +/* ---- Setup / Operator, in one window ---- */ + +/* Panes cross-fade rather than jumping, so switching mode reads as the same + window changing rather than a new screen appearing. */ +/* Both panes are now one continuous settings page. The entrance animation is + kept on the first only — running it on both produced a visible two-stage + fade with a seam between them. */ +.mode-pane { animation: none; } +#setupPane { animation: paneIn .26s cubic-bezier(.2,.7,.3,1) both; } +.mode-pane[hidden] { display: none; } +@keyframes paneIn { + from { opacity: 0; transform: translateY(.5rem); } + to { opacity: 1; transform: none; } +} +@media (prefers-reduced-motion: reduce) { .mode-pane { animation: none; } } + +/* The hero becomes the control in operator mode. */ +.hero-go { + appearance: none; border: 0; cursor: pointer; + width: 100%; margin-top: 1.125rem; padding: 1.125rem; + border-radius: 12px; font: inherit; font-size: 1.375rem; font-weight: 750; + letter-spacing: .02em; + background: var(--go); color: #052e18; + transition: background .18s ease, transform .08s ease, opacity .18s ease; + animation: paneIn .26s cubic-bezier(.2,.7,.3,1) both; +} +.hero-go[hidden] { display: none; } +.hero-go:active { transform: scale(.99); } +.hero-go[data-live="true"] { background: var(--stop); color: #fff; } +.hero-go:disabled { background: var(--raised); color: var(--muted); cursor: not-allowed; } + +.stats { display: grid; grid-template-columns: repeat(3,1fr); gap: .5rem; margin-top: 1rem; + animation: paneIn .3s cubic-bezier(.2,.7,.3,1) both; } +.stats[hidden] { display: none; } +.stat { background: var(--raised); border-radius: 10px; padding: .625rem; } +.stat b { display: block; font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; } +.stat span { font-size: .6875rem; color: var(--muted); } + +/* The cough button. Amber, not red — red is "stopped", and this is the opposite + of stopped: everything is still running, it just is not going out. */ +.hold-btn { + display: block; width: 100%; max-width: 22rem; margin: .5rem auto 0; + padding: .5rem; font-weight: 600; letter-spacing: .02em; +} +.hold-btn[data-held="true"] { + border-color: var(--warn); background: rgba(245,165,36,.18); color: var(--warn); +} +.holdbar { + margin: .625rem auto 0; max-width: 34rem; padding: .5rem .75rem; border-radius: 7px; + border: 1px solid var(--warn); background: rgba(245,165,36,.14); + font-size: .8125rem; line-height: 1.45; + animation: holdPulse 1.6s ease-in-out infinite; +} +.holdbar b { color: var(--warn); letter-spacing: .04em; } +@keyframes holdPulse { 0%,100% { opacity: 1; } 50% { opacity: .68; } } +@media (prefers-reduced-motion: reduce) { .holdbar { animation: none; } } + +.subhead { margin: 1rem 0 .375rem; font-size: .8125rem; color: var(--muted); + text-transform: uppercase; letter-spacing: .06em; } + +/* The QR is printed and handed out. When the address behind it moves, or when + it is pointing at a network no phone is on, that has to be impossible to + miss — it looks fine either way, and the failure only shows up in the room. */ +.qrwarn { + margin: 0 0 .625rem; padding: .5rem .6875rem; border-radius: 7px; + border: 1px solid var(--warn); background: rgba(245,165,36,.12); + color: var(--text); font-size: .8125rem; line-height: 1.45; +} +.qrwarn b { color: var(--warn); } + +.chips { display: flex; flex-wrap: wrap; gap: .375rem; margin: .625rem 0; min-height: 1.75rem; } +.chips:empty::before { content: "None"; color: var(--muted); font-size: .8125rem; } +.chip { + display: inline-flex; align-items: center; gap: .375rem; + background: var(--raised); border: 1px solid var(--line); + border-radius: 999px; padding: .25rem .4375rem .25rem .6875rem; font-size: .8125rem; +} +.chip button { appearance: none; border: 0; background: transparent; color: var(--muted); + cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 .1875rem; } +.vtag.warn { color: var(--warn); } + +.chip button:hover { color: var(--stop); } + +/* Pinning and unpinning used to be instantaneous, which on a page this long + reads as a chip appearing from nowhere. Same treatment as the language + columns: grow in, shrink out, and fly from the search result you picked. */ +@keyframes chipIn { from { transform: scale(.86); opacity: 0; } to { transform: scale(1); opacity: 1; } } +@keyframes chipOut { from { transform: scale(1); opacity: 1; max-width: 18rem; } + to { transform: scale(.82); opacity: 0; max-width: 0; + padding-left: 0; padding-right: 0; margin-left: -.375rem; } } +.chip.chip-in { animation: chipIn .2s cubic-bezier(.2,.7,.3,1) both; } +.chip.chip-out { animation: chipOut .19s ease-in both; overflow: hidden; white-space: nowrap; + pointer-events: none; } +.chip.is-landing { visibility: hidden; } +.chipfly { + position: fixed; z-index: 60; pointer-events: none; margin: 0; + box-shadow: 0 8px 24px rgba(0,0,0,.45); + will-change: transform, opacity; +} +.chipfly button { display: none; } +@media (prefers-reduced-motion: reduce) { + .chipfly { display: none; } + .chip.chip-in, .chip.chip-out { animation: none; } +} + +.opresults { display: grid; gap: .1875rem; margin-top: .375rem; max-height: 11rem; overflow-y: auto; } +.opresults:empty { display: none; } +.opresults button { + appearance: none; text-align: left; width: 100%; font: inherit; font-size: .8125rem; + background: var(--raised); border: 1px solid transparent; color: var(--text); + border-radius: 8px; padding: .4375rem .625rem; cursor: pointer; +} +.opresults button:hover { border-color: var(--accent); } +.opresults button span { color: var(--muted); margin-left: .4375rem; font-size: .75rem; } + +.codeblock { display: flex; align-items: center; gap: .5rem; background: var(--raised); + border: 1px solid var(--line); border-radius: 9px; padding: .5rem .625rem; } +.codeblock code { flex: 1; overflow-x: auto; white-space: nowrap; font-size: .75rem; color: #cbd5e6; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.codeblock + .codeblock { margin-top: .375rem; } +.urlname { flex: 0 0 3.5rem; font-size: .75rem; font-weight: 600; color: var(--dim); } +h2.mt { margin-top: 1.5rem; } +textarea { width: 100%; padding: .625rem .75rem; background: var(--raised); color: var(--text); + border: 1px solid var(--line); border-radius: 9px; font-family: ui-monospace, Menlo, monospace; + font-size: .8125rem; line-height: 1.6; resize: vertical; outline: none; } +textarea:focus { border-color: var(--accent); } + +/* The volunteer's traffic light: one sentence, colored by severity. */ +.op-health { margin: .375rem 0 0; font-size: .8125rem; font-weight: 600; } +.op-health[data-level="green"] { color: #4ade80; } +.op-health[data-level="amber"] { color: #fbbf24; } +.op-health[data-level="red"] { color: #f87171; } + +/* The voice auditioning list: one row per candidate, play and choose. */ +.voicelist { display: flex; flex-direction: column; gap: .25rem; margin-top: .5rem; + max-height: 19rem; overflow-y: auto; } +.vrow { display: flex; align-items: center; gap: .5rem; padding: .375rem .5rem; + background: var(--raised); border: 1px solid var(--line); border-radius: 8px; } +.vrow.is-current { border-color: var(--accent); } +.vrow .vname { flex: 1; font-size: .8125rem; } +.vrow .vtag { font-size: .6875rem; color: var(--dim); } +.vrow .vmsg { font-size: .6875rem; color: #fbbf24; max-width: 14rem; } +.vrow .vmsg.bad { color: #f87171; } +.vrow button { flex: 0 0 auto; } +/* + * "Offer this voice to listeners". + * + * Subject to exactly the two traps documented for .oprow below — `label` is + * styled as a small-caps field caption and bare `input` is width:100% — so it + * has to undo both, or the word "Offer" arrives shouting in letter-spaced caps + * next to a checkbox ten rems wide. + */ +.vrow .voffer { + display: flex; align-items: center; gap: .3rem; + flex: 0 0 auto; margin: 0; + text-transform: none; letter-spacing: normal; font-weight: 400; + font-size: .6875rem; color: var(--dim); + cursor: pointer; user-select: none; +} +.vrow .voffer input[type="checkbox"] { width: auto; min-width: 0; flex: 0 0 auto; margin: 0; } +.vrow .voffer:has(input:checked) { color: var(--accent); } + +/* + * Checkbox rows on the operator page. + * + * These are