241 lines
6.4 KiB
JavaScript
241 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Waveform Generator
|
|
*
|
|
* Generates waveform JSON data from audio files using ffmpeg.
|
|
*
|
|
* Usage:
|
|
* node scripts/generate-waveform.js <input-audio> [output-json] [columns]
|
|
*
|
|
* Example:
|
|
* node scripts/generate-waveform.js input/meeting.opus outputs/float32/meeting.waveform.json 1000
|
|
*/
|
|
|
|
const { spawn } = require("child_process");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const DEFAULT_COLUMNS = 1000;
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
console.log(`
|
|
Waveform Generator - Creates waveform JSON from audio files
|
|
|
|
Usage:
|
|
node scripts/generate-waveform.js <input-audio> [output-json] [columns]
|
|
|
|
Arguments:
|
|
input-audio Path to the audio file (opus, mp3, wav, etc.)
|
|
output-json Output path for waveform JSON (default: <input>.waveform.json)
|
|
columns Number of waveform columns/peaks (default: ${DEFAULT_COLUMNS})
|
|
|
|
Example:
|
|
node scripts/generate-waveform.js input/meeting.opus
|
|
node scripts/generate-waveform.js input/meeting.opus outputs/meeting.waveform.json 2000
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const inputPath = args[0];
|
|
const columns = parseInt(args[2], 10) || DEFAULT_COLUMNS;
|
|
|
|
let outputPath = args[1];
|
|
if (!outputPath) {
|
|
const dir = path.dirname(inputPath);
|
|
const base = path.basename(inputPath, path.extname(inputPath));
|
|
outputPath = path.join(dir, `${base}.waveform.json`);
|
|
}
|
|
|
|
return { inputPath, outputPath, columns };
|
|
}
|
|
|
|
function probeAudio(inputPath) {
|
|
return new Promise((resolve, reject) => {
|
|
const ffprobe = spawn("ffprobe", [
|
|
"-v", "quiet",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
inputPath
|
|
]);
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
ffprobe.stdout.on("data", (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
ffprobe.stderr.on("data", (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
ffprobe.on("close", (code) => {
|
|
if (code !== 0) {
|
|
reject(new Error(`ffprobe failed with code ${code}: ${stderr}`));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const info = JSON.parse(stdout);
|
|
const audioStream = info.streams.find(s => s.codec_type === "audio");
|
|
|
|
if (!audioStream) {
|
|
reject(new Error("No audio stream found in file"));
|
|
return;
|
|
}
|
|
|
|
resolve({
|
|
duration: parseFloat(info.format.duration),
|
|
sampleRate: parseInt(audioStream.sample_rate, 10),
|
|
channels: audioStream.channels
|
|
});
|
|
} catch (err) {
|
|
reject(new Error(`Failed to parse ffprobe output: ${err.message}`));
|
|
}
|
|
});
|
|
|
|
ffprobe.on("error", (err) => {
|
|
reject(new Error(`Failed to run ffprobe: ${err.message}. Make sure ffmpeg is installed.`));
|
|
});
|
|
});
|
|
}
|
|
|
|
function extractPCM(inputPath) {
|
|
return new Promise((resolve, reject) => {
|
|
const ffmpeg = spawn("ffmpeg", [
|
|
"-i", inputPath,
|
|
"-ac", "1", // Convert to mono
|
|
"-ar", "8000", // Resample to 8kHz (sufficient for waveform)
|
|
"-f", "f32le", // Output as 32-bit float little-endian
|
|
"-" // Output to stdout
|
|
]);
|
|
|
|
const chunks = [];
|
|
let stderr = "";
|
|
|
|
ffmpeg.stdout.on("data", (chunk) => {
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
ffmpeg.stderr.on("data", (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
ffmpeg.on("close", (code) => {
|
|
if (code !== 0) {
|
|
reject(new Error(`ffmpeg failed with code ${code}: ${stderr}`));
|
|
return;
|
|
}
|
|
|
|
const buffer = Buffer.concat(chunks);
|
|
const samples = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.length / 4);
|
|
resolve(samples);
|
|
});
|
|
|
|
ffmpeg.on("error", (err) => {
|
|
reject(new Error(`Failed to run ffmpeg: ${err.message}. Make sure ffmpeg is installed.`));
|
|
});
|
|
});
|
|
}
|
|
|
|
function computePeaks(samples, columns) {
|
|
const peaks = [];
|
|
const samplesPerColumn = Math.max(1, Math.floor(samples.length / columns));
|
|
|
|
for (let col = 0; col < columns; col++) {
|
|
const start = col * samplesPerColumn;
|
|
const end = Math.min(start + samplesPerColumn, samples.length);
|
|
|
|
let min = 1;
|
|
let max = -1;
|
|
|
|
for (let i = start; i < end; i++) {
|
|
const sample = samples[i];
|
|
if (sample < min) min = sample;
|
|
if (sample > max) max = sample;
|
|
}
|
|
|
|
// Round to 3 decimal places to reduce JSON size
|
|
peaks.push({
|
|
min: Math.round(min * 1000) / 1000,
|
|
max: Math.round(max * 1000) / 1000
|
|
});
|
|
}
|
|
|
|
return peaks;
|
|
}
|
|
|
|
async function generateWaveform(inputPath, outputPath, columns) {
|
|
console.log(`Processing: ${inputPath}`);
|
|
console.log(`Output: ${outputPath}`);
|
|
console.log(`Columns: ${columns}`);
|
|
console.log("");
|
|
|
|
// Probe audio file for metadata
|
|
console.log("Probing audio file...");
|
|
const audioInfo = await probeAudio(inputPath);
|
|
console.log(` Duration: ${audioInfo.duration.toFixed(2)}s`);
|
|
console.log(` Sample Rate: ${audioInfo.sampleRate}Hz`);
|
|
console.log(` Channels: ${audioInfo.channels}`);
|
|
console.log("");
|
|
|
|
// Extract PCM samples
|
|
console.log("Extracting PCM samples...");
|
|
const samples = await extractPCM(inputPath);
|
|
console.log(` Extracted ${samples.length} samples`);
|
|
console.log("");
|
|
|
|
// Compute peaks
|
|
console.log("Computing waveform peaks...");
|
|
const peaks = computePeaks(samples, columns);
|
|
console.log(` Generated ${peaks.length} peaks`);
|
|
console.log("");
|
|
|
|
// Create output JSON
|
|
const waveformData = {
|
|
version: 1,
|
|
source: path.basename(inputPath),
|
|
duration: audioInfo.duration,
|
|
sampleRate: audioInfo.sampleRate,
|
|
columns: peaks.length,
|
|
peaks: peaks
|
|
};
|
|
|
|
// Ensure output directory exists
|
|
const outputDir = path.dirname(outputPath);
|
|
if (outputDir && !fs.existsSync(outputDir)) {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
// Write JSON file
|
|
fs.writeFileSync(outputPath, JSON.stringify(waveformData, null, 2));
|
|
|
|
const stats = fs.statSync(outputPath);
|
|
console.log(`Waveform saved: ${outputPath}`);
|
|
console.log(` File size: ${(stats.size / 1024).toFixed(2)} KB`);
|
|
}
|
|
|
|
async function main() {
|
|
const { inputPath, outputPath, columns } = parseArgs();
|
|
|
|
if (!fs.existsSync(inputPath)) {
|
|
console.error(`Error: Input file not found: ${inputPath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
await generateWaveform(inputPath, outputPath, columns);
|
|
console.log("\nDone!");
|
|
} catch (err) {
|
|
console.error(`\nError: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|