Files
Siteforge/scripts/generate-salvation-audio.mjs
T

149 lines
6.6 KiB
JavaScript

// 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 --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=...
// ELEVENLABS_VOICE_ID=... (from --list-voices, or your cloned voice)
// Optional:
// ELEVENLABS_MODEL=eleven_multilingual_v2
// ELEVENLABS_STABILITY=0.5 (lower = more expressive, higher = steadier)
// ELEVENLABS_SPEED=1
//
// 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'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const API = 'https://api.elevenlabs.io/v1'
const apiKey = process.env.ELEVENLABS_API_KEY
const voiceId = process.env.ELEVENLABS_VOICE_ID
const model = process.env.ELEVENLABS_MODEL || 'eleven_multilingual_v2'
const MAX_CHARS = 4500
let totalCost = 0
function fail(message) {
console.error(`\n${message}\n`)
process.exit(1)
}
if (!apiKey) fail('ELEVENLABS_API_KEY is not set. Add it to your local .env file (not to chat or to git).')
async function listVoices() {
const res = await fetch(`${API}/voices`, { headers: { 'xi-api-key': apiKey } })
if (!res.ok) fail(`Could not list voices (${res.status}). Check the API key.`)
const { voices = [] } = await res.json()
console.log('\nYour voices (name: voice id)\n')
for (const v of voices) console.log(` ${v.name}${v.category ? ` [${v.category}]` : ''}: ${v.voice_id}`)
console.log('\nPut the one you want in .env as ELEVENLABS_VOICE_ID=...\n')
}
// Split on paragraph boundaries so each request stays under MAX_CHARS and never cuts a sentence.
function chunkText(text) {
const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(Boolean)
const chunks = []
let current = ''
for (const p of paragraphs) {
if (current && current.length + p.length + 2 > MAX_CHARS) { chunks.push(current); current = '' }
current = current ? `${current}\n\n${p}` : p
}
if (current) chunks.push(current)
return chunks
}
async function speak(text, previous, next) {
const res = await fetch(`${API}/text-to-speech/${voiceId}?output_format=mp3_44100_128`, {
method: 'POST',
headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
body: JSON.stringify({
text,
model_id: model,
voice_settings: {
stability: Number(process.env.ELEVENLABS_STABILITY ?? 0.5),
similarity_boost: 0.75,
style: 0,
use_speaker_boost: true,
speed: Number(process.env.ELEVENLABS_SPEED ?? 1),
},
...(previous ? { previous_text: previous.slice(-600) } : {}),
...(next ? { next_text: next.slice(0, 600) } : {}),
}),
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
fail(`ElevenLabs returned ${res.status}.\n${detail.slice(0, 400)}`)
}
const cost = res.headers.get('character-cost')
if (cost) totalCost += Number(cost) || 0
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 outDir = path.join(ROOT, 'audio-out')
await mkdir(outDir, { recursive: true })
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 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)))