skills/elevenlabs-remotion/generate.js

1509 lines
47 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* ElevenLabs Voiceover Generator with Request Stitching
*
* Features:
* - Single text generation
* - Scene-based generation with request stitching for consistent prosody
* - Character/narrator modes for natural voiceovers
* - Pronunciation dictionaries for brand names and terms
* - Single scene regeneration for fine-tuning
* - Automatic timing validation with ffprobe
* - Silence detection
* - Individual scene files + combined output
*
* Usage:
* node generate.js --text "Your text" --output output.mp3
* node generate.js --scenes scenes.json --output-dir public/audio/
* node generate.js --scenes scenes.json --scene scene2 --output-dir public/audio/
* node generate.js --validate public/audio/project/
* node generate.js --list-voices
* node generate.js --list-dictionaries
* node generate.js --embed-thumbnail video.mp4 --thumbnail thumb.png --output video-with-thumb.mp4
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const { execSync } = require('child_process');
// Paths
const SKILL_DIR = path.dirname(__filename);
const DICT_DIR = path.join(SKILL_DIR, 'dictionaries');
const DICT_CACHE_FILE = path.join(SKILL_DIR, '.dictionary-cache.json');
// Default dictionary (set to null for no default, or specify your dictionary name)
const DEFAULT_DICTIONARY = null;
// Load environment variables from .env.local
function loadEnv() {
const envPath = path.join(process.cwd(), '.env.local');
if (fs.existsSync(envPath)) {
const content = fs.readFileSync(envPath, 'utf-8');
content.split('\n').forEach(line => {
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
process.env[key.trim()] = valueParts.join('=').trim();
}
});
}
}
loadEnv();
const API_KEY = process.env.ELEVENLABS_API_KEY;
if (!API_KEY && !process.argv.includes('--validate') && !process.argv.includes('--help') && !process.argv.includes('-h') && !process.argv.includes('--list-dictionaries')) {
console.error('Error: ELEVENLABS_API_KEY not found in .env.local');
process.exit(1);
}
// Timing thresholds for validation
const TIMING_CONFIG = {
maxDurationDiffPercent: 15,
maxLeadingSilenceMs: 200,
maxTrailingSilenceMs: 500,
minWordsPerSecond: 2.0,
maxWordsPerSecond: 4.5,
idealWordsPerSecond: 3.0,
};
// Character/narrator presets
const CHARACTER_PRESETS = {
literal: {
name: 'Literal',
description: 'Reads text exactly as written',
stability: 0.5,
similarity: 0.75,
style: 0.0,
},
narrator: {
name: 'Narrator',
description: 'Professional storyteller, smooth transitions, engaging',
stability: 0.65,
similarity: 0.8,
style: 0.15,
},
salesperson: {
name: 'Salesperson',
description: 'Enthusiastic, persuasive, energetic delivery',
stability: 0.4,
similarity: 0.75,
style: 0.35,
},
expert: {
name: 'Expert',
description: 'Authoritative, confident, knowledgeable tone',
stability: 0.7,
similarity: 0.85,
style: 0.1,
},
conversational: {
name: 'Conversational',
description: 'Casual, friendly, like talking to a friend',
stability: 0.45,
similarity: 0.7,
style: 0.25,
},
dramatic: {
name: 'Dramatic',
description: 'Intense, emotional, high impact delivery',
stability: 0.35,
similarity: 0.75,
style: 0.5,
},
calm: {
name: 'Calm',
description: 'Soothing, reassuring, gentle delivery',
stability: 0.8,
similarity: 0.85,
style: 0.05,
},
};
// ============================================
// PRONUNCIATION DICTIONARY FUNCTIONS
// ============================================
/**
* Load dictionary cache
*/
function loadDictionaryCache() {
if (fs.existsSync(DICT_CACHE_FILE)) {
try {
return JSON.parse(fs.readFileSync(DICT_CACHE_FILE, 'utf-8'));
} catch {
return {};
}
}
return {};
}
/**
* Parse PLS dictionary file and extract grapheme->alias mappings
* Used as fallback when API dictionary permissions are missing
*/
function parsePLSDictionary(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const mappings = [];
// Simple regex to extract grapheme and alias pairs
const lexemeRegex = /<lexeme>[\s\S]*?<grapheme>([^<]+)<\/grapheme>[\s\S]*?<alias>([^<]+)<\/alias>[\s\S]*?<\/lexeme>/gi;
let match;
while ((match = lexemeRegex.exec(content)) !== null) {
mappings.push({
grapheme: match[1].trim(),
alias: match[2].trim(),
});
}
// Sort by grapheme length descending to avoid partial replacements
mappings.sort((a, b) => b.grapheme.length - a.grapheme.length);
return mappings;
}
/**
* Apply pronunciation dictionary as text preprocessing fallback
* Used when dictionary API permissions are missing
*/
function applyDictionaryFallback(text, dictName) {
const dictPath = path.join(DICT_DIR, `${dictName}.pls`);
if (!fs.existsSync(dictPath)) {
return text;
}
const mappings = parsePLSDictionary(dictPath);
let processedText = text;
for (const { grapheme, alias } of mappings) {
// Use case-insensitive replacement while preserving word boundaries where appropriate
const regex = new RegExp(escapeRegex(grapheme), 'gi');
processedText = processedText.replace(regex, alias);
}
return processedText;
}
/**
* Escape special regex characters
*/
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Save dictionary cache
*/
function saveDictionaryCache(cache) {
fs.writeFileSync(DICT_CACHE_FILE, JSON.stringify(cache, null, 2));
}
/**
* Upload a pronunciation dictionary file to ElevenLabs
*/
async function uploadDictionary(filePath, name) {
const fileContent = fs.readFileSync(filePath);
const fileName = path.basename(filePath);
// Create multipart form data boundary
const boundary = '----ElevenLabsBoundary' + Date.now();
// Build multipart body
let body = '';
body += `--${boundary}\r\n`;
body += `Content-Disposition: form-data; name="name"\r\n\r\n`;
body += `${name}\r\n`;
body += `--${boundary}\r\n`;
body += `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`;
body += `Content-Type: application/xml\r\n\r\n`;
const bodyStart = Buffer.from(body, 'utf-8');
const bodyEnd = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf-8');
const fullBody = Buffer.concat([bodyStart, fileContent, bodyEnd]);
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'api.elevenlabs.io',
path: '/v1/pronunciation-dictionaries/add-from-file',
method: 'POST',
headers: {
'xi-api-key': API_KEY,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': fullBody.length,
},
}, (res) => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
const data = Buffer.concat(chunks).toString();
try {
const json = JSON.parse(data);
if (res.statusCode === 200 || res.statusCode === 201) {
resolve(json);
} else {
reject(new Error(`Upload failed: ${json.detail?.message || json.detail || data}`));
}
} catch {
reject(new Error(`Upload failed: ${data}`));
}
});
});
req.on('error', reject);
req.write(fullBody);
req.end();
});
}
/**
* List all pronunciation dictionaries
*/
async function listDictionaries() {
const response = await makeRequest({
hostname: 'api.elevenlabs.io',
path: '/v1/pronunciation-dictionaries',
method: 'GET',
headers: { 'xi-api-key': API_KEY },
});
if (response.status !== 200) {
throw new Error('Failed to list dictionaries');
}
return response.data.pronunciation_dictionaries || [];
}
/**
* Get or create a dictionary, returns { id, versionId }
*/
async function getOrCreateDictionary(dictName) {
// Check cache first
const cache = loadDictionaryCache();
if (cache[dictName]) {
console.log(` 📖 Using cached dictionary: ${dictName}`);
return cache[dictName];
}
// Check if dictionary file exists
const dictPath = path.join(DICT_DIR, `${dictName}.pls`);
if (!fs.existsSync(dictPath)) {
throw new Error(`Dictionary file not found: ${dictPath}`);
}
// Check if already exists on ElevenLabs
const existing = await listDictionaries();
const found = existing.find(d => d.name === dictName);
if (found) {
const result = {
id: found.id,
versionId: found.latest_version_id,
};
cache[dictName] = result;
saveDictionaryCache(cache);
console.log(` 📖 Found existing dictionary: ${dictName}`);
return result;
}
// Upload new dictionary
console.log(` 📤 Uploading dictionary: ${dictName}`);
const uploaded = await uploadDictionary(dictPath, dictName);
const result = {
id: uploaded.id,
versionId: uploaded.version_id,
};
cache[dictName] = result;
saveDictionaryCache(cache);
return result;
}
/**
* Display available dictionaries
*/
async function displayDictionaries() {
console.log('\n📖 Pronunciation Dictionaries\n');
// Local dictionaries
console.log('Local dictionaries (in .claude/skills/elevenlabs/dictionaries/):');
if (fs.existsSync(DICT_DIR)) {
const files = fs.readdirSync(DICT_DIR).filter(f => f.endsWith('.pls'));
if (files.length === 0) {
console.log(' (none)');
} else {
files.forEach(f => {
const name = f.replace('.pls', '');
console.log(` - ${name} (${f})`);
});
}
} else {
console.log(' (directory not found)');
}
// Remote dictionaries
if (API_KEY) {
console.log('\nRemote dictionaries (on ElevenLabs):');
try {
const remote = await listDictionaries();
if (remote.length === 0) {
console.log(' (none)');
} else {
remote.forEach(d => {
console.log(` - ${d.name} (id: ${d.id})`);
});
}
} catch (e) {
console.log(` (error: ${e.message})`);
}
}
// Cache
console.log('\nCached dictionary IDs:');
const cache = loadDictionaryCache();
const keys = Object.keys(cache);
if (keys.length === 0) {
console.log(' (none)');
} else {
keys.forEach(k => {
console.log(` - ${k}: ${cache[k].id}`);
});
}
console.log('\nUsage:');
console.log(' --dictionary mybrand # Use local dictionary by name');
console.log(' --no-dictionary # Disable dictionary');
}
// ============================================
// TIMING VALIDATION FUNCTIONS
// ============================================
function getAudioDuration(filePath) {
try {
const result = execSync(
`ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${filePath}"`,
{ encoding: 'utf-8' }
);
return parseFloat(result.trim());
} catch {
console.warn(` ⚠ Could not get duration for ${filePath} (ffprobe not available?)`);
return null;
}
}
function detectSilence(filePath) {
try {
const result = execSync(
`ffmpeg -i "${filePath}" -af silencedetect=noise=-30dB:d=0.1 -f null - 2>&1`,
{ encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
);
const silenceStarts = [];
const silenceEnds = [];
const lines = result.split('\n');
for (const line of lines) {
const startMatch = line.match(/silence_start: ([\d.]+)/);
const endMatch = line.match(/silence_end: ([\d.]+)/);
if (startMatch) silenceStarts.push(parseFloat(startMatch[1]));
if (endMatch) silenceEnds.push(parseFloat(endMatch[1]));
}
const duration = getAudioDuration(filePath);
let leadingSilence = 0;
if (silenceStarts.length > 0 && silenceStarts[0] < 0.05) {
leadingSilence = silenceEnds[0] || 0;
}
let trailingSilence = 0;
if (silenceEnds.length > 0 && duration) {
const lastEnd = silenceEnds[silenceEnds.length - 1];
const lastStart = silenceStarts[silenceStarts.length - 1];
if (Math.abs(lastEnd - duration) < 0.1 || lastEnd > duration - 0.1) {
trailingSilence = duration - lastStart;
}
}
return { leadingSilence, trailingSilence };
} catch {
return { leadingSilence: 0, trailingSilence: 0 };
}
}
function countWords(text) {
return text.trim().split(/\s+/).filter(w => w.length > 0).length;
}
function validateTiming(filePath, text, expectedDuration = null) {
const issues = [];
const warnings = [];
const actualDuration = getAudioDuration(filePath);
if (!actualDuration) {
return { actualDuration: null, issues: ['Could not determine audio duration'], warnings: [], silence: null, wordsPerSecond: null };
}
if (expectedDuration) {
const diffPercent = Math.abs(actualDuration - expectedDuration) / expectedDuration * 100;
if (diffPercent > TIMING_CONFIG.maxDurationDiffPercent) {
const diff = actualDuration - expectedDuration;
if (diff > 0) {
issues.push(`Audio ${diff.toFixed(2)}s longer than expected (${actualDuration.toFixed(2)}s vs ${expectedDuration}s)`);
} else {
issues.push(`Audio ${Math.abs(diff).toFixed(2)}s shorter than expected (${actualDuration.toFixed(2)}s vs ${expectedDuration}s)`);
}
}
}
const silence = detectSilence(filePath);
if (silence.leadingSilence > TIMING_CONFIG.maxLeadingSilenceMs / 1000) {
warnings.push(`Leading silence: ${(silence.leadingSilence * 1000).toFixed(0)}ms (may start late)`);
}
if (silence.trailingSilence > TIMING_CONFIG.maxTrailingSilenceMs / 1000) {
warnings.push(`Trailing silence: ${(silence.trailingSilence * 1000).toFixed(0)}ms`);
}
const wordCount = countWords(text);
const speakingDuration = actualDuration - silence.leadingSilence - silence.trailingSilence;
const wordsPerSecond = wordCount / speakingDuration;
if (wordsPerSecond < TIMING_CONFIG.minWordsPerSecond) {
warnings.push(`Speaking rate slow: ${wordsPerSecond.toFixed(1)} words/sec (target: ${TIMING_CONFIG.idealWordsPerSecond})`);
} else if (wordsPerSecond > TIMING_CONFIG.maxWordsPerSecond) {
warnings.push(`Speaking rate fast: ${wordsPerSecond.toFixed(1)} words/sec (target: ${TIMING_CONFIG.idealWordsPerSecond})`);
}
return {
actualDuration,
expectedDuration,
issues,
warnings,
silence,
wordsPerSecond,
wordCount,
};
}
async function validateProject(outputDir) {
const files = fs.readdirSync(outputDir);
const infoFile = files.find(f => f.endsWith('-info.json'));
if (!infoFile) {
console.error(`No info file found in ${outputDir}`);
process.exit(1);
}
const infoPath = path.join(outputDir, infoFile);
const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
console.log(`\n🔍 Validating ${info.name} (${info.totalScenes} scenes)\n`);
let hasIssues = false;
let hasWarnings = false;
const updatedScenes = [];
for (const scene of info.scenes) {
const filePath = path.join(outputDir, scene.file);
if (!fs.existsSync(filePath)) {
console.log(`${scene.id}: File not found - ${scene.file}`);
hasIssues = true;
updatedScenes.push(scene);
continue;
}
const validation = validateTiming(filePath, scene.text, scene.duration);
const updatedScene = {
...scene,
actualDuration: validation.actualDuration,
wordsPerSecond: validation.wordsPerSecond ? parseFloat(validation.wordsPerSecond.toFixed(2)) : null,
leadingSilence: validation.silence?.leadingSilence ? parseFloat(validation.silence.leadingSilence.toFixed(3)) : 0,
trailingSilence: validation.silence?.trailingSilence ? parseFloat(validation.silence.trailingSilence.toFixed(3)) : 0,
};
updatedScenes.push(updatedScene);
const status = validation.issues.length > 0 ? '❌' : validation.warnings.length > 0 ? '⚠️' : '✅';
console.log(`${status} ${scene.id}: ${validation.actualDuration?.toFixed(2)}s (expected: ${scene.duration || 'N/A'}s)`);
if (validation.issues.length > 0) {
hasIssues = true;
validation.issues.forEach(i => console.log(`${i}`));
}
if (validation.warnings.length > 0) {
hasWarnings = true;
validation.warnings.forEach(w => console.log(` ⚠️ ${w}`));
}
if (validation.wordsPerSecond) {
const rateIcon = validation.wordsPerSecond < TIMING_CONFIG.minWordsPerSecond ? '🐢' :
validation.wordsPerSecond > TIMING_CONFIG.maxWordsPerSecond ? '🐇' : '👍';
console.log(` ${rateIcon} ${validation.wordCount} words @ ${validation.wordsPerSecond.toFixed(1)} words/sec`);
}
}
info.scenes = updatedScenes;
info.validatedAt = new Date().toISOString();
fs.writeFileSync(infoPath, JSON.stringify(info, null, 2));
console.log('\n' + '─'.repeat(50));
const totalActual = updatedScenes.reduce((sum, s) => sum + (s.actualDuration || 0), 0);
const totalExpected = updatedScenes.reduce((sum, s) => sum + (s.duration || 0), 0);
console.log(`📊 Total duration: ${totalActual.toFixed(2)}s (expected: ${totalExpected.toFixed(2)}s)`);
if (hasIssues) {
console.log('\n❌ Issues found - consider regenerating affected scenes');
console.log(' Example: node generate.js --scenes <file> --scene <id> --output-dir ' + outputDir);
} else if (hasWarnings) {
console.log('\n⚠ Warnings found - review timing for best results');
} else {
console.log('\n✅ All scenes passed validation!');
}
console.log(`\n📝 Updated: ${infoFile} (with actual durations)`);
return { hasIssues, hasWarnings };
}
// ============================================
// ARGUMENT PARSING
// ============================================
function parseArgs() {
const args = process.argv.slice(2);
const options = {
text: null,
file: null,
output: 'output.mp3',
outputDir: null,
voice: 'George',
model: 'eleven_multilingual_v2',
stability: 0.5,
similarity: 0.75,
style: 0.0,
speed: 1.0,
listVoices: false,
listCharacters: false,
listDictionaries: false,
scenes: null,
scene: null,
character: null,
combined: true,
newText: null,
validate: null,
skipValidation: false,
dictionary: DEFAULT_DICTIONARY,
noDictionary: false,
embedThumbnail: null,
thumbnail: null,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const nextArg = args[i + 1];
switch (arg) {
case '--text':
case '-t':
options.text = nextArg;
i++;
break;
case '--file':
case '-f':
options.file = nextArg;
i++;
break;
case '--output':
case '-o':
options.output = nextArg;
i++;
break;
case '--output-dir':
options.outputDir = nextArg;
i++;
break;
case '--voice':
case '-v':
options.voice = nextArg;
i++;
break;
case '--model':
case '-m':
options.model = nextArg;
i++;
break;
case '--stability':
options.stability = parseFloat(nextArg);
i++;
break;
case '--similarity':
options.similarity = parseFloat(nextArg);
i++;
break;
case '--style':
options.style = parseFloat(nextArg);
i++;
break;
case '--speed':
options.speed = parseFloat(nextArg);
i++;
break;
case '--list-voices':
options.listVoices = true;
break;
case '--list-characters':
options.listCharacters = true;
break;
case '--list-dictionaries':
options.listDictionaries = true;
break;
case '--scenes':
options.scenes = nextArg;
i++;
break;
case '--scene':
options.scene = nextArg;
i++;
break;
case '--character':
case '-c':
options.character = nextArg;
i++;
break;
case '--new-text':
options.newText = nextArg;
i++;
break;
case '--no-combined':
options.combined = false;
break;
case '--validate':
options.validate = nextArg;
i++;
break;
case '--skip-validation':
options.skipValidation = true;
break;
case '--dictionary':
case '-d':
options.dictionary = nextArg;
i++;
break;
case '--no-dictionary':
options.noDictionary = true;
options.dictionary = null;
break;
case '--embed-thumbnail':
options.embedThumbnail = nextArg;
i++;
break;
case '--thumbnail':
options.thumbnail = nextArg;
i++;
break;
case '--help':
case '-h':
printHelp();
process.exit(0);
}
}
if (options.character) {
const preset = CHARACTER_PRESETS[options.character.toLowerCase()];
if (preset) {
options.stability = preset.stability;
options.similarity = preset.similarity;
options.style = preset.style;
} else {
console.error(`Unknown character: ${options.character}`);
console.error(`Use --list-characters to see available options.`);
process.exit(1);
}
}
return options;
}
function printHelp() {
console.log(`
ElevenLabs Voiceover Generator with Request Stitching & Pronunciation Dictionaries
Usage:
node generate.js --text "Your text" --output output.mp3
node generate.js --scenes scenes.json --output-dir public/audio/
node generate.js --scenes scenes.json --scene scene2 --new-text "New text"
node generate.js --validate public/audio/project/
node generate.js --list-voices
node generate.js --list-characters
node generate.js --list-dictionaries
node generate.js --embed-thumbnail video.mp4 --thumbnail thumb.png -o video-with-thumb.mp4
Options:
--text, -t Text to convert to speech
--file, -f Read text from file
--output, -o Output file path (default: output.mp3)
--output-dir Output directory for scene files
--voice, -v Voice name or ID (default: George)
--model, -m Model ID (default: eleven_multilingual_v2)
--stability Voice stability 0-1 (default: 0.5)
--similarity Similarity boost 0-1 (default: 0.75)
--style Style exaggeration 0-1 (default: 0.0)
--character, -c Character preset (narrator, salesperson, expert, etc.)
--dictionary, -d Pronunciation dictionary name
--no-dictionary Disable pronunciation dictionary
--scenes JSON file with scenes for stitched generation
--scene Regenerate single scene by ID (use with --scenes)
--new-text New text for scene regeneration (optional)
--no-combined Don't create combined file (scenes mode only)
--validate Validate timing of generated audio in directory
--skip-validation Skip automatic validation after generation
--embed-thumbnail Video file to embed thumbnail into
--thumbnail Thumbnail image file (PNG/JPG)
--list-voices List all available voices
--list-characters List all character presets
--list-dictionaries List pronunciation dictionaries
--help, -h Show this help
Character Presets:
literal - Reads text exactly as written (default)
narrator - Professional storyteller, smooth, engaging
salesperson - Enthusiastic, persuasive, energetic
expert - Authoritative, confident, knowledgeable
conversational - Casual, friendly, natural
dramatic - Intense, emotional, impactful
calm - Soothing, reassuring, gentle
Pronunciation Dictionaries:
Use dictionaries to handle brand names, acronyms, or technical terms.
To create a custom dictionary, add a .pls file to:
.claude/skills/elevenlabs/dictionaries/
Example .pls file:
<?xml version="1.0" encoding="UTF-8"?>
<lexicon version="1.0" xmlns="http://www.w3.org/2005/01/pronunciation-lexicon"
alphabet="ipa" xml:lang="en-US">
<lexeme>
<grapheme>MyBrand</grapheme>
<alias>My Brand</alias>
</lexeme>
</lexicon>
Timing Validation:
After generation, the tool automatically validates:
- Actual vs expected duration (warns if >15% difference)
- Leading silence (warns if >200ms - audio starts late)
- Trailing silence (warns if >500ms)
- Speaking rate (optimal: ~3 words/second for German)
Scene File Format (scenes.json):
{
"name": "my-video",
"voice": "George",
"character": "narrator",
"scenes": [
{
"id": "scene1",
"text": "Welcome to our product demo.",
"duration": 4,
"character": "dramatic"
}
]
}
`);
}
function listCharacters() {
console.log('\nAvailable Character Presets:\n');
console.log('Name'.padEnd(15) + 'Stability'.padEnd(12) + 'Similarity'.padEnd(12) + 'Style'.padEnd(8) + 'Description');
console.log('-'.repeat(90));
for (const [key, preset] of Object.entries(CHARACTER_PRESETS)) {
console.log(
key.padEnd(15) +
preset.stability.toFixed(2).padEnd(12) +
preset.similarity.toFixed(2).padEnd(12) +
preset.style.toFixed(2).padEnd(8) +
preset.description
);
}
console.log('\nUsage:');
console.log(' --character narrator # Use narrator style');
console.log(' --character salesperson # Use salesperson style');
}
// ============================================
// API FUNCTIONS
// ============================================
function makeRequest(options, postData = null) {
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
const headers = res.headers;
const requestId = headers['request-id'];
const characterCost = headers['character-cost'];
if (res.headers['content-type']?.includes('application/json')) {
try {
resolve({
status: res.statusCode,
data: JSON.parse(Buffer.concat(chunks).toString()),
headers,
requestId,
characterCost
});
} catch {
resolve({
status: res.statusCode,
data: Buffer.concat(chunks),
headers,
requestId,
characterCost
});
}
} else {
resolve({
status: res.statusCode,
data: Buffer.concat(chunks),
headers,
requestId,
characterCost
});
}
});
});
req.on('error', reject);
if (postData) req.write(postData);
req.end();
});
}
async function listVoices() {
console.log('Fetching available voices...\n');
const response = await makeRequest({
hostname: 'api.elevenlabs.io',
path: '/v1/voices',
method: 'GET',
headers: { 'xi-api-key': API_KEY },
});
if (response.status !== 200) {
console.error('Error fetching voices:', response.data);
return;
}
console.log('Available Voices:\n');
console.log('Name'.padEnd(25) + 'ID'.padEnd(28) + 'Labels');
console.log('-'.repeat(80));
response.data.voices.forEach(voice => {
const labels = voice.labels ? Object.values(voice.labels).join(', ') : '';
console.log(
voice.name.substring(0, 24).padEnd(25) +
voice.voice_id.padEnd(28) +
labels.substring(0, 25)
);
});
console.log('\nRecommended voices:');
console.log(' - George (warm, captivating British)');
console.log(' - Antoni (professional, warm)');
console.log(' - Arnold (authoritative, deep)');
console.log(' - Josh (friendly, conversational)');
}
async function getVoiceId(voiceName) {
if (voiceName.length >= 20 && voiceName.length <= 24) {
return voiceName;
}
const response = await makeRequest({
hostname: 'api.elevenlabs.io',
path: '/v1/voices',
method: 'GET',
headers: { 'xi-api-key': API_KEY },
});
if (response.status !== 200) {
throw new Error('Failed to fetch voices');
}
const voice = response.data.voices.find(
v => v.name.toLowerCase() === voiceName.toLowerCase()
);
if (!voice) {
throw new Error(`Voice "${voiceName}" not found. Use --list-voices to see available voices.`);
}
return voice.voice_id;
}
// Clamp stability to valid v3 values (0.0, 0.5, 1.0)
function clampStabilityForV3(stability) {
if (stability <= 0.25) return 0.0;
if (stability <= 0.75) return 0.5;
return 1.0;
}
async function generateSpeechChunk(text, voiceId, options, previousRequestIds = [], dictionaryLocators = []) {
// v3 model requires specific stability values
let stability = options.stability;
if (options.model === 'eleven_v3') {
stability = clampStabilityForV3(stability);
}
const requestBody = {
text: text,
model_id: options.model,
voice_settings: {
stability: stability,
similarity_boost: options.similarity,
style: options.style,
use_speaker_boost: true,
},
};
// v3 model doesn't support request stitching yet
if (options.model !== 'eleven_v3' && previousRequestIds.length > 0) {
requestBody.previous_request_ids = previousRequestIds;
}
// Add pronunciation dictionary if provided
if (dictionaryLocators.length > 0) {
requestBody.pronunciation_dictionary_locators = dictionaryLocators;
}
const response = await makeRequest({
hostname: 'api.elevenlabs.io',
path: `/v1/text-to-speech/${voiceId}`,
method: 'POST',
headers: {
'xi-api-key': API_KEY,
'Content-Type': 'application/json',
'Accept': 'audio/mpeg',
},
}, JSON.stringify(requestBody));
if (response.status !== 200) {
console.error('Error generating speech:', response.data);
throw new Error('Failed to generate speech');
}
return {
audio: response.data,
requestId: response.requestId,
characterCost: response.characterCost,
};
}
// ============================================
// THUMBNAIL EMBEDDING FUNCTION
// ============================================
/**
* Embed a thumbnail image into an MP4 video file using ffmpeg
* This sets the thumbnail that video players and social platforms display
*/
function embedThumbnail(videoPath, thumbnailPath, outputPath) {
if (!fs.existsSync(videoPath)) {
throw new Error(`Video file not found: ${videoPath}`);
}
if (!fs.existsSync(thumbnailPath)) {
throw new Error(`Thumbnail file not found: ${thumbnailPath}`);
}
console.log(`\n🖼️ Embedding thumbnail into video`);
console.log(` Video: ${videoPath}`);
console.log(` Thumbnail: ${thumbnailPath}`);
console.log(` Output: ${outputPath}`);
console.log('');
// Create output directory if needed
const outputDir = path.dirname(outputPath);
if (outputDir && !fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
try {
// Use ffmpeg to embed the thumbnail as an attached picture
// -map 0 = take all streams from first input (video)
// -map 1 = take all streams from second input (thumbnail)
// -c copy = copy streams without re-encoding (fast)
// -disposition:v:1 attached_pic = mark second video stream as thumbnail
execSync(
`ffmpeg -y -i "${videoPath}" -i "${thumbnailPath}" -map 0 -map 1 -c copy -disposition:v:1 attached_pic "${outputPath}"`,
{ encoding: 'utf-8', stdio: 'pipe' }
);
const stats = fs.statSync(outputPath);
console.log(`✅ Thumbnail embedded successfully!`);
console.log(` Output: ${outputPath} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
return outputPath;
} catch (error) {
// Check if ffmpeg is available
try {
execSync('ffmpeg -version', { stdio: 'pipe' });
} catch {
throw new Error('ffmpeg not found. Please install ffmpeg to embed thumbnails.');
}
throw new Error(`Failed to embed thumbnail: ${error.message}`);
}
}
// ============================================
// GENERATION FUNCTIONS
// ============================================
async function generateSpeech(text, options) {
console.log(`Generating voiceover...`);
console.log(` Voice: ${options.voice}`);
console.log(` Model: ${options.model}`);
if (options.character) console.log(` Character: ${options.character}`);
if (options.dictionary) console.log(` Dictionary: ${options.dictionary}`);
console.log(` Text length: ${text.length} characters`);
console.log(` Output: ${options.output}`);
console.log('');
const voiceId = await getVoiceId(options.voice);
// Get dictionary locators if specified
let dictionaryLocators = [];
let useFallback = false;
const dictName = options.noDictionary ? null : (options.dictionary || DEFAULT_DICTIONARY);
if (dictName) {
try {
const dict = await getOrCreateDictionary(dictName);
dictionaryLocators = [{
pronunciation_dictionary_id: dict.id,
version_id: dict.versionId,
}];
} catch (e) {
console.warn(` ⚠ Could not load dictionary API: ${e.message}`);
console.log(` 🔄 Using text preprocessing fallback for ${dictName}`);
useFallback = true;
}
}
// Apply text preprocessing fallback if API failed
const processedText = useFallback && dictName ? applyDictionaryFallback(text, dictName) : text;
const result = await generateSpeechChunk(processedText, voiceId, options, [], dictionaryLocators);
const outputDir = path.dirname(options.output);
if (outputDir && !fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(options.output, result.audio);
const stats = fs.statSync(options.output);
console.log(`✓ Voiceover saved: ${options.output} (${(stats.size / 1024).toFixed(1)} KB)`);
if (result.characterCost) console.log(` Character cost: ${result.characterCost}`);
if (!options.skipValidation) {
console.log('\n📊 Validating timing...');
const validation = validateTiming(options.output, text);
if (validation.actualDuration) {
console.log(` Duration: ${validation.actualDuration.toFixed(2)}s`);
console.log(` Speaking rate: ${validation.wordsPerSecond?.toFixed(1)} words/sec`);
validation.warnings.forEach(w => console.log(` ⚠️ ${w}`));
}
}
return options.output;
}
async function regenerateSingleScene(scenesFile, sceneId, options) {
const config = JSON.parse(fs.readFileSync(scenesFile, 'utf-8'));
const voiceName = config.voice || options.voice;
const projectName = config.name || 'voiceover';
const outputDir = options.outputDir || path.dirname(options.output) || 'public/audio';
const dictName = options.noDictionary ? null : (options.dictionary || config.dictionary || DEFAULT_DICTIONARY);
const sceneIndex = config.scenes.findIndex(s => s.id === sceneId);
if (sceneIndex === -1) {
console.error(`Scene "${sceneId}" not found in ${scenesFile}`);
console.error(`Available scenes: ${config.scenes.map(s => s.id).join(', ')}`);
process.exit(1);
}
const scene = config.scenes[sceneIndex];
const text = options.newText || scene.text;
let characterSettings = { ...CHARACTER_PRESETS.literal };
if (config.character && CHARACTER_PRESETS[config.character]) {
characterSettings = { ...CHARACTER_PRESETS[config.character] };
}
if (scene.character && CHARACTER_PRESETS[scene.character]) {
characterSettings = { ...CHARACTER_PRESETS[scene.character] };
}
if (options.character && CHARACTER_PRESETS[options.character]) {
characterSettings = { ...CHARACTER_PRESETS[options.character] };
}
const infoFilePath = path.join(outputDir, `${projectName}-info.json`);
let previousRequestIds = [];
let infoData = null;
if (fs.existsSync(infoFilePath)) {
infoData = JSON.parse(fs.readFileSync(infoFilePath, 'utf-8'));
const prevScenes = infoData.scenes.slice(Math.max(0, sceneIndex - 3), sceneIndex);
previousRequestIds = prevScenes.map(s => s.requestId).filter(Boolean);
}
console.log(`\n🔄 Regenerating ${sceneId}`);
console.log(` Voice: ${voiceName}`);
console.log(` Character: ${options.character || scene.character || config.character || 'literal'}`);
if (dictName) console.log(` Dictionary: ${dictName}`);
console.log(` Text: "${text.substring(0, 60)}${text.length > 60 ? '...' : ''}"`);
if (options.newText) console.log(` (Using new text)`);
console.log('');
const voiceId = await getVoiceId(voiceName);
// Get dictionary locators
let dictionaryLocators = [];
let useFallback = false;
if (dictName) {
try {
const dict = await getOrCreateDictionary(dictName);
dictionaryLocators = [{
pronunciation_dictionary_id: dict.id,
version_id: dict.versionId,
}];
} catch (e) {
console.warn(` ⚠ Could not load dictionary: ${e.message}`);
console.log(` 🔄 Using text preprocessing fallback`);
useFallback = true;
}
}
// Apply text preprocessing fallback if API failed
const processedText = useFallback && dictName ? applyDictionaryFallback(text, dictName) : text;
const result = await generateSpeechChunk(processedText, voiceId, {
...options,
stability: characterSettings.stability,
similarity: characterSettings.similarity,
style: characterSettings.style,
}, previousRequestIds, dictionaryLocators);
const sceneFilename = `${projectName}-${sceneId}.mp3`;
const sceneFilePath = path.join(outputDir, sceneFilename);
fs.writeFileSync(sceneFilePath, result.audio);
const stats = fs.statSync(sceneFilePath);
console.log(`✓ Regenerated: ${sceneFilename} (${(stats.size / 1024).toFixed(1)} KB)`);
if (!options.skipValidation) {
console.log('\n📊 Validating timing...');
const validation = validateTiming(sceneFilePath, text, scene.duration);
if (validation.actualDuration) {
const status = validation.issues.length > 0 ? '❌' : validation.warnings.length > 0 ? '⚠️' : '✅';
console.log(`${status} Actual: ${validation.actualDuration.toFixed(2)}s, Expected: ${scene.duration || 'N/A'}s`);
console.log(` Speaking rate: ${validation.wordsPerSecond?.toFixed(1)} words/sec (${validation.wordCount} words)`);
if (validation.silence.leadingSilence > 0.05) {
console.log(` Leading silence: ${(validation.silence.leadingSilence * 1000).toFixed(0)}ms`);
}
validation.issues.forEach(i => console.log(`${i}`));
validation.warnings.forEach(w => console.log(` ⚠️ ${w}`));
if (infoData) {
infoData.scenes[sceneIndex] = {
...infoData.scenes[sceneIndex],
text: text,
size: stats.size,
actualDuration: validation.actualDuration,
wordsPerSecond: parseFloat(validation.wordsPerSecond.toFixed(2)),
leadingSilence: parseFloat(validation.silence.leadingSilence.toFixed(3)),
requestId: result.requestId,
regeneratedAt: new Date().toISOString(),
character: options.character || scene.character || config.character || 'literal',
};
infoData.updatedAt = new Date().toISOString();
fs.writeFileSync(infoFilePath, JSON.stringify(infoData, null, 2));
console.log(`✓ Updated: ${projectName}-info.json`);
}
}
}
if (options.newText) {
config.scenes[sceneIndex].text = options.newText;
fs.writeFileSync(scenesFile, JSON.stringify(config, null, 2));
console.log(`✓ Updated: ${scenesFile}`);
}
console.log(`\n✅ Scene regeneration complete!`);
return result;
}
async function generateScenes(scenesFile, options) {
const config = JSON.parse(fs.readFileSync(scenesFile, 'utf-8'));
const scenes = config.scenes;
const voiceName = config.voice || options.voice;
const projectName = config.name || 'voiceover';
const globalCharacter = options.character || config.character || 'literal';
const dictName = options.noDictionary ? null : (options.dictionary || config.dictionary || DEFAULT_DICTIONARY);
// Allow model override from scenes.json
const model = config.model || options.model;
options.model = model; // Update options for downstream use
const outputDir = options.outputDir || path.dirname(options.output) || 'public/audio';
console.log(`\n🎬 Generating ${scenes.length} scenes with Request Stitching`);
console.log(` Voice: ${voiceName}`);
console.log(` Model: ${model}`);
console.log(` Character: ${globalCharacter}`);
if (dictName) console.log(` Dictionary: ${dictName}`);
console.log(` Output: ${outputDir}/`);
console.log('');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const voiceId = await getVoiceId(voiceName);
// Get dictionary locators
let dictionaryLocators = [];
let useFallback = false;
if (dictName) {
try {
const dict = await getOrCreateDictionary(dictName);
dictionaryLocators = [{
pronunciation_dictionary_id: dict.id,
version_id: dict.versionId,
}];
} catch (e) {
console.warn(` ⚠ Could not load dictionary: ${e.message}`);
console.log(` 🔄 Using text preprocessing fallback for ${dictName}`);
useFallback = true;
}
}
const requestIds = [];
const audioBuffers = [];
const sceneInfo = [];
let totalCharacters = 0;
for (let i = 0; i < scenes.length; i++) {
const scene = scenes[i];
const sceneId = scene.id || `scene${i + 1}`;
const rawText = scene.text;
// Apply text preprocessing fallback if API failed
const text = useFallback && dictName ? applyDictionaryFallback(rawText, dictName) : rawText;
const sceneCharacter = scene.character || globalCharacter;
const characterSettings = CHARACTER_PRESETS[sceneCharacter] || CHARACTER_PRESETS.literal;
console.log(`[${i + 1}/${scenes.length}] ${sceneId} (${sceneCharacter})`);
console.log(` "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
try {
const result = await generateSpeechChunk(text, voiceId, {
...options,
stability: scene.stability || characterSettings.stability,
similarity: scene.similarity || characterSettings.similarity,
style: scene.style || characterSettings.style,
}, requestIds.slice(-3), dictionaryLocators);
const sceneFilename = `${projectName}-${sceneId}.mp3`;
const sceneFilePath = path.join(outputDir, sceneFilename);
fs.writeFileSync(sceneFilePath, result.audio);
const stats = fs.statSync(sceneFilePath);
let validation = null;
if (!options.skipValidation) {
validation = validateTiming(sceneFilePath, text, scene.duration);
}
const actualDuration = validation?.actualDuration || null;
const status = !validation ? '✓' :
validation.issues.length > 0 ? '⚠️' :
validation.warnings.length > 0 ? '⚠️' : '✓';
console.log(` ${status} ${sceneFilename} (${(stats.size / 1024).toFixed(1)} KB, ${actualDuration?.toFixed(2) || '?'}s)`);
if (validation?.issues.length > 0) {
validation.issues.forEach(i => console.log(`${i}`));
}
if (validation?.warnings.length > 0) {
validation.warnings.forEach(w => console.log(` ⚠️ ${w}`));
}
requestIds.push(result.requestId);
audioBuffers.push(result.audio);
sceneInfo.push({
id: sceneId,
file: sceneFilename,
text: text,
size: stats.size,
duration: scene.duration || null,
actualDuration: actualDuration,
wordsPerSecond: validation?.wordsPerSecond ? parseFloat(validation.wordsPerSecond.toFixed(2)) : null,
leadingSilence: validation?.silence?.leadingSilence ? parseFloat(validation.silence.leadingSilence.toFixed(3)) : 0,
delay: scene.delay || 0,
character: sceneCharacter,
requestId: result.requestId,
});
totalCharacters += text.length;
} catch (error) {
console.error(` ✗ Error: ${error.message}`);
throw error;
}
if (i < scenes.length - 1) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
if (options.combined) {
const combinedFilename = `${projectName}-combined.mp3`;
const combinedFilePath = path.join(outputDir, combinedFilename);
const combinedBuffer = Buffer.concat(audioBuffers);
fs.writeFileSync(combinedFilePath, combinedBuffer);
const combinedStats = fs.statSync(combinedFilePath);
console.log(`\n✓ Combined: ${combinedFilename} (${(combinedStats.size / 1024).toFixed(1)} KB)`);
}
const infoFilePath = path.join(outputDir, `${projectName}-info.json`);
const infoData = {
name: projectName,
voice: voiceName,
model: options.model,
character: globalCharacter,
dictionary: dictName,
totalScenes: scenes.length,
totalCharacters: totalCharacters,
generatedAt: new Date().toISOString(),
scenes: sceneInfo,
};
fs.writeFileSync(infoFilePath, JSON.stringify(infoData, null, 2));
console.log(`\n📋 Summary`);
console.log(` Scenes: ${scenes.length}`);
console.log(` Characters: ${totalCharacters}`);
const totalActual = sceneInfo.reduce((sum, s) => sum + (s.actualDuration || 0), 0);
const totalExpected = sceneInfo.reduce((sum, s) => sum + (s.duration || 0), 0);
console.log(` Total duration: ${totalActual.toFixed(2)}s (expected: ${totalExpected.toFixed(2)}s)`);
const issues = sceneInfo.filter(s => s.actualDuration && s.duration && Math.abs(s.actualDuration - s.duration) / s.duration > 0.15);
if (issues.length > 0) {
console.log(`\n⚠️ ${issues.length} scene(s) with timing issues:`);
issues.forEach(s => {
const diff = s.actualDuration - s.duration;
console.log(` - ${s.id}: ${diff > 0 ? '+' : ''}${diff.toFixed(2)}s (actual: ${s.actualDuration.toFixed(2)}s, expected: ${s.duration}s)`);
});
}
console.log(`\n✅ Scene generation complete!`);
console.log(`\nTo regenerate a scene with timing issues:`);
console.log(` node generate.js --scenes ${scenesFile} --scene <scene-id> --output-dir ${outputDir}`);
return sceneInfo;
}
// ============================================
// MAIN
// ============================================
async function main() {
const options = parseArgs();
try {
if (options.listVoices) {
await listVoices();
return;
}
if (options.listCharacters) {
listCharacters();
return;
}
if (options.listDictionaries) {
await displayDictionaries();
return;
}
if (options.validate) {
await validateProject(options.validate);
return;
}
if (options.embedThumbnail) {
if (!options.thumbnail) {
console.error('Error: --thumbnail is required when using --embed-thumbnail');
process.exit(1);
}
const outputPath = options.output !== 'output.mp3' ? options.output : options.embedThumbnail.replace(/\.mp4$/, '-thumb.mp4');
embedThumbnail(options.embedThumbnail, options.thumbnail, outputPath);
return;
}
if (options.scenes) {
if (!fs.existsSync(options.scenes)) {
console.error(`Error: Scenes file not found: ${options.scenes}`);
process.exit(1);
}
if (options.scene) {
await regenerateSingleScene(options.scenes, options.scene, options);
return;
}
await generateScenes(options.scenes, options);
return;
}
let text = options.text;
if (options.file) {
if (!fs.existsSync(options.file)) {
console.error(`Error: File not found: ${options.file}`);
process.exit(1);
}
text = fs.readFileSync(options.file, 'utf-8').trim();
}
if (!text) {
console.error('Error: No text provided. Use --text, --file, or --scenes');
printHelp();
process.exit(1);
}
await generateSpeech(text, options);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
}
main();