#!/usr/bin/env node /** * Waveform Generator * * Generates waveform JSON data from audio files using ffmpeg. * * Usage: * node scripts/generate-waveform.js [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 [output-json] [columns] Arguments: input-audio Path to the audio file (opus, mp3, wav, etc.) output-json Output path for waveform JSON (default: .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();