UNPKG

bpm-detector

Version:

A simple BPM detector for audio files

436 lines (368 loc) 14.5 kB
const wav = require('wav-decoder'); const ffmpeg = require('fluent-ffmpeg'); const stream = require('stream'); const { Readable } = stream; const { promises: fs } = require('fs'); const os = require('os'); const path = require('path'); // Dependencies to add to package.json: // "fluent-ffmpeg": "^2.1.2", // "wav-decoder": "^1.3.0" /** * Extracts PCM data from various audio formats using FFmpeg * @param {Buffer} buffer - The input audio file buffer * @param {string} mimeType - MIME type (e.g., "audio/mp3", "audio/wav") * @returns {Promise<Object>} - Object containing PCM data and sample rate */ async function extractPCMData(buffer, mimeType) { if (mimeType === 'audio/wav') { // For WAV files, we can use the wav-decoder directly try { const audioData = await wav.decode(buffer); return { sampleRate: audioData.sampleRate, channelData: audioData.channelData }; } catch (error) { throw new Error(`Error decoding WAV file: ${error.message}`); } } // For other formats, use FFmpeg to extract PCM data const tempInputPath = path.join(os.tmpdir(), `input-${Date.now()}.bin`); const tempOutputPath = path.join(os.tmpdir(), `output-${Date.now()}.raw`); try { // Write buffer to temporary file await fs.writeFile(tempInputPath, buffer); // Determine the file extension based on MIME type const extensionMap = { 'audio/mp3': 'mp3', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/flac': 'flac', 'audio/aac': 'aac', 'audio/m4a': 'm4a' }; const extension = extensionMap[mimeType] || 'bin'; // Extract PCM data using FFmpeg return new Promise((resolve, reject) => { let sampleRate = 44100; // Default sample rate ffmpeg(tempInputPath) .inputFormat(extension) .outputFormat('s16le') // 16-bit signed little endian PCM .audioChannels(1) // Convert to mono .audioFrequency(sampleRate) .on('start', (commandLine) => { console.log('FFmpeg process started:', commandLine); }) .on('error', (err) => { reject(new Error(`FFmpeg extraction error: ${err.message}`)); }) .on('end', async () => { try { // Read the extracted PCM data const rawBuffer = await fs.readFile(tempOutputPath); // Convert raw PCM buffer to Float32Array const floatData = new Float32Array(rawBuffer.length / 2); for (let i = 0; i < floatData.length; i++) { // Convert 16-bit PCM to float in the range [-1, 1] floatData[i] = rawBuffer.readInt16LE(i * 2) / 32768; } resolve({ sampleRate, channelData: [floatData] }); } catch (readErr) { reject(new Error(`Error reading extracted data: ${readErr.message}`)); } finally { // Clean up temp files fs.unlink(tempInputPath).catch(() => {}); fs.unlink(tempOutputPath).catch(() => {}); } }) .save(tempOutputPath); }); } catch (err) { // Clean up temp files in case of error try { await fs.unlink(tempInputPath); await fs.unlink(tempOutputPath); } catch (unlinkErr) { // Ignore errors during cleanup } throw new Error(`Extraction error: ${err.message}`); } } /** * Computes an enhanced amplitude envelope with onset detection. * @param {Float32Array} signal - The PCM signal. * @param {number} windowSize - Window size for smoothing * @returns {Float32Array} - The computed envelope. */ function computeEnvelope(signal, windowSize = 100) { // Step 1: Full-wave rectification (take absolute values) const rectified = new Float32Array(signal.length); for (let i = 0; i < signal.length; i++) { rectified[i] = Math.abs(signal[i]); } // Step 2: Moving average smoothing const smoothed = new Float32Array(signal.length); const halfWindow = Math.floor(windowSize / 2); for (let i = 0; i < signal.length; i++) { let sum = 0; let count = 0; for (let j = Math.max(0, i - halfWindow); j <= Math.min(signal.length - 1, i + halfWindow); j++) { sum += rectified[j]; count++; } smoothed[i] = sum / count; } // Step 3: Enhance envelope by emphasizing onsets (derivative-based) const envelope = new Float32Array(signal.length); // Don't start at 0 to avoid out-of-bounds access when looking back for (let i = 2; i < signal.length; i++) { // Basic derivative to detect changes const derivative = smoothed[i] - smoothed[i - 1]; // Only emphasize positive changes (onsets) if (derivative > 0) { envelope[i] = smoothed[i] + (derivative * 0.5); // Emphasize onsets } else { envelope[i] = smoothed[i]; } } // Ensure first samples have valid values envelope[0] = smoothed[0]; envelope[1] = smoothed[1]; return envelope; } /** * Computes the autocorrelation of a signal over a range of lags. * Uses a more efficient algorithm with variance normalization for better results. * @param {Float32Array} signal - The envelope signal. * @param {number} minLag - Minimum lag (in samples). * @param {number} maxLag - Maximum lag (in samples). * @returns {Object} - An object with the best lag and its correlation value. */ function autocorrelate(signal, minLag, maxLag) { // Calculate mean and variance for normalization const signalMean = signal.reduce((sum, val) => sum + val, 0) / signal.length; // Compute variance and create normalized signal let variance = 0; const normalizedSignal = new Float32Array(signal.length); for (let i = 0; i < signal.length; i++) { normalizedSignal[i] = signal[i] - signalMean; variance += normalizedSignal[i] * normalizedSignal[i]; } variance /= signal.length; // If variance is too small, signal is nearly constant if (variance < 1e-6) { return { bestLag: minLag, bestCorrelation: 0 }; } let bestLag = minLag; let bestCorrelation = -Infinity; // Use a more efficient algorithm for autocorrelation for (let lag = minLag; lag <= maxLag; lag++) { let correlation = 0; const n = signal.length - lag; if (n <= 0) continue; // Use dot product for correlation, but more efficiently for (let i = 0; i < n; i++) { correlation += normalizedSignal[i] * normalizedSignal[i + lag]; } // Normalize by variance and sample size for better comparison across lags correlation = correlation / (variance * n); if (correlation > bestCorrelation) { bestCorrelation = correlation; bestLag = lag; } } return { bestLag, bestCorrelation }; } /** * Detects BPM from an audio buffer using autocorrelation on the amplitude envelope. * Supports various audio formats including MP3, WAV, FLAC, AAC, OGG. * @param {Buffer} fileBuffer - Audio file buffer. * @param {string} mimeType - MIME type (e.g., "audio/mp3", "audio/wav") * @param {Object} options - Optional parameters for BPM detection * @param {number} options.minBPM - Minimum BPM to detect (default: 40) * @param {number} options.maxBPM - Maximum BPM to detect (default: 200) * @param {boolean} options.multipleTempi - Whether to return multiple tempo candidates (default: false) * @param {number} options.windowSizeMs - Window size in milliseconds for envelope detection (default: 50) * @param {number} options.timeout - Timeout in milliseconds (default: 30000 - 30 seconds) * @param {number} options.maxDuration - Maximum audio duration to process in seconds (default: 60) * @param {Function} options.onProgress - Callback function for progress updates (default: null) * @returns {Promise<Object>} - Object containing BPM result and metadata * @throws {Error} - Various error types with descriptive messages */ async function detectBPM(fileBuffer, mimeType, options = {}) { const { minBPM = 40, maxBPM = 200, multipleTempi = false, windowSizeMs = 50, timeout = 30000, maxDuration = 60, onProgress = null } = options; // Create a timeout promise const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error('BPM_DETECTION_TIMEOUT: Operation timed out after ' + timeout + 'ms')); }, timeout); }); // Create the main detection promise const detectionPromise = (async () => { try { // Report progress if (typeof onProgress === 'function') { onProgress({ stage: 'init', progress: 0 }); } // Validate input if (!fileBuffer || !(fileBuffer instanceof Buffer)) { throw new Error('BPM_INVALID_INPUT: File buffer is required and must be a Buffer'); } if (!mimeType || typeof mimeType !== 'string') { throw new Error('BPM_INVALID_INPUT: MIME type is required and must be a string'); } // Start time measurement for performance tracking const startTime = Date.now(); // Extract PCM data from the audio buffer if (typeof onProgress === 'function') { onProgress({ stage: 'extracting', progress: 10 }); } const { sampleRate, channelData, duration } = await extractPCMData(fileBuffer, mimeType); // Check if audio is too long and truncate if needed const signal = channelData[0]; const maxSamples = maxDuration * sampleRate; let processedSignal = signal; if (signal.length > maxSamples) { console.warn(`Audio duration (${duration}s) exceeds maximum (${maxDuration}s). Truncating to first ${maxDuration} seconds.`); processedSignal = new Float32Array(signal.buffer, 0, maxSamples); } // Ensure there's enough data if (processedSignal.length < sampleRate * 5) { console.warn("Audio sample is short for BPM estimation, results may be less accurate."); } // Apply a low-pass filter if (typeof onProgress === 'function') { onProgress({ stage: 'filtering', progress: 30 }); } const filteredSignal = lowPassFilter(processedSignal, sampleRate, 200); // Cut off at 200Hz // Compute amplitude envelope if (typeof onProgress === 'function') { onProgress({ stage: 'envelope', progress: 50 }); } const windowSize = Math.floor((sampleRate * windowSizeMs) / 1000); const envelope = computeEnvelope(filteredSignal, windowSize); // Define lag range based on BPM range const minLag = Math.floor(sampleRate * 60 / maxBPM); const maxLag = Math.floor(sampleRate * 60 / minBPM); // Compute autocorrelation if (typeof onProgress === 'function') { onProgress({ stage: 'correlation', progress: 70 }); } const { bestLag, bestCorrelation } = autocorrelate(envelope, minLag, maxLag); // Calculate confidence level based on correlation value const confidence = Math.min(100, Math.max(0, bestCorrelation * 100)); // Calculate BPM from the best lag const mainBPM = (60 * sampleRate) / bestLag; if (typeof onProgress === 'function') { onProgress({ stage: 'finalizing', progress: 90 }); } let result; if (!multipleTempi) { result = { bpm: Math.round(mainBPM), confidence, correlation: bestCorrelation, duration: duration }; } else { // Find multiple tempo candidates const tempoHarmonics = [ mainBPM / 2, // Half tempo mainBPM, // Main tempo mainBPM * 2 // Double tempo ].filter(bpm => bpm >= minBPM && bpm <= maxBPM); result = { bpm: Math.round(mainBPM), candidates: tempoHarmonics.map(bpm => Math.round(bpm)), confidence, correlation: bestCorrelation, duration: duration }; } // Calculate processing time const processingTime = Date.now() - startTime; result.processingTime = processingTime; if (typeof onProgress === 'function') { onProgress({ stage: 'complete', progress: 100 }); } return result; } catch (error) { // Categorize errors if (error.message.includes('FFmpeg')) { throw new Error(`BPM_DECODE_ERROR: ${error.message}`); } else if (error.message.includes('timeout')) { throw new Error(`BPM_TIMEOUT: ${error.message}`); } else { throw new Error(`BPM_ERROR: ${error.message}`); } } })(); // Race the detection against the timeout return Promise.race([detectionPromise, timeoutPromise]); } /** * Simple low-pass filter implementation * @param {Float32Array} signal - Input signal * @param {number} sampleRate - Sample rate of the signal * @param {number} cutoffFreq - Cutoff frequency in Hz * @returns {Float32Array} - Filtered signal */ function lowPassFilter(signal, sampleRate, cutoffFreq) { const RC = 1.0 / (2.0 * Math.PI * cutoffFreq); const dt = 1.0 / sampleRate; const alpha = dt / (RC + dt); const filtered = new Float32Array(signal.length); filtered[0] = signal[0]; for (let i = 1; i < signal.length; i++) { filtered[i] = filtered[i - 1] + alpha * (signal[i] - filtered[i - 1]); } return filtered; } /** * Error codes that can be returned by the BPM detector * @type {Object} */ const ErrorCodes = { BPM_TIMEOUT: 'BPM_TIMEOUT', BPM_DECODE_ERROR: 'BPM_DECODE_ERROR', BPM_INVALID_INPUT: 'BPM_INVALID_INPUT', BPM_PROCESSING_ERROR: 'BPM_PROCESSING_ERROR', BPM_FFMPEG_ERROR: 'BPM_FFMPEG_ERROR' }; /** * Helper function to check if the BPM calculation is likely to be accurate * @param {Object} result - Result object from detectBPM * @returns {boolean} - Whether the result is likely to be accurate */ function isReliableBPM(result) { if (!result) return false; // Check confidence level if (result.confidence < 40) return false; // Check if audio duration was sufficient if (result.duration < 5) return false; return true; } module.exports = { detectBPM, ErrorCodes, isReliableBPM, // Export utility functions for advanced usage autocorrelate, computeEnvelope, lowPassFilter, extractPCMData }; // Support for ESM module.exports.default = detectBPM;