You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

260 lines
6.7 KiB
JavaScript

#!/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);
});