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
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);
|
|
});
|