#!/usr/bin/env node /** * Generate MP3 audio files from content/index.json using edge-tts. * Generates TWO variants per key: * - .mp3 (natural/fast speed, default rate) * - --slow.mp3 (slowed speech at -30% rate for learners) * Idempotent: skips files that already have >500 bytes. * Concurrency: max 4 parallel processes. * Retry: up to 2 retries per failure. * * CLI: --only slow|fast|all (default: all) */ import { readFileSync, statSync, renameSync, unlinkSync, copyFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFile } from 'node:child_process'; import { tmpdir } from 'node:os'; import { randomBytes } from 'node:crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const CONTENT_PATH = join(ROOT, 'content', 'index.json'); const AUDIO_DIR = join(ROOT, 'audio'); const VOICE_ELSA = 'it-IT-ElsaNeural'; // chunks + learner const VOICE_DIEGO = 'it-IT-DiegoNeural'; // italian speaker const MAX_CONCURRENCY = 4; const MAX_RETRIES = 2; const RETRY_DELAY_MS = 1000; // Parse CLI flags const args = process.argv.slice(2); const onlyFlag = args.includes('--only') ? args[args.indexOf('--only') + 1] : 'all'; const MODE = onlyFlag === 'slow' ? 'slow' : onlyFlag === 'fast' ? 'fast' : 'all'; /** * Extract all unique {audioKey, text, voice} entries from index.json. */ function extractEntries() { const data = JSON.parse(readFileSync(CONTENT_PATH, 'utf8')); const seen = new Map(); // audioKey -> {text, voice} for (const mission of data.missions) { // Chunks: always Elsa if (mission.chunks) { for (const chunk of mission.chunks) { if (chunk.audioKey && !seen.has(chunk.audioKey)) { seen.set(chunk.audioKey, { text: chunk.it, voice: VOICE_ELSA, }); } } } // Dialogues: voice depends on speaker if (mission.dialogues) { for (const dialogue of mission.dialogues) { if (!Array.isArray(dialogue)) continue; for (const line of dialogue) { if (line.audioKey && !seen.has(line.audioKey)) { const voice = line.speaker === 'italian' ? VOICE_DIEGO : VOICE_ELSA; seen.set(line.audioKey, { text: line.it, voice, }); } } } } } return [...seen.entries()].map(([audioKey, { text, voice }]) => ({ audioKey, text, voice, })); } /** * Check if a file exists and has size > minBytes. */ function hasAudio(filePath, minBytes = 0) { try { const st = statSync(filePath); return st.size > minBytes; } catch { return false; } } /** * Generate one audio file via edge-tts. * @param {string} outPath - Final output path * @param {string} text - Text to synthesize * @param {string} voice - Voice identifier * @param {string} [rate] - Optional rate adjustment (e.g., '-30%') * @returns {Promise<{ok: boolean, error?: string}>} */ async function generateAudio(outPath, text, voice, rate) { const tmpName = `tts_${randomBytes(6).toString('hex')}.mp3`; const tmpPath = join(tmpdir(), tmpName); const args = [ '-m', 'edge_tts', '--voice', voice, '--text', text, '--write-media', tmpPath, ]; if (rate) { // Use --rate=VALUE format to avoid argparse treating negative values as flags args.push(`--rate=${rate}`); } return new Promise((resolve) => { execFile('python', args, { timeout: 30000 }, (err, _stdout, stderr) => { if (err) { resolve({ ok: false, error: stderr || err.message }); return; } // Verify tmp file was created with content try { const st = statSync(tmpPath); if (st.size < 500) { // Too small, likely failed silently try { unlinkSync(tmpPath); } catch {} resolve({ ok: false, error: `Output too small (${st.size} bytes)` }); return; } } catch { resolve({ ok: false, error: 'Temp file not created' }); return; } // Move to final location (copy+delete for cross-device safety) try { try { renameSync(tmpPath, outPath); } catch (renameErr) { if (renameErr.code === 'EXDEV') { copyFileSync(tmpPath, outPath); unlinkSync(tmpPath); } else { throw renameErr; } } resolve({ ok: true }); } catch (moveErr) { try { unlinkSync(tmpPath); } catch {} resolve({ ok: false, error: `Move failed: ${moveErr.message}` }); } }); }); } /** * Generate one audio variant (fast or slow) for a given key. * Returns { ok: true } or { ok: false, error: string }. */ async function generateOne(audioKey, text, voice, variant) { const suffix = variant === 'slow' ? '--slow' : ''; const outPath = join(AUDIO_DIR, `${audioKey}${suffix}.mp3`); const rate = variant === 'slow' ? '-30%' : undefined; return generateAudio(outPath, text, voice, rate); } /** * Sleep helper. */ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } /** * Process entries with bounded concurrency. * Generates both fast and slow variants based on MODE flag. */ async function processAll(entries) { let generatedFast = 0; let generatedSlow = 0; let skippedFast = 0; let skippedSlow = 0; let failed = 0; const failures = []; // Build work items based on mode const todo = []; for (const entry of entries) { if (MODE === 'all' || MODE === 'fast') { const fastPath = join(AUDIO_DIR, `${entry.audioKey}.mp3`); if (hasAudio(fastPath, 500)) { skippedFast++; } else { todo.push({ ...entry, variant: 'fast' }); } } if (MODE === 'all' || MODE === 'slow') { const slowPath = join(AUDIO_DIR, `${entry.audioKey}--slow.mp3`); if (hasAudio(slowPath, 500)) { skippedSlow++; } else { todo.push({ ...entry, variant: 'slow' }); } } } console.log(`Mode: ${MODE}`); console.log(`Total keys: ${entries.length}`); if (MODE === 'all' || MODE === 'fast') { console.log(`Fast variant - Already exist (>500b): ${skippedFast}`); } if (MODE === 'all' || MODE === 'slow') { console.log(`Slow variant - Already exist (>500b): ${skippedSlow}`); } console.log(`To generate: ${todo.length}`); console.log(`Concurrency: ${MAX_CONCURRENCY}`); console.log('---'); // Bounded concurrency pool let idx = 0; async function worker() { while (idx < todo.length) { const currentIdx = idx++; const item = todo[currentIdx]; const suffix = item.variant === 'slow' ? '--slow' : ''; const filePath = join(AUDIO_DIR, `${item.audioKey}${suffix}.mp3`); // Double-check (another worker may have done it) if (hasAudio(filePath, 500)) { if (item.variant === 'slow') skippedSlow++; else skippedFast++; continue; } let lastError = ''; let success = false; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { if (attempt > 0) { await sleep(RETRY_DELAY_MS * attempt); } const result = await generateOne(item.audioKey, item.text, item.voice, item.variant); if (result.ok) { success = true; break; } lastError = result.error; } if (success) { if (item.variant === 'slow') { generatedSlow++; } else { generatedFast++; } const total = generatedFast + generatedSlow; if (total % 20 === 0) { console.log(` Progress: ${total}/${todo.length} generated (fast:${generatedFast}, slow:${generatedSlow})...`); } } else { failed++; failures.push({ key: `${item.audioKey}${suffix}`, error: lastError }); console.log(` FAIL: ${item.audioKey}${suffix} — ${lastError}`); } } } const workers = []; for (let w = 0; w < Math.min(MAX_CONCURRENCY, todo.length); w++) { workers.push(worker()); } await Promise.all(workers); return { generatedFast, generatedSlow, skippedFast, skippedSlow, failed, failures }; } // Main async function main() { console.log('=== edge-tts Audio Generator (Dual Speed) ==='); console.log(`Content: ${CONTENT_PATH}`); console.log(`Output: ${AUDIO_DIR}`); console.log(`Mode: ${MODE} (--only slow|fast|all)`); console.log(''); const entries = extractEntries(); console.log(`Unique audio keys extracted: ${entries.length}`); console.log(''); const stats = await processAll(entries); console.log(''); console.log('=== Summary ==='); if (MODE === 'all' || MODE === 'fast') { console.log(`Fast (natural) - Generated: ${stats.generatedFast}, Skipped: ${stats.skippedFast}`); } if (MODE === 'all' || MODE === 'slow') { console.log(`Slow (-30%) - Generated: ${stats.generatedSlow}, Skipped: ${stats.skippedSlow}`); } console.log(`Failed: ${stats.failed}`); if (stats.failures.length > 0) { console.log(''); console.log('Failed keys:'); for (const f of stats.failures) { console.log(` ${f.key}: ${f.error}`); } } } main().catch((err) => { console.error('Fatal error:', err); process.exit(1); });