#!/usr/bin/env node /** * Generate MP3 audio files from content/index.json using edge-tts. * Idempotent: skips files that already have >0 bytes. * Concurrency: max 4 parallel processes. * Retry: up to 2 retries per failure. */ 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; /** * 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. * Returns { ok: true } or { ok: false, error: string }. */ async function generateOne(audioKey, text, voice) { const outPath = join(AUDIO_DIR, `${audioKey}.mp3`); 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, ]; 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}` }); } }); }); } /** * Sleep helper. */ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } /** * Process entries with bounded concurrency. */ async function processAll(entries) { let generated = 0; let skipped = 0; let failed = 0; const failures = []; // Filter to only those needing generation const todo = []; for (const entry of entries) { const filePath = join(AUDIO_DIR, `${entry.audioKey}.mp3`); if (hasAudio(filePath, 0)) { skipped++; } else { todo.push(entry); } } console.log(`Total keys: ${entries.length}`); console.log(`Already exist (>0 bytes): ${skipped}`); console.log(`To generate: ${todo.length}`); console.log(`Concurrency: ${MAX_CONCURRENCY}`); console.log('---'); // Bounded concurrency pool let idx = 0; const results = []; async function worker() { while (idx < todo.length) { const currentIdx = idx++; const entry = todo[currentIdx]; const filePath = join(AUDIO_DIR, `${entry.audioKey}.mp3`); // Double-check (another worker may have done it) if (hasAudio(filePath, 0)) { skipped++; 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(entry.audioKey, entry.text, entry.voice); if (result.ok) { success = true; break; } lastError = result.error; } if (success) { generated++; if (generated % 20 === 0) { console.log(` Progress: ${generated}/${todo.length} generated...`); } } else { failed++; failures.push({ key: entry.audioKey, error: lastError }); console.log(` FAIL: ${entry.audioKey} — ${lastError}`); } } } const workers = []; for (let w = 0; w < Math.min(MAX_CONCURRENCY, todo.length); w++) { workers.push(worker()); } await Promise.all(workers); return { generated, skipped, failed, failures }; } // Main async function main() { console.log('=== edge-tts Audio Generator ==='); console.log(`Content: ${CONTENT_PATH}`); console.log(`Output: ${AUDIO_DIR}`); 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 ==='); console.log(`Generated: ${stats.generated}`); console.log(`Skipped (already exist): ${stats.skipped}`); 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); });