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