feat: add audio conversion script and enhance audio token handling for .opus files
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s

This commit is contained in:
2025-09-04 23:16:55 +02:00
parent d89647cd5e
commit 12113ec1ce
5 changed files with 198 additions and 6 deletions

162
scripts/convert-to-opus.js Normal file
View File

@@ -0,0 +1,162 @@
#!/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();