Files
space-talkers/scripts/generate-waveform.js

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