#!/usr/bin/env node // Batch convert audio files in data/ to Opus using ffmpeg // Usage: node scripts/convert-to-opus.js [--force] [--dry-run] [--bitrate 96] [--concurrency 2] // Requires: ffmpeg in PATH or dev dependency ffmpeg-static import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { spawn } from 'child_process'; import { createRequire } from 'module'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT = path.resolve(__dirname, '..'); const DATA_DIR = path.resolve(ROOT, 'data'); function parseArgs(argv) { const args = { force: false, dryRun: false, bitrate: 96, concurrency: 2 }; const skip = new Set(); for (let i = 2; i < argv.length; i++) { if (skip.has(i)) continue; const a = argv[i]; if (a === '--force' || a === '-f') args.force = true; else if (a === '--dry-run' || a === '-n') args.dryRun = true; else if (a === '--bitrate' || a === '-b') { const next = argv[i + 1]; const v = Number(next); if (!Number.isFinite(v) || v <= 0) throw new Error('Invalid --bitrate'); args.bitrate = v; skip.add(i + 1); } else if (a === '--concurrency' || a === '-j') { const next = argv[i + 1]; const v = Number(next); if (!Number.isFinite(v) || v <= 0) throw new Error('Invalid --concurrency'); args.concurrency = Math.max(1, Math.floor(v)); skip.add(i + 1); } else { throw new Error(`Unknown arg: ${a}`); } } return args; } function ensureDataDir() { if (!fs.existsSync(DATA_DIR)) { throw new Error(`Data directory not found: ${DATA_DIR}`); } } function listSources() { const exts = ['.mp3', '.wav', '.m4a', '.ogg']; return fs .readdirSync(DATA_DIR) .filter((f) => exts.includes(path.extname(f).toLowerCase())) .map((f) => ({ src: path.join(DATA_DIR, f), base: f })); } function opusPathFor(base) { const name = base.replace(/\.[^.]+$/i, '.opus'); return path.join(DATA_DIR, name); } function findFfmpeg() { // Prefer local ffmpeg-static if present try { const require = createRequire(import.meta.url); const ff = require('ffmpeg-static'); if (ff) return ff; } catch {} return 'ffmpeg'; } function convertOne({ src, dst, bitrate, dryRun }) { return new Promise((resolve) => { const args = [ '-y', '-i', src, '-vn', '-c:a', 'libopus', '-b:a', `${bitrate}k`, '-application', 'audio', dst, ]; if (dryRun) { return resolve({ ok: true, skipped: true, reason: 'dry-run', src, dst }); } const ffmpegBin = findFfmpeg(); const child = spawn(ffmpegBin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => (stdout += d.toString())); child.stderr.on('data', (d) => (stderr += d.toString())); child.on('error', (err) => resolve({ ok: false, src, dst, error: String(err) })); child.on('close', (code) => { if (code === 0) resolve({ ok: true, src, dst, stdout, stderr }); else resolve({ ok: false, src, dst, code, stdout, stderr }); }); }); } async function run() { try { const { force, dryRun, bitrate, concurrency } = parseArgs(process.argv); ensureDataDir(); const sources = listSources(); if (sources.length === 0) { console.log('No source files found in data/.'); return; } const jobs = sources.map((s) => { const dst = opusPathFor(s.base); const exists = fs.existsSync(dst); const skip = exists && !force; return { ...s, dst, skip }; }); const toDo = jobs.filter((j) => !j.skip); const skipped = jobs.filter((j) => j.skip); if (skipped.length) console.log(`Skipping ${skipped.length} existing .opus files (use --force to overwrite)`); console.log( `Converting ${toDo.length} file(s) to Opus @ ${bitrate} kbps with concurrency ${concurrency}...` ); let ok = 0; let fail = 0; const queue = [...toDo]; const workers = new Array(Math.min(concurrency, queue.length)).fill(null).map(async () => { while (queue.length) { const job = queue.shift(); const res = await convertOne({ src: job.src, dst: job.dst, bitrate, dryRun }); if (res.ok) { ok++; console.log( `✔ ${path.basename(job.src)} -> ${path.basename(job.dst)}${dryRun ? ' [dry-run]' : ''}` ); } else { fail++; console.error( `✖ Failed: ${path.basename(job.src)} -> ${path.basename(job.dst)} (${res.code ?? res.error ?? 'error'})` ); } } }); await Promise.all(workers); console.log( `Done. Success: ${ok}, Failed: ${fail}, Skipped: ${skipped.length}${dryRun ? ' (dry-run)' : ''}` ); } catch (e) { console.error('Error:', e.message); process.exitCode = 1; } } run();