bpm-detector
Version:
A simple BPM detector for audio files
436 lines (368 loc) • 14.5 kB
JavaScript
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;