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,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 # 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):
|
||||
// ELEVENLABS_API_KEY=...
|
||||
@@ -11,8 +14,8 @@
|
||||
// ELEVENLABS_STABILITY=0.5 (lower = more expressive, higher = steadier)
|
||||
// ELEVENLABS_SPEED=1
|
||||
//
|
||||
// Output goes to audio-out/ (git-ignored). Upload the MP3 in Admin → Asset Manager, then paste its
|
||||
// URL into Admin → Gospel Page.
|
||||
// Output goes to audio-out/ (git-ignored). Upload the MP3s in Admin → Asset Manager, then paste their
|
||||
// URLs into Admin → Gospel Page.
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -81,29 +84,65 @@ async function speak(text, previous, next) {
|
||||
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() {
|
||||
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.')
|
||||
|
||||
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')
|
||||
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.`)
|
||||
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)))
|
||||
Reference in new issue
Block a user