class BeatDetect {
/** @summary <h1>Beat detection library</h1>
* @author Arthur Beaulieu
* @since November 2020
* @description <blockquote>This library provides an audio analyser to retrieve a track beat per minute value. It is
* also made to guess the time offset before the first significant sound (relative to the BPM), and the time offset of
* the estimated first bar. All its parameters can be updated when constructed, so it can adapt to any type of audio
* input. The analysis method is based on the work of Joe Sullivan and José M. Pérez, and is describe in the
* <code>README.md</code> file of its repository. Remarks and pull requests are welcome!</blockquote>
* @param {object} [options] - The configuration object of this library
* @param {boolean} [options.log=false] - Log debug information during the analysis process
* @param {boolean} [options.perf=false] - Log ellapsed time of each analysis step
* @param {number} [options.sampleRate=44100] - The sample rate to use during the audio analysis. Can be changed its setter
* @param {boolean} [options.round=false] - Allow the output rounding to remove floating point
* @param {number} [options.float=8] - The floating precision for the output. Disabled if round is at true
* @param {number} [options.lowPassFreq=150] - The low pass filter cut frequency
* @param {number} [options.highPassFreq=100] - The high pass filter cut frequency
* @param {number[]} [options.bpmRange=[90, 180]] - The BPM range to output the result in
* @param {number} [options.timeSignature=4] - The analysed audio time signature **/
constructor(options = {}) {
// Attach Web Audio API components to the window
window.AudioContext = window.AudioContext || window.webkitAudioContext;
window.OfflineContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
// Ensure that client browser supports the Web Audio API, return otherwise
if (!window.AudioContext || !window.OfflineContext) {
console.error(`BeatDetect.ERROR : Your browser doesn't support the WebAudio API.`);
return; // Stop right here the BeatDetect.js component construction
}
/** @public
* @member {string} - The BeatDetect version number **/
this.VERSION = '1.0.0';
/** @private
* @member {boolean} - Log debug information in the console when set to true **/
this._log = options.log || false;
/* ---- Automation beat deterination internals ---- */
/** @private
* @member {boolean} - Log elapsed times during the analysis in the console when set to true **/
this._perf = options.perf || false;
/** @private
* @member {number} - The sample rate used for analysis. Must match the analysed audio sample rate **/
this._sampleRate = options.sampleRate || 44100;
/** @private
* @member {boolean} - Remove any floating point from output when set to true **/
this._round = options.round || false;
/** @private
* @member {number} - The number of floating point for the output **/
this._float = options.float || 8;
/** @private
* @member {number} - The low pass filter cut frequency **/
this._lowPassFreq = options.lowPassFreq || 150;
/** @private
* @member {number} - The high pass filter cut frequency **/
this._highPassFreq = options.highPassFreq || 100;
/** @private
* @member {array} - The BPM range to display the output in **/
this._bpmRange = options.bpmRange || [90, 180];
/** @private
* @member {number} - The studied track time signature **/
this._timeSignature = options.timeSignature || 4;
/* ------ Manual beat deterination internals ------ */
/** @private
* @member {number} - The amount of time a click is trigerred to compute BPM **/
this.count = 0;
/** @private
* @member {object} - Contains timestamp used to determine manual BPM **/
this._ts = {
current: 0,
previous: 0,
first: 0
};
/** @private
* @member {number} - Reset tap timeout ID **/
this._tapResetId = -1;
}
/* --------------------------------------------------------------------------------------------------------------- */
/* --------------------------------------------- PUBLIC METHOD ------------------------------------------------ */
/* --------------------------------------------------------------------------------------------------------------- */
/** @method
* @name getBeatInfo
* @public
* @memberof BeatDetect
* @description <blockquote>Perform a beat detection on a given track and return the analysis result trhough the
* Promise resolution. Any exception will be thrown in the Promise catch method.</blockquote>
* @param {object} options - The beat detection option
* @param {string} options.url - The url to the audio file to analyse
* @param {string} [options.name] - The track name, only useful for logging
* @returns {promise} A Promise that is resolved when analysis is done, of will be rejected otherwise **/
getBeatInfo(options) {
// Performances mark to compute execution duration
options.perf = {
m0: performance.now(), // Start beat detection
m1: 0, // Fetch track done
m2: 0, // Offline context rendered
m3: 0 // Bpm processing done
};
// In order ; fetch track, decode its buffer, process it and send back BPM info
return new Promise((resolve, reject) => {
this._fetchRawTrack(options)
.then(this._buildOfflineCtx.bind(this))
.then(this._processRenderedBuffer.bind(this))
.then(resolve).catch(reject);
});
}
/* --------------------------------------------------------------------------------------------------------------- */
/* ---------------------------------------- OVERALL LOGIC METHODS --------------------------------------------- */
/* --------------------------------------------------------------------------------------------------------------- */
/** @method
* @name _fetchRawTrack
* @private
* @memberof BeatDetect
* @description <blockquote>This method will perform a fetch on the given URL to retrieve the track to analyse.</blockquote>
* @param {object} options - The option object sent to the <code>getBeatInfo</code> method, augmented with performance marks
* @returns {promise} A Promise that is resolved when analysis is done, of will be rejected otherwise **/
_fetchRawTrack(options) {
return new Promise((resolve, reject) => {
if (!options) {
reject('BeatDetect.ERROR : No options object sent to _fetchRawTrack method.');
} else if (!options.url || !options.perf || typeof options.url !== 'string' || typeof options.perf !== 'object') {
reject('BeatDetect.ERROR : Options object sent to _fetchRawTrack method is invalid.');
} else {
this._logEvent('log', `Fetch track${options.name ? ' ' + options.name : ''}.`);
let request = new XMLHttpRequest();
request.open('GET', options.url, true);
request.responseType = 'arraybuffer';
request.onload = () => {
if (request.status == 404) {
reject('BeatDetect.ERROR : 404 File not found.');
}
options.perf.m1 = performance.now();
resolve(Object.assign(request, options));
};
request.onerror = reject;
request.send();
}
});
}
/** @method
* @name _buildOfflineCtx
* @private
* @memberof BeatDetect
* @description <blockquote>This method will build and connect all required nodes to perform the BPM analysis.</blockquote>
* @param {object} options - The option object sent to the <code>_fetchRawTrack</code> method, augmented with track array buffer
* @returns {promise} A Promise that is resolved when analysis is done, of will be rejected otherwise **/
_buildOfflineCtx(options) {
return new Promise((resolve, reject) => {
if (!options) {
reject('BeatDetect.ERROR : No options object sent to _buildOfflineCtx method.');
} else if (!options.response || !options.perf || typeof options.response !== 'object' || typeof options.perf !== 'object') {
reject('BeatDetect.ERROR : Options object sent to _buildOfflineCtx method is invalid.');
} else {
this._logEvent('log', 'Offline rendering of the track.');
// Decode track audio with audio context to later feed the offline context with a buffer
const audioCtx = new AudioContext();
audioCtx.decodeAudioData(options.response, buffer => {
// Define offline context according to the buffer sample rate and duration
const offlineCtx = new window.OfflineContext(2, buffer.duration * this._sampleRate, this._sampleRate);
// Create buffer source from loaded track
const source = offlineCtx.createBufferSource();
source.buffer = buffer;
// Lowpass filter to ignore most frequencies except bass (goal is to retrieve kick impulsions)
const lowpass = offlineCtx.createBiquadFilter();
lowpass.type = 'lowpass';
lowpass.frequency.value = this._lowPassFreq;
lowpass.Q.value = 1;
// Apply a high pass filter to remove the bassline
const highpass = offlineCtx.createBiquadFilter();
highpass.type = 'highpass';
highpass.frequency.value = this._highPassFreq;
highpass.Q.value = 1;
// Chain offline nodes from source to destination with filters among
source.connect(lowpass);
lowpass.connect(highpass);
highpass.connect(offlineCtx.destination);
// Start the source and rendering
source.start(0);
offlineCtx.startRendering();
// Continnue analysis when buffer has been read
offlineCtx.oncomplete = result => {
options.perf.m2 = performance.now();
resolve(Object.assign(result, options));
};
offlineCtx.onerror = reject;
}, err => {
reject(`BeatDetect.ERROR : ${err}`);
});
}
});
}
/** @method
* @name _processRenderedBuffer
* @private
* @memberof BeatDetect
* @description <blockquote>This method will process the audio buffer to extract its peak and guess the track BPM and offset.</blockquote>
* @param {object} options - The option object sent to the <code>_buildOfflineCtx</code> method, augmented with track audio buffer
* @returns {promise} A Promise that is resolved when analysis is done, of will be rejected otherwise **/
_processRenderedBuffer(options) {
return new Promise((resolve, reject) => {
if (!options) {
reject('BeatDetect.ERROR : No options object sent to _processRenderedBuffer method.');
} else if (!options.renderedBuffer || !options.perf || typeof options.renderedBuffer !== 'object' || typeof options.perf !== 'object') {
reject('BeatDetect.ERROR : Options object sent to _processRenderedBuffer method is invalid.');
} else {
this._logEvent('log', 'Collect beat info.');
// Extract PCM data from offline rendered buffer
const dataL = options.renderedBuffer.getChannelData(0);
const dataR = options.renderedBuffer.getChannelData(1);
// Extract most intense peaks, and create intervals between them
const peaks = this._getPeaks([dataL, dataR]);
const groups = this._getIntervals(peaks);
// Sort found intervals by count to get the most accurate one in first position
var top = groups.sort((intA, intB) => {
return intB.count - intA.count;
}).splice(0, 5); // Only keep the 5 best matches
// Build offset and first bar
const offsets = this._getOffsets(dataL, top[0].tempo);
options.perf.m3 = performance.now();
this._logEvent('log', 'Analysis done.');
// Sent BPM info to the caller
resolve(Object.assign({
bpm: top[0].tempo,
offset: this._floatRound(offsets.offset, this._float),
firstBar: this._floatRound(offsets.firstBar, this._float)
}, this._perf ? { // Assign perf key to return object if user requested it
perf: this._getPerfDuration(options.perf)
} : null));
}
});
}
/* --------------------------------------------------------------------------------------------------------------- */
/* ------------------------------------------ BPM GUESS METHODS ----------------------------------------------- */
/* --------------------------------------------------------------------------------------------------------------- */
/** @method
* @name _getPeaks
* @private
* @memberof BeatDetect
* @description <blockquote>This method will extract peak value from given channel data. See implementation for further details.</blockquote>
* @param {array[]} data - Array containg L/R audio data arrays
* @returns {array} An array filled with peaks value **/
_getPeaks(data) {
// What we're going to do here, is to divide up our audio into parts.
// We will then identify, for each part, what the loudest sample is in that part.
// It's implied that that sample would represent the most likely 'beat' within that part.
// Each part 22,050 samples, half fft.
const partSize = this._sampleRate / 2;
const parts = data[0].length / partSize;
let peaks = [];
// Iterate over .5s parts we created
for (let i = 0; i < parts; ++i) {
let max = 0;
// Iterate each byte in the studied part
for (let j = i * partSize; j < (i + 1) * partSize; ++j) {
const volume = Math.max(Math.abs(data[0][j]), Math.abs(data[1][j]));
if (!max || (volume > max.volume)) {
// Save peak at its most intense position
max = {
position: j,
volume: volume
};
}
}
peaks.push(max);
}
// Sort peaks per volume
peaks.sort((a, b) => {
return b.volume - a.volume;
});
// This way we can ignore the less loud half
peaks = peaks.splice(0, peaks.length * 0.5);
// Then sort again by position to retrieve the playback order
peaks.sort((a, b) => {
return a.position - b.position;
});
// Send back peaks
return peaks;
}
/** @method
* @name _getIntervals
* @private
* @memberof BeatDetect
* @description <blockquote>This method will then compute time interval between peak, in order to
* spot the interval that is the most represented. See implementation for further details.</blockquote>
* @param {object[]} peaks - The peaks for a given track. Returned from _getPeaks method
* @returns {array} An array of time intervals **/
_getIntervals(peaks) {
// What we now do is get all of our peaks, and then measure the distance to
// other peaks, to create intervals. Then, based on the distance between
// those peaks (the distance of the intervals) we can calculate the BPM of
// that particular interval.
// The interval that is seen the most should have the BPM that corresponds
// to the track itself.
const groups = [];
// Comparing each peak with the next one to compute an interval group
peaks.forEach((peak, index) => {
for (let i = 1; (index + i) < peaks.length && i < 10; ++i) {
const group = {
tempo: (60 * this._sampleRate) / (peaks[index + i].position - peak.position),
count: 1,
position: peak.position,
peaks: []
};
// Trim to fit tempo range to lower bound
while (group.tempo <= this._bpmRange[0]) {
group.tempo *= 2;
}
// Trim to fit tempo range to upper bound
while (group.tempo > this._bpmRange[1]) {
group.tempo /= 2;
}
// Integer or floating rounding of tempo value
if (this._round === true) { // Integer rounding
group.tempo = Math.round(group.tempo);
} else { // Floating rounding
group.tempo = this._floatRound(group.tempo, this._float);
}
// Test if exists and if so, increment the interval count number
const exists = groups.some(interval => {
if (interval.tempo === group.tempo) {
interval.peaks.push(peak);
++interval.count;
// Notify that group already exists
return true;
}
// Return false if no match
return false;
});
// Insert only if not existing
if (!exists) {
groups.push(group);
}
}
});
return groups;
}
/** @method
* @name _getOffsets
* @private
* @memberof BeatDetect
* @description <blockquote>This method will finally compute time offset from song start to first bar, or first
* significant beat. See implementation for further details.</blockquote>
* @param {object[]} data - Array containg L audio data (no important to stereo this)
* @param {number} bpm - The most credible BPM, computed after the most frequent time interval
* @returns {object} The beat offset and the offset to the first bar **/
_getOffsets(data, bpm) {
// Now we have bpm, we re-calculate peaks for the 30 first seconds.
// Since a peak is at the maximum waveform height, we need to offset its time a little on its left.
// This offset is empiric, and based on a fraction of the BPM duration in time.
// We assume the left offset from the highest volule value is 5% of the bpm time frame
// Once peak are found and sorted, we get offset by taking the most intense peak (which is
// a strong time of the time signature), and use its position to find the smallest time from
// the track start that is relative to the time signature and the strong time found.
// The first bar is the actual first beat that overcome a 20% threshold, it will mostly be
// equal to the BPM offset.
var partSize = this._sampleRate / 2;
var parts = data.length / partSize;
var peaks = [];
// Create peak with little offset on the left to get the very start of the peak
for (let i = 0; i < parts; ++i) {
let max = 0;
for (let j = i * partSize; j < (i + 1) * partSize; ++j) {
const volume = data[j];
if (!max || (volume > max.volume)) {
max = {
position: j - Math.round(((60 / bpm) * 0.05) * this._sampleRate), // Arbitrary offset on the left of the peak about 5% bpm time
volume: volume
};
}
}
peaks.push(max);
}
// Saved peaks ordered by position before any sort manipuplation
const unsortedPeaks = [...peaks]; // Clone array before sorting for first beat matching
// Sort peak per decreasing volumes
peaks.sort((a, b) => {
return b.volume - a.volume;
});
// First peak is the loudest, we assume it is a strong time of the 4/4 time signature
const refOffset = this._getLowestTimeOffset(peaks[0].position, bpm);
let mean = 0;
let divider = 0;
// Find shortest offset
for (let i = 0; i < peaks.length; ++i) {
const offset = this._getLowestTimeOffset(peaks[i].position, bpm);
if (offset - refOffset < 0.05 || refOffset - offset > -0.05) { // Only keep first times to compute mean
mean += offset;
++divider;
}
}
// Find first beat offset
let i = 0; // Try finding the first peak index that is louder than provided threshold (0.02)
while (unsortedPeaks[i].volume < 0.02) { // Threshold is also arbitrary...
++i;
}
// Convert position into time
let firstBar = (unsortedPeaks[i].position / this._sampleRate);
// If matching first bar is before any possible time ellapsed, we set it at computed offset
if (firstBar > (mean / divider) && firstBar < (60 / bpm)) {
firstBar = (mean / divider)
}
// Return both offset and first bar offset
return {
offset: (mean / divider),
firstBar: firstBar
};
}
/** @method
* @name _getLowestTimeOffset
* @private
* @memberof BeatDetect
* @description <blockquote>This method will search for the smallest time in track for a beat ; using
* the estimated bpm, we rewind from time signature to get the closest from the track beginning.
* See implementation for further details.</blockquote>
* @param {object[]} position - The beat position for beat to lower
* @param {number} bpm - The most credible BPM, computed after the most frequent time interval
* @returns {object} The beat offset and the offset to the first bar **/
_getLowestTimeOffset(position, bpm) {
// Here we compute beat time offset using the first spotted peak.
// The lowest means we rewind following the time signature, to find the smallest time
// which is between 0s and the full mesure time (timesignature * tempo)
// Using its sample index and the found bpm
const bpmTime = 60 / bpm;
const firstBeatTime = position / this._sampleRate;
let offset = firstBeatTime;
while (offset >= bpmTime) {
offset -= (bpmTime * this._timeSignature);
}
if (offset < 0) {
while (offset < 0) {
offset += bpmTime;
}
}
return offset;
}
/** @method
* @name _getPerfDuration
* @private
* @memberof BeatDetect
* @description <blockquote>This method will format performance mark to be readable as times</blockquote>
* @param {object[]} perf - The performance mark to format
* @returns {object} The ellapsed times for different beat detection steps **/
_getPerfDuration(perf) {
// Convert performance mark into ellapsed seconds
return {
total: (perf.m3 - perf.m0) / 1000,
fetch: (perf.m1 - perf.m0) / 1000,
render: (perf.m2 - perf.m1) / 1000,
process: (perf.m3 - perf.m2) / 1000
}
}
/* --------------------------------------------------------------------------------------------------------------- */
/* ------------------------------------------- BPM TAP METHODS ------------------------------------------------ */
/* --------------------------------------------------------------------------------------------------------------- */
/** @method
* @name tapBpm
* @public
* @memberof BeatDetect
* @description <blockquote>Providing a DOM element and a callback to manually determine a bpm, using a click.
* After 5 seconds, the result will be reset.</blockquote>
* @param {objects} options - Manual bpm determinitation options
* @param {object} options.element - The DOM element to listen to
* @param {number} options.precision - The floating point for result
* @param {function} options.callback - The callback function to call each click **/
tapBpm(options) {
options.element.addEventListener('click', this._tapBpm.bind(this, options), false);
}
/** @method
* @name _tapBpm
* @private
* @memberof BeatDetect
* @description <blockquote>Internal method to determine manual BPM</blockquote>
* @param {object} options - The internal options object
* @param {number} precision - The floating point for result
* @param {function} callback - The callback function to call each click **/
_tapBpm(options) {
window.clearTimeout(this._tapResetId);
this._ts.current = Date.now();
// Store the first timestamp of the tap sequence on first click
if (this._ts.first === 0) {
this._ts.first = this._ts.current;
}
if (this._ts.previous !== 0) {
let bpm = 60000 * this.count / (this._ts.current - this._ts.first);
if (options.precision) {
bpm = this._floatRound(bpm, options.precision);
}
options.callback(bpm);
}
// Store the old timestamp
this._ts.previous = this._ts.current;
++this.count;
this._tapResetId = window.setTimeout(() => {
this.count = 0;
this._ts.current = 0;
this._ts.previous = 0;
this._ts.first = 0;
options.callback('--');
}, 5000);
}
/* --------------------------------------------------------------------------------------------------------------- */
/* -------------------------------------------- UTIL METHODS -------------------------------------------------- */
/* --------------------------------------------------------------------------------------------------------------- */
/** @method
* @name _logEvent
* @private
* @memberof BeatDetect
* @description <blockquote>This method will display a given console output if the logging is allowed.</blockquote>
* @param {string} level - The console method to call in info, log, warn, error, trace etc.
* @param {string} string - The text to display in the console **/
_logEvent(level, string) {
if (this._log === true) {
console[level](`BeatDetect : ${string}`);
}
}
/** @method
* @name _floatRound
* @private
* @memberof BeatDetect
* @description <blockquote>This method will return a rounded floating value to a given precision.</blockquote>
* @param {number} value - The value to round at a given floating point
* @param {number} precision - The amount of numbers after the floating point
* @returns {number} The rounded value with its given floating point **/
_floatRound(value, precision) {
const multiplier = Math.pow(10, precision || 0);
return Math.round(value * multiplier) / multiplier;
}
/* --------------------------------------------------------------------------------------------------------------- */
/* -------------------------------------------- SETTERS METHODS ----------------------------------------------- */
/* --------------------------------------------------------------------------------------------------------------- */
/** Set sample rate for analysis.
* @param {number} sampleRate **/
set sampleRate(sampleRate) {
this._sampleRate = sampleRate;
}
/** Set logging in console.
* @param {boolean} log **/
set log(log) {
this._log = log;
}
/** Set performance timings in console.
* @param {boolean} perf **/
set perf(perf) {
this._perf = perf;
}
/** Set output rounding.
* @param {boolean} round **/
set round(round) {
this._round = round;
}
/** Set the output floating precision.
* @param {number} round **/
set float(float) {
this._float = float;
}
/** Set the low pass filter cut frequency.
* @param {number} round **/
set lowPassFreq(lowPassFreq) {
this._lowPassFreq = lowPassFreq;
}
/** Set the high pass filter cut frequency.
* @param {number} round **/
set highPassFreq(highPassFreq) {
this._highPassFreq = highPassFreq;
}
}
export default BeatDetect;