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.

320 lines
9.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Generate MP3 audio files from content/index.json using edge-tts.
* Generates TWO variants per key:
* - <key>.mp3 (natural/fast speed, default rate)
* - <key>--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);
});