Gospel page audio: choose-your-path narration (case, then Guilty or Not guilty tracks), branching player, three admin audio fields, generator --sample/--only; v2.41.0
Publish Container / build (push) Successful in 16m4s
Publish Container / build (push) Successful in 16m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ad1c3e35b1
commit
ae0016cfee
9 files changed
+228
-55
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.40.0",
|
"version": "2.41.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -643,6 +643,22 @@
|
|||||||
#audio-seek { flex: 1; min-width: 0; accent-color: var(--gold); height: 1.6rem; cursor: pointer; }
|
#audio-seek { flex: 1; min-width: 0; accent-color: var(--gold); height: 1.6rem; cursor: pointer; }
|
||||||
.audio-time { font-family: 'Cinzel', serif; font-size: 0.8rem; min-width: 4.6rem; text-align: right; color: var(--parchment-dark); white-space: nowrap; }
|
.audio-time { font-family: 'Cinzel', serif; font-size: 0.8rem; min-width: 4.6rem; text-align: right; color: var(--parchment-dark); white-space: nowrap; }
|
||||||
@media (max-width: 600px) { .audio-controls { flex-wrap: wrap; } #audio-seek { flex-basis: 100%; order: 5; } .audio-time { margin-left: auto; } }
|
@media (max-width: 600px) { .audio-controls { flex-wrap: wrap; } #audio-seek { flex-basis: 100%; order: 5; } .audio-time { margin-left: auto; } }
|
||||||
|
|
||||||
|
.audio-actions { margin-top: 1rem; padding-top: 1rem; border-top: 1px dashed rgba(184,134,11,0.5); }
|
||||||
|
.audio-actions[hidden] { display: none; }
|
||||||
|
.audio-q { font-family: 'Cinzel', serif; font-weight: 900; font-size: 1.25rem; letter-spacing: 0.06em; text-transform: uppercase; color: #fff; margin-bottom: 0.7rem; text-align: center; }
|
||||||
|
.audio-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem; }
|
||||||
|
.audio-choice { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; padding: 1rem 0.8rem; font-family: 'Cinzel', serif; font-weight: 900; font-size: 1.1rem; letter-spacing: 0.08em; text-transform: uppercase; color: #1a0f05; background: linear-gradient(135deg, #f0c24a, var(--gold) 55%, #9a6f0a); border: 2px solid #f6d77a; border-radius: 3px; cursor: pointer; box-shadow: 0 0 22px rgba(240,194,74,0.3); transition: transform .15s, filter .15s; }
|
||||||
|
.audio-choice small { font-family: 'EB Garamond', serif; font-weight: 500; font-size: 0.95rem; letter-spacing: 0; text-transform: none; }
|
||||||
|
.audio-choice:hover { transform: translateY(-2px); filter: brightness(1.08); }
|
||||||
|
.audio-choice--alt { background: transparent; color: var(--parchment); border-color: var(--gold); box-shadow: none; }
|
||||||
|
.audio-choice--alt:hover { background: rgba(184,134,11,0.18); filter: none; }
|
||||||
|
.audio-actions-row { display: flex; flex-wrap: wrap; gap: 0.7rem; justify-content: center; }
|
||||||
|
.audio-actions-row .audio-choice { flex: 1 1 14rem; }
|
||||||
|
.audio-skip { margin-top: 0.8rem; text-align: center; }
|
||||||
|
.audio-skip[hidden] { display: none; }
|
||||||
|
.audio-link { background: none; border: 0; color: var(--parchment-dark); font-family: 'EB Garamond', serif; font-size: 1rem; font-style: italic; text-decoration: underline; cursor: pointer; }
|
||||||
|
@media (max-width: 600px) { .audio-choices { grid-template-columns: 1fr; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -687,8 +703,8 @@
|
|||||||
<div class="audio-top">
|
<div class="audio-top">
|
||||||
<span class="audio-icon" aria-hidden="true">🎧</span>
|
<span class="audio-icon" aria-hidden="true">🎧</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="audio-title">Listen to the case</p>
|
<p class="audio-title" id="audio-title">Listen to the case</p>
|
||||||
<p class="audio-sub">The full case read aloud, about 10 minutes.</p>
|
<p class="audio-sub" id="audio-sub">The case read aloud, about 10 minutes. At the end you choose your plea.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<audio id="audio-el" preload="none"></audio>
|
<audio id="audio-el" preload="none"></audio>
|
||||||
@@ -699,6 +715,8 @@
|
|||||||
<span class="audio-time" id="audio-time">0:00</span>
|
<span class="audio-time" id="audio-time">0:00</span>
|
||||||
<button type="button" class="audio-btn audio-speed" id="audio-speed" aria-label="Playback speed">1×</button>
|
<button type="button" class="audio-btn audio-speed" id="audio-speed" aria-label="Playback speed">1×</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="audio-actions" id="audio-actions" hidden></div>
|
||||||
|
<p class="audio-skip" id="audio-skip-wrap"><button type="button" class="audio-link" id="audio-skip">Skip to the plea</button></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="gospel-message" aria-labelledby="good-news">
|
<section class="gospel-message" aria-labelledby="good-news">
|
||||||
@@ -1650,8 +1668,9 @@ buildTimeline();
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// ── Audio narration (shown only when a URL is set in Admin → Gospel Page) ──
|
// ── Audio narration (shown only when URLs are set in Admin → Gospel Page) ──
|
||||||
fetch('/api/salvation-audio').then(function (r) { return r.ok ? r.json() : { url: '' }; }).then(function (d) {
|
// The listener hears the case, then chooses Guilty or Not guilty and hears that branch.
|
||||||
|
fetch('/api/salvation-audio').then(function (r) { return r.ok ? r.json() : {}; }).then(function (d) {
|
||||||
if (!d || !d.url) return;
|
if (!d || !d.url) return;
|
||||||
const card = document.getElementById('audio-card');
|
const card = document.getElementById('audio-card');
|
||||||
const el = document.getElementById('audio-el');
|
const el = document.getElementById('audio-el');
|
||||||
@@ -1659,6 +1678,12 @@ buildTimeline();
|
|||||||
const seek = document.getElementById('audio-seek');
|
const seek = document.getElementById('audio-seek');
|
||||||
const timeEl = document.getElementById('audio-time');
|
const timeEl = document.getElementById('audio-time');
|
||||||
const speedBtn = document.getElementById('audio-speed');
|
const speedBtn = document.getElementById('audio-speed');
|
||||||
|
const titleEl = document.getElementById('audio-title');
|
||||||
|
const subEl = document.getElementById('audio-sub');
|
||||||
|
const actions = document.getElementById('audio-actions');
|
||||||
|
const skipWrap = document.getElementById('audio-skip-wrap');
|
||||||
|
let part = 'case';
|
||||||
|
|
||||||
el.src = d.url;
|
el.src = d.url;
|
||||||
card.hidden = false;
|
card.hidden = false;
|
||||||
const fmt = function (t) { if (!isFinite(t)) return '0:00'; const m = Math.floor(t / 60); const s = Math.floor(t % 60); return m + ':' + (s < 10 ? '0' : '') + s; };
|
const fmt = function (t) { if (!isFinite(t)) return '0:00'; const m = Math.floor(t / 60); const s = Math.floor(t % 60); return m + ':' + (s < 10 ? '0' : '') + s; };
|
||||||
@@ -1666,14 +1691,75 @@ buildTimeline();
|
|||||||
seek.value = el.duration ? (el.currentTime / el.duration) * 100 : 0;
|
seek.value = el.duration ? (el.currentTime / el.duration) * 100 : 0;
|
||||||
timeEl.textContent = fmt(el.currentTime) + (el.duration ? ' / ' + fmt(el.duration) : '');
|
timeEl.textContent = fmt(el.currentTime) + (el.duration ? ' / ' + fmt(el.duration) : '');
|
||||||
};
|
};
|
||||||
|
const setPlayIcon = function (playing) { playBtn.textContent = playing ? '❚❚' : '▶'; playBtn.setAttribute('aria-label', playing ? 'Pause' : 'Play'); };
|
||||||
|
const button = function (cls, html, onClick) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.className = cls; b.innerHTML = html; b.addEventListener('click', onClick);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
const show = function (nodes) { actions.innerHTML = ''; nodes.forEach(function (n) { actions.appendChild(n); }); actions.hidden = false; };
|
||||||
|
const hideActions = function () { actions.hidden = true; actions.innerHTML = ''; };
|
||||||
|
const reveal = function () { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
|
||||||
|
|
||||||
|
function showChoice() {
|
||||||
|
skipWrap.hidden = true;
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
const q = document.createElement('p'); q.className = 'audio-q'; q.textContent = 'How do you plead?';
|
||||||
|
const row = document.createElement('div'); row.className = 'audio-choices';
|
||||||
|
row.appendChild(button('audio-choice', 'Guilty<small>I accept the pardon</small>', function () { plead('guilty'); }));
|
||||||
|
row.appendChild(button('audio-choice audio-choice--alt', 'Not guilty<small>I stand on my own record</small>', function () { plead('not-guilty'); }));
|
||||||
|
wrap.appendChild(q); wrap.appendChild(row);
|
||||||
|
show([wrap]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playPart(name, url, title, sub) {
|
||||||
|
part = name;
|
||||||
|
hideActions();
|
||||||
|
titleEl.textContent = title; subEl.textContent = sub;
|
||||||
|
el.src = url; el.playbackRate = el.playbackRate || 1;
|
||||||
|
el.play().catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function plead(value) {
|
||||||
|
skipWrap.hidden = true;
|
||||||
|
// Keep the page in step with the audio: select the plea and open its panel without scrolling away.
|
||||||
|
choose(value, { scroll: false, setSelect: true });
|
||||||
|
const url = value === 'guilty' ? d.guiltyUrl : d.notGuiltyUrl;
|
||||||
|
if (!url) { choose(value, { scroll: true, setSelect: true }); return; }
|
||||||
|
playPart(value === 'guilty' ? 'guilty' : 'not-guilty', url,
|
||||||
|
value === 'guilty' ? 'You pleaded guilty' : 'You pleaded not guilty',
|
||||||
|
value === 'guilty' ? 'Here is what happens next.' : 'Please be sure you understand what this means.');
|
||||||
|
reveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnded() {
|
||||||
|
setPlayIcon(false);
|
||||||
|
if (part === 'case') { showChoice(); reveal(); return; }
|
||||||
|
const row = document.createElement('div'); row.className = 'audio-actions-row';
|
||||||
|
if (part === 'guilty') {
|
||||||
|
row.appendChild(button('audio-choice', 'Let us know', function () {
|
||||||
|
choose('guilty', { scroll: false, setSelect: true });
|
||||||
|
const pray = document.getElementById('btn-pray'); if (pray && pray.style.display !== 'none') pray.click();
|
||||||
|
document.getElementById('plea-guilty').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}));
|
||||||
|
row.appendChild(button('audio-choice audio-choice--alt', 'Read the record', function () { location.hash = '#record'; }));
|
||||||
|
} else {
|
||||||
|
row.appendChild(button('audio-choice', 'Change my plea to guilty', function () { plead('guilty'); }));
|
||||||
|
row.appendChild(button('audio-choice audio-choice--alt', 'Read the record', function () { location.hash = '#record'; }));
|
||||||
|
row.appendChild(button('audio-choice audio-choice--alt', 'Ask a question', function () { location.href = '/contact?source=salvation'; }));
|
||||||
|
}
|
||||||
|
show([row]); reveal();
|
||||||
|
}
|
||||||
|
|
||||||
playBtn.addEventListener('click', function () { if (el.paused) el.play(); else el.pause(); });
|
playBtn.addEventListener('click', function () { if (el.paused) el.play(); else el.pause(); });
|
||||||
el.addEventListener('play', function () { playBtn.textContent = '❚❚'; playBtn.setAttribute('aria-label', 'Pause'); });
|
el.addEventListener('play', function () { setPlayIcon(true); });
|
||||||
el.addEventListener('pause', function () { playBtn.textContent = '▶'; playBtn.setAttribute('aria-label', 'Play'); });
|
el.addEventListener('pause', function () { setPlayIcon(false); });
|
||||||
el.addEventListener('ended', function () { playBtn.textContent = '▶'; playBtn.setAttribute('aria-label', 'Play'); });
|
el.addEventListener('ended', onEnded);
|
||||||
el.addEventListener('timeupdate', paint);
|
el.addEventListener('timeupdate', paint);
|
||||||
el.addEventListener('loadedmetadata', paint);
|
el.addEventListener('loadedmetadata', paint);
|
||||||
seek.addEventListener('input', function () { if (el.duration) el.currentTime = (seek.value / 100) * el.duration; });
|
seek.addEventListener('input', function () { if (el.duration) el.currentTime = (seek.value / 100) * el.duration; });
|
||||||
document.getElementById('audio-back').addEventListener('click', function () { el.currentTime = Math.max(0, el.currentTime - 15); });
|
document.getElementById('audio-back').addEventListener('click', function () { el.currentTime = Math.max(0, el.currentTime - 15); });
|
||||||
|
document.getElementById('audio-skip').addEventListener('click', function () { el.pause(); showChoice(); });
|
||||||
const speeds = [1, 1.25, 1.5, 0.85];
|
const speeds = [1, 1.25, 1.5, 0.85];
|
||||||
let si = 0;
|
let si = 0;
|
||||||
speedBtn.addEventListener('click', function () { si = (si + 1) % speeds.length; el.playbackRate = speeds[si]; speedBtn.textContent = speeds[si] + '×'; });
|
speedBtn.addEventListener('click', function () { si = (si + 1) % speeds.length; el.playbackRate = speeds[si]; speedBtn.textContent = speeds[si] + '×'; });
|
||||||
@@ -1685,7 +1771,9 @@ buildTimeline();
|
|||||||
const notGuilty = document.getElementById('plea-notguilty');
|
const notGuilty = document.getElementById('plea-notguilty');
|
||||||
const cardG = document.getElementById('card-guilty');
|
const cardG = document.getElementById('card-guilty');
|
||||||
const cardN = document.getElementById('card-notguilty');
|
const cardN = document.getElementById('card-notguilty');
|
||||||
function choose(value) {
|
function choose(value, opts) {
|
||||||
|
const o = opts || {};
|
||||||
|
if (o.setSelect) select.value = value;
|
||||||
guilty.hidden = value !== 'guilty';
|
guilty.hidden = value !== 'guilty';
|
||||||
notGuilty.hidden = value !== 'not-guilty';
|
notGuilty.hidden = value !== 'not-guilty';
|
||||||
cardG.classList.toggle('is-selected', value === 'guilty');
|
cardG.classList.toggle('is-selected', value === 'guilty');
|
||||||
@@ -1693,7 +1781,7 @@ buildTimeline();
|
|||||||
cardG.classList.toggle('is-dim', value === 'not-guilty');
|
cardG.classList.toggle('is-dim', value === 'not-guilty');
|
||||||
cardN.classList.toggle('is-dim', value === 'guilty');
|
cardN.classList.toggle('is-dim', value === 'guilty');
|
||||||
const panel = value === 'guilty' ? guilty : value === 'not-guilty' ? notGuilty : null;
|
const panel = value === 'guilty' ? guilty : value === 'not-guilty' ? notGuilty : null;
|
||||||
if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
if (panel && o.scroll !== false) panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
select.addEventListener('change', function () { choose(select.value); });
|
select.addEventListener('change', function () { choose(select.value); });
|
||||||
document.getElementById('btn-change-plea').addEventListener('click', function () { select.value = 'guilty'; choose('guilty'); });
|
document.getElementById('btn-change-plea').addEventListener('click', function () { select.value = 'guilty'; choose('guilty'); });
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// Generates the /salvation audio from scripts/salvation/narration-case.txt using ElevenLabs.
|
// Generates the /salvation audio from the narration files in scripts/salvation/ using ElevenLabs.
|
||||||
|
// There are three tracks: the case, then the listener chooses Guilty or Not guilty.
|
||||||
//
|
//
|
||||||
// node --env-file=.env scripts/generate-salvation-audio.mjs --list-voices # find your voice id
|
// node --env-file=.env scripts/generate-salvation-audio.mjs --list-voices # find your voice id
|
||||||
// node --env-file=.env scripts/generate-salvation-audio.mjs # generate the MP3
|
// node --env-file=.env scripts/generate-salvation-audio.mjs --sample # ~40 seconds, to test the voice (cheap)
|
||||||
|
// node --env-file=.env scripts/generate-salvation-audio.mjs # generate all three MP3s
|
||||||
|
// node --env-file=.env scripts/generate-salvation-audio.mjs --only=guilty # just one: case | guilty | not-guilty
|
||||||
//
|
//
|
||||||
// Needs, in your local .env (never commit it):
|
// Needs, in your local .env (never commit it):
|
||||||
// ELEVENLABS_API_KEY=...
|
// ELEVENLABS_API_KEY=...
|
||||||
@@ -11,8 +14,8 @@
|
|||||||
// ELEVENLABS_STABILITY=0.5 (lower = more expressive, higher = steadier)
|
// ELEVENLABS_STABILITY=0.5 (lower = more expressive, higher = steadier)
|
||||||
// ELEVENLABS_SPEED=1
|
// ELEVENLABS_SPEED=1
|
||||||
//
|
//
|
||||||
// Output goes to audio-out/ (git-ignored). Upload the MP3 in Admin → Asset Manager, then paste its
|
// Output goes to audio-out/ (git-ignored). Upload the MP3s in Admin → Asset Manager, then paste their
|
||||||
// URL into Admin → Gospel Page.
|
// URLs into Admin → Gospel Page.
|
||||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
@@ -81,29 +84,65 @@ async function speak(text, previous, next) {
|
|||||||
return Buffer.from(await res.arrayBuffer())
|
return Buffer.from(await res.arrayBuffer())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRACKS = [
|
||||||
|
{ id: 'case', file: 'narration-1-case.txt', out: 'salvation-case.mp3' },
|
||||||
|
{ id: 'guilty', file: 'narration-2-guilty.txt', out: 'salvation-guilty.mp3' },
|
||||||
|
{ id: 'not-guilty', file: 'narration-3-not-guilty.txt', out: 'salvation-not-guilty.mp3' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function renderTrack(text, outPath) {
|
||||||
|
const chunks = chunkText(text)
|
||||||
|
const parts = []
|
||||||
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
|
process.stdout.write(` part ${i + 1}/${chunks.length}… `)
|
||||||
|
parts.push(await speak(chunks[i], chunks[i - 1], chunks[i + 1]))
|
||||||
|
console.log('done')
|
||||||
|
}
|
||||||
|
await writeFile(outPath, Buffer.concat(parts))
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
if (process.argv.includes('--list-voices')) { await listVoices(); return }
|
if (process.argv.includes('--list-voices')) { await listVoices(); return }
|
||||||
if (!voiceId) fail('ELEVENLABS_VOICE_ID is not set. Run with --list-voices to find it, then add it to .env.')
|
if (!voiceId) fail('ELEVENLABS_VOICE_ID is not set. Run with --list-voices to find it, then add it to .env.')
|
||||||
|
|
||||||
const src = path.join(ROOT, 'scripts/salvation/narration-case.txt')
|
|
||||||
const text = (await readFile(src, 'utf8')).trim()
|
|
||||||
const chunks = chunkText(text)
|
|
||||||
console.log(`\n${text.length.toLocaleString()} characters in ${chunks.length} parts (model ${model}).`)
|
|
||||||
console.log('This uses about that many ElevenLabs credits.\n')
|
|
||||||
|
|
||||||
const parts = []
|
|
||||||
for (let i = 0; i < chunks.length; i += 1) {
|
|
||||||
process.stdout.write(` part ${i + 1}/${chunks.length}… `)
|
|
||||||
parts.push(await speak(chunks[i], chunks[i - 1], chunks[i + 1]))
|
|
||||||
console.log('done')
|
|
||||||
}
|
|
||||||
|
|
||||||
const outDir = path.join(ROOT, 'audio-out')
|
const outDir = path.join(ROOT, 'audio-out')
|
||||||
await mkdir(outDir, { recursive: true })
|
await mkdir(outDir, { recursive: true })
|
||||||
const out = path.join(outDir, 'salvation-case.mp3')
|
|
||||||
await writeFile(out, Buffer.concat(parts))
|
if (process.argv.includes('--sample')) {
|
||||||
|
const full = (await readFile(path.join(ROOT, 'scripts/salvation', TRACKS[0].file), 'utf8')).trim()
|
||||||
|
// Whole paragraphs only, so the sample never stops mid-sentence (about 700 characters).
|
||||||
|
let sample = ''
|
||||||
|
for (const para of full.split(/\n{2,}/)) {
|
||||||
|
if (sample && sample.length + para.length > 750) break
|
||||||
|
sample = sample ? `${sample}\n\n${para}` : para
|
||||||
|
}
|
||||||
|
// Don't end on a lone heading like "The Accuser."
|
||||||
|
const lastBreak = sample.lastIndexOf('\n\n')
|
||||||
|
if (lastBreak > 0 && sample.length - lastBreak < 40) sample = sample.slice(0, lastBreak)
|
||||||
|
console.log(`\nSample: ${sample.length} characters (model ${model}).\n`)
|
||||||
|
const out = path.join(outDir, 'salvation-sample.mp3')
|
||||||
|
await renderTrack(sample, out)
|
||||||
|
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
|
||||||
|
console.log(`\nSaved ${out}\nIf the voice sounds right, run again without --sample.\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const only = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1]
|
||||||
|
const tracks = only ? TRACKS.filter(t => t.id === only) : TRACKS
|
||||||
|
if (!tracks.length) fail(`Unknown --only value "${only}". Use: case, guilty or not-guilty.`)
|
||||||
|
|
||||||
|
const texts = []
|
||||||
|
for (const t of tracks) texts.push((await readFile(path.join(ROOT, 'scripts/salvation', t.file), 'utf8')).trim())
|
||||||
|
const chars = texts.reduce((n, t) => n + t.length, 0)
|
||||||
|
console.log(`\n${tracks.length} track(s), ${chars.toLocaleString()} characters in total (model ${model}).`)
|
||||||
|
console.log('This uses about that many ElevenLabs credits.\n')
|
||||||
|
|
||||||
|
for (const [i, t] of tracks.entries()) {
|
||||||
|
console.log(` ${t.id} (${texts[i].length.toLocaleString()} characters)`)
|
||||||
|
await renderTrack(texts[i], path.join(outDir, t.out))
|
||||||
|
}
|
||||||
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
|
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
|
||||||
console.log(`\nSaved ${out}\nListen to it before you upload it. Check the Scripture names and references.\n`)
|
console.log(`\nSaved to ${outDir}:\n${tracks.map(t => ` ${t.out}`).join('\n')}\nListen to each one before you upload it. Check the Scripture names and references.\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(err => fail(err?.message ?? String(err)))
|
main().catch(err => fail(err?.message ?? String(err)))
|
||||||
@@ -54,22 +54,18 @@ Before you plead.
|
|||||||
|
|
||||||
Before you plead, read what Jesus did for you. Everything you have just heard tells you that the Substitute took your place. The record shows you how: the night he was arrested, the trials where he was declared innocent and condemned anyway, the long road to the cross, and the empty tomb on the third day.
|
Before you plead, read what Jesus did for you. Everything you have just heard tells you that the Substitute took your place. The record shows you how: the night he was arrested, the trials where he was declared innocent and condemned anyway, the long road to the cross, and the empty tomb on the third day.
|
||||||
|
|
||||||
Please do not skip it. It is the story of what Jesus did for you, told by four witnesses and woven into one account. Take your time and read it slowly. Then come back and enter your plea.
|
Please do not skip it. It is the story of what Jesus did for you, told by four witnesses and woven into one account. Take your time and read it slowly. You can pause this audio at any time, read the record, and then come back and press play. When you are ready, continue.
|
||||||
|
|
||||||
How do you plead?
|
How do you plead?
|
||||||
|
|
||||||
The court is not asking whether you are a good person. It is asking you to be honest about the charges, and to decide where you will stand: on your own record, or on what Jesus has done in your place.
|
The court is not asking whether you are a good person. It is asking you to be honest about the charges, and to decide where you will stand: on your own record, or on what Jesus has done in your place.
|
||||||
|
|
||||||
|
You have two choices.
|
||||||
|
|
||||||
You can plead guilty. That means: I admit it. I have broken God's law, whether I knew it or not, and I cannot save myself. I am not asking the court to find me innocent. I am asking for the pardon Jesus paid for with his life. And here is what happens: the Judge declares you forgiven, and counts you righteous because of Christ. The accuser has nothing left to say.
|
You can plead guilty. That means: I admit it. I have broken God's law, whether I knew it or not, and I cannot save myself. I am not asking the court to find me innocent. I am asking for the pardon Jesus paid for with his life. And here is what happens: the Judge declares you forgiven, and counts you righteous because of Christ. The accuser has nothing left to say.
|
||||||
|
|
||||||
Or you can plead not guilty. That means: I stand on my own record. I believe I have kept God's law well enough to be found innocent, and I do not need anyone to take my place. If that is your plea, please be sure you understand what it means. It means telling the Judge, "I have kept your law perfectly." God's standard is not "better than most people." It is perfection. Ask yourself honestly: Have you ever told a lie, even a small one? Ever taken something that was not yours? Ever put something else ahead of God? Ever hated someone in your heart? If the answer is yes, then by God's own standard, that plea does not hold. And it means standing before the Judge with no Substitute. But that is not what God wants for you. He takes no pleasure in anyone's death, and the offer of pardon is open right now. Scripture says, "Now is the day of salvation."
|
Or you can plead not guilty. That means: I stand on my own record. I believe I have kept God's law well enough to be found innocent, and I do not need anyone to take my place. That plea has a cost, and I will explain it if you choose it.
|
||||||
|
|
||||||
If you are ready to plead guilty and receive the pardon, you can tell God in your own words, or you can pray something like this:
|
Now look at the two buttons below the player. When you are ready, press the one that matches your plea, and I will walk you through what happens next.
|
||||||
|
|
||||||
"Lord God, I plead guilty. I have sinned against you, and I cannot save myself. I believe Jesus Christ took my place on the cross, died for my sins, and rose again. I turn from my sin, and I trust him alone. Thank you for the pardon I could never earn. Make me new, and help me follow you. Amen."
|
Take a moment. Be honest with God, and honest with yourself.
|
||||||
|
|
||||||
If you prayed that and meant it, then God has forgiven you. "Therefore, there is now no condemnation for those who are in Christ Jesus." Your feelings may go up and down, but the verdict does not change.
|
|
||||||
|
|
||||||
Here is what to do next. Tell a Christian friend or family member what you decided. Begin reading the Gospel of John, and keep going through the New Testament. Talk to God every day. Find a Bible-teaching church where you can grow, and be baptized as a public sign of your faith. And if you would like, let us know, and someone from our team will reach out to you.
|
|
||||||
|
|
||||||
Welcome to the family of God.
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
You have pleaded guilty. That is the beginning of the good news.
|
||||||
|
|
||||||
|
You are not earning anything by saying this. You are receiving a gift. Romans, chapter six, verse twenty-three: "For the wages of sin is death, but the gift of God is eternal life in Christ Jesus our Lord." Here is what to do next.
|
||||||
|
|
||||||
|
First, tell God. Words do not save you. Trusting Jesus does. But you can say it to him in your own words, or you can pray this prayer. If you mean it, pray it out loud with me.
|
||||||
|
|
||||||
|
"Lord God, I plead guilty. I have sinned against you, and I cannot save myself. I believe Jesus Christ took my place on the cross, died for my sins, and rose again. I turn from my sin, and I trust him alone. Thank you for the pardon I could never earn. Make me new, and help me follow you. Amen."
|
||||||
|
|
||||||
|
Second, believe it is finished. If you meant that prayer, God has forgiven you. Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." Your feelings may go up and down, but the verdict does not change.
|
||||||
|
|
||||||
|
Third, tell someone. Tell a Christian friend or family member what you decided.
|
||||||
|
|
||||||
|
Fourth, start reading. Begin with the Gospel of John, then keep going through the New Testament, and talk to God every day.
|
||||||
|
|
||||||
|
Fifth, find a church. Join a Bible-teaching church where you can grow, and be baptized as a public sign of your faith. Acts, chapter two, verse thirty-eight.
|
||||||
|
|
||||||
|
And if you prayed that prayer and meant it, we would love to rejoice with you. Press the button below that says, Let us know. You can leave your name and your email, and someone from our team will reach out to you personally. It is completely optional.
|
||||||
|
|
||||||
|
Welcome to the family of God.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
You have pleaded not guilty. Please be sure you understand what that means.
|
||||||
|
|
||||||
|
Pleading not guilty is not just saying, "I am not a bad person." It is telling the Judge, "I have kept your law perfectly, and I need no one to take my place."
|
||||||
|
|
||||||
|
God's standard is not "better than most people." It is perfection. Matthew, chapter five, verse forty-eight: "Be perfect, therefore, as your heavenly Father is perfect." And breaking that standard at even one point makes a person guilty of breaking all of it. James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." That includes the things done without knowing they were wrong.
|
||||||
|
|
||||||
|
So test your plea honestly.
|
||||||
|
|
||||||
|
Have you ever told a lie, even a small one?
|
||||||
|
|
||||||
|
Have you ever taken something that was not yours?
|
||||||
|
|
||||||
|
Have you ever used God's name carelessly, or put something else ahead of him: money, a person, yourself?
|
||||||
|
|
||||||
|
Have you ever hated someone in your heart, or looked at someone with lust? Jesus said those count too. Matthew, chapter five.
|
||||||
|
|
||||||
|
If the answer to any of those is yes, then by God's own standard, the plea does not hold. First John, chapter one, verse eight: "If we say that we have no sin, we are deceiving ourselves, and the truth is not in us."
|
||||||
|
|
||||||
|
And here is what a not guilty plea costs. It means standing before the Judge on your own record, with no Substitute. The sentence for sin stands. Romans, chapter six, verse twenty-three. But that is not what God wants for you. He says he takes no pleasure in anyone's death. Ezekiel, chapter thirty-three, verse eleven. And the offer of pardon is open right now. Second Corinthians, chapter six, verse two: "Now is the day of salvation."
|
||||||
|
|
||||||
|
If you are not sure, that is okay. Questions and doubts are fine. Asking honestly is not the same as refusing.
|
||||||
|
|
||||||
|
If you would like to change your plea, press the button below that says, Change my plea to guilty. You can also read the record of what Jesus did for you, or ask us a question. Whatever you decide, thank you for listening honestly. The offer of the pardon is still open.
|
||||||
@@ -227,11 +227,15 @@ function buildSalvationConfirmationEmail({ name }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
// The /salvation page is static HTML, so it asks the server for the narration URL set in Admin → Gospel Page.
|
// The /salvation page is static HTML, so it asks the server for the narration URLs set in Admin → Gospel Page.
|
||||||
app.get('/api/salvation-audio', (_req, res) => {
|
app.get('/api/salvation-audio', (_req, res) => {
|
||||||
const url = sanitizeUrl(state.cachedSiteContent?.salvationAudioUrl) || ''
|
const c = state.cachedSiteContent ?? {}
|
||||||
res.set('Cache-Control', 'public, max-age=300')
|
res.set('Cache-Control', 'public, max-age=300')
|
||||||
res.json({ url })
|
res.json({
|
||||||
|
url: sanitizeUrl(c.salvationAudioUrl) || '',
|
||||||
|
guiltyUrl: sanitizeUrl(c.salvationAudioGuiltyUrl) || '',
|
||||||
|
notGuiltyUrl: sanitizeUrl(c.salvationAudioNotGuiltyUrl) || '',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/salvation-response', rateLimit, async (req, res) => {
|
app.post('/api/salvation-response', rateLimit, async (req, res) => {
|
||||||
|
|||||||
+12
-12
@@ -7031,21 +7031,21 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
</div>
|
</div>
|
||||||
<div className="admin-panel-subhead">
|
<div className="admin-panel-subhead">
|
||||||
<h3>Audio narration</h3>
|
<h3>Audio narration</h3>
|
||||||
<p>An audio version of The Case, with a player at the top of the page. Leave this blank to hide the player.</p>
|
<p>A choose-your-path audio version. Listeners hear The Case, then choose Guilty or Not guilty and hear the matching track. Leave the first field blank to hide the player. If the two plea tracks are blank, the plea buttons just select the plea on the page.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
{([
|
||||||
<label htmlFor="field-salvationAudioUrl">Audio file URL (MP3)</label>
|
['salvationAudioUrl', '1. The Case (MP3)'],
|
||||||
<input id="field-salvationAudioUrl" type="text" placeholder="https://…/salvation-case.mp3" value={form.salvationAudioUrl ?? ''} onChange={e => handleChange('salvationAudioUrl', e.target.value)} />
|
['salvationAudioGuiltyUrl', '2. Guilty: next steps and prayer (MP3)'],
|
||||||
</div>
|
['salvationAudioNotGuiltyUrl', '3. Not guilty: what it means (MP3)'],
|
||||||
{form.salvationAudioUrl?.trim() && (
|
] as const).map(([key, label]) => (
|
||||||
<div className="admin-field">
|
<div className="admin-field" key={key}>
|
||||||
<label>Preview</label>
|
<label htmlFor={`field-${key}`}>{label}</label>
|
||||||
<audio controls preload="none" src={form.salvationAudioUrl.trim()} style={{ width: '100%' }} />
|
<input id={`field-${key}`} type="text" placeholder="https://…/salvation-case.mp3" value={form[key] ?? ''} onChange={e => handleChange(key, e.target.value)} />
|
||||||
|
{form[key]?.trim() && <audio controls preload="none" src={form[key].trim()} style={{ width: '100%', marginTop: 8 }} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
<p className="admin-stats-note">
|
<p className="admin-stats-note">
|
||||||
To make the file: put your ElevenLabs key and voice in your local <code>.env</code>, then run{' '}
|
To make the files: put your ElevenLabs key and voice in your local <code>.env</code>, try <code>node --env-file=.env scripts/generate-salvation-audio.mjs --sample</code> first, then run it without <code>--sample</code> to make all three. Upload the MP3s in Asset Manager and paste their URLs here, then Save and Publish.
|
||||||
<code>node --env-file=.env scripts/generate-salvation-audio.mjs</code>. Upload the MP3 in Asset Manager and paste its URL here, then Save and Publish.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ export interface SiteContent {
|
|||||||
prismVideoUrl: string
|
prismVideoUrl: string
|
||||||
// Audio narration of the /salvation page (served to the static page via /api/salvation-audio)
|
// Audio narration of the /salvation page (served to the static page via /api/salvation-audio)
|
||||||
salvationAudioUrl: string
|
salvationAudioUrl: string
|
||||||
|
salvationAudioGuiltyUrl: string
|
||||||
|
salvationAudioNotGuiltyUrl: string
|
||||||
prismEyebrow: string
|
prismEyebrow: string
|
||||||
prismHeading: string
|
prismHeading: string
|
||||||
prismIntro: string
|
prismIntro: string
|
||||||
@@ -531,6 +533,8 @@ export const DEFAULTS: SiteContent = {
|
|||||||
shareQrImageUrl: '/images/qr-code.jpg',
|
shareQrImageUrl: '/images/qr-code.jpg',
|
||||||
prismVideoUrl: 'https://files.versebyversewithnate.us/images/PRISM.mp4',
|
prismVideoUrl: 'https://files.versebyversewithnate.us/images/PRISM.mp4',
|
||||||
salvationAudioUrl: '',
|
salvationAudioUrl: '',
|
||||||
|
salvationAudioGuiltyUrl: '',
|
||||||
|
salvationAudioNotGuiltyUrl: '',
|
||||||
prismEyebrow: 'Featured Video',
|
prismEyebrow: 'Featured Video',
|
||||||
prismHeading: 'PRISM',
|
prismHeading: 'PRISM',
|
||||||
prismIntro:
|
prismIntro:
|
||||||
|
|||||||
Reference in new issue
Block a user