Publish Container / build (push) Successful in 17m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
110 lines
4.5 KiB
JavaScript
110 lines
4.5 KiB
JavaScript
// Generates the /salvation audio from scripts/salvation/narration-case.txt using ElevenLabs.
|
|
//
|
|
// 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
|
|
//
|
|
// 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 MP3 in Admin → Asset Manager, then paste its
|
|
// URL 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())
|
|
}
|
|
|
|
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 (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`)
|
|
}
|
|
|
|
main().catch(err => fail(err?.message ?? String(err)))
|