How can I use JS WebAudioAPI for beat detection?
Asked Answered
O

2

14

I'm interested in using the JavaScript WebAudioAPI to detect song beats, and then render them in a canvas.

I can handle the canvas part, but I'm not a big audio guy and really don't understand how to make a beat detector in JavaScript.

I've tried following this article but cannot, for the life of me, connect the dots between each function to make a functional program.

I know I should show you some code but honestly I don't have any, all my attempts have failed miserably and the relevant code it's in the previously mentioned article.

Anyways I'd really appreciate some guidance, or even better a demo of how to actually detect song beats with the WebAudioAPI.

Thanks!

Oakland answered 7/5, 2015 at 20:6 Comment(5)
The article you linked to covers the whole topic rather darn well, what specifically are you having a hard time with?Guessrope
Yes I know! That's why is so frustrating, I can't make it work, I mean where do I get the threshold or the data for the first function, do I use the filters before getting the peaks or after? I feel dumb.Oakland
The threshold depends a lot on what type of audio you're working with - shortly put it's a number you choose. The data is just what ever audio file you want to work with.Guessrope
As I said I'm not an audio guy, so that doesn't really help me, as for the data is it the buffer of the audio file? (as when you load an audio file via WebAudioAPI)Oakland
I actually find the MDN page about the Web Audio API quite informative.Gaff
G
22

The main thing to understand about the referenced article by Joe Sullivan is that even though it gives a lot of source code, it's far from final and complete code. To reach a working solution you will still need both some coding and debugging skills.

This answer draws most of its code from the referenced article, original licensing applies where appropriate.

Below is a naïve sample implementation for using the functions described by the above article, you still need to figure out correct thresholds for a functional solution.


The code consists of preparation code written for the answer:

and then, as described in the article:

  • filtering the audio, in this example with a low-pass filter
  • calculating peaks using a threshold
  • grouping interval counts and then tempo counts

For the threshold I used an arbitrary value of .98 of the range between maximum and minimum values; when grouping I added some additional checks and arbitrary rounding to avoid possible infinite loops and make it an easy-to-debug sample.

Note that commenting is scarce to keep the sample implementation brief because:

  • the logic behind processing is explained in the referenced article
  • the syntax can be referenced in the API docs of the related methods

audio_file.onchange = function() {
  var file = this.files[0];
  var reader = new FileReader();
  var context = new(window.AudioContext || window.webkitAudioContext)();
  reader.onload = function() {
    context.decodeAudioData(reader.result, function(buffer) {
      prepare(buffer);
    });
  };
  reader.readAsArrayBuffer(file);
};

function prepare(buffer) {
  var offlineContext = new OfflineAudioContext(1, buffer.length, buffer.sampleRate);
  var source = offlineContext.createBufferSource();
  source.buffer = buffer;
  var filter = offlineContext.createBiquadFilter();
  filter.type = "lowpass";
  source.connect(filter);
  filter.connect(offlineContext.destination);
  source.start(0);
  offlineContext.startRendering();
  offlineContext.oncomplete = function(e) {
    process(e);
  };
}

function process(e) {
  var filteredBuffer = e.renderedBuffer;
  //If you want to analyze both channels, use the other channel later
  var data = filteredBuffer.getChannelData(0);
  var max = arrayMax(data);
  var min = arrayMin(data);
  var threshold = min + (max - min) * 0.98;
  var peaks = getPeaksAtThreshold(data, threshold);
  var intervalCounts = countIntervalsBetweenNearbyPeaks(peaks);
  var tempoCounts = groupNeighborsByTempo(intervalCounts);
  tempoCounts.sort(function(a, b) {
    return b.count - a.count;
  });
  if (tempoCounts.length) {
    output.innerHTML = tempoCounts[0].tempo;
  }
}

// http://tech.beatport.com/2014/web-audio/beat-detection-using-web-audio/
function getPeaksAtThreshold(data, threshold) {
  var peaksArray = [];
  var length = data.length;
  for (var i = 0; i < length;) {
    if (data[i] > threshold) {
      peaksArray.push(i);
      // Skip forward ~ 1/4s to get past this peak.
      i += 10000;
    }
    i++;
  }
  return peaksArray;
}

function countIntervalsBetweenNearbyPeaks(peaks) {
  var intervalCounts = [];
  peaks.forEach(function(peak, index) {
    for (var i = 0; i < 10; i++) {
      var interval = peaks[index + i] - peak;
      var foundInterval = intervalCounts.some(function(intervalCount) {
        if (intervalCount.interval === interval) return intervalCount.count++;
      });
      //Additional checks to avoid infinite loops in later processing
      if (!isNaN(interval) && interval !== 0 && !foundInterval) {
        intervalCounts.push({
          interval: interval,
          count: 1
        });
      }
    }
  });
  return intervalCounts;
}

function groupNeighborsByTempo(intervalCounts) {
  var tempoCounts = [];
  intervalCounts.forEach(function(intervalCount) {
    //Convert an interval to tempo
    var theoreticalTempo = 60 / (intervalCount.interval / 44100);
    theoreticalTempo = Math.round(theoreticalTempo);
    if (theoreticalTempo === 0) {
      return;
    }
    // Adjust the tempo to fit within the 90-180 BPM range
    while (theoreticalTempo < 90) theoreticalTempo *= 2;
    while (theoreticalTempo > 180) theoreticalTempo /= 2;

    var foundTempo = tempoCounts.some(function(tempoCount) {
      if (tempoCount.tempo === theoreticalTempo) return tempoCount.count += intervalCount.count;
    });
    if (!foundTempo) {
      tempoCounts.push({
        tempo: theoreticalTempo,
        count: intervalCount.count
      });
    }
  });
  return tempoCounts;
}

// https://mcmap.net/q/28558/-find-the-min-max-element-of-an-array-in-javascript
function arrayMin(arr) {
  var len = arr.length,
    min = Infinity;
  while (len--) {
    if (arr[len] < min) {
      min = arr[len];
    }
  }
  return min;
}

function arrayMax(arr) {
  var len = arr.length,
    max = -Infinity;
  while (len--) {
    if (arr[len] > max) {
      max = arr[len];
    }
  }
  return max;
}
<input id="audio_file" type="file" accept="audio/*"></input>
<audio id="audio_player"></audio>
<p>
  Most likely tempo: <span id="output"></span>
</p>
Guessrope answered 7/5, 2015 at 22:34 Comment(6)
Thanks! This is a really complete answer considering what little I brought to the discussion :) Although I'm not sure how effective this method really is, since the song, X-Ambassadors - Renegades, for example, gives me 128BPM with your snippet, and when run through this website, 90BPM. Anyways that's obviously not your fault, but the algorithm's, so thank you very much :)Oakland
One issue is that the linked code and the snippet above don't set the lowpass filter frequency. Who knows what it defaults to. If I look at the source of the page it sets it to 200Hz for the 'With lowpass filter' button. filter.frequency.value = 200Skinned
@Skinned This is a very basic example of using the functionality, there's obviously a lot of room for improvement. But thanks for the comment.Guessrope
The default frequency for a filter, BTW, is 440Hz.Mecham
i think your programm is buggy. I Tested 10 Songs and I always got 128BPMHaggard
@Haggard It's entirely possible this is due to the arbitrary values I chose for the threshold and the filter default (see above), this is merely a sample of how to bind the described functions together, as asked.Guessrope
P
12

I wrote a tutorial here which shows how to do this with the javascript Web Audio API.

https://askmacgyver.com/blog/tutorial/how-to-implement-tempo-detection-in-your-application

Outline of Steps

  1. Transform Audio File into an Array Buffer
  2. Run Array Buffer Through Low Pass Filter
  3. Trim a 10 second Clip from the Array Buffer
  4. Down Sample the Data
  5. Normalize the Data
  6. Count Volume Groupings
  7. Infer Tempo from Groupings Count

This code below does the heavy lifting.

Load Audio File Into Array Buffer and Run Through Low Pass Filter

function createBuffers(url) {

 // Fetch Audio Track via AJAX with URL
 request = new XMLHttpRequest();

 request.open('GET', url, true);
 request.responseType = 'arraybuffer';

 request.onload = function(ajaxResponseBuffer) {

    // Create and Save Original Buffer Audio Context in 'originalBuffer'
    var audioCtx = new AudioContext();
    var songLength = ajaxResponseBuffer.total;

    // Arguments: Channels, Length, Sample Rate
    var offlineCtx = new OfflineAudioContext(1, songLength, 44100);
    source = offlineCtx.createBufferSource();
    var audioData = request.response;
    audioCtx.decodeAudioData(audioData, function(buffer) {

         window.originalBuffer = buffer.getChannelData(0);
         var source = offlineCtx.createBufferSource();
         source.buffer = buffer;

         // Create a Low Pass Filter to Isolate Low End Beat
         var filter = offlineCtx.createBiquadFilter();
         filter.type = "lowpass";
         filter.frequency.value = 140;
         source.connect(filter);
         filter.connect(offlineCtx.destination);

            // Render this low pass filter data to new Audio Context and Save in 'lowPassBuffer'
            offlineCtx.startRendering().then(function(lowPassAudioBuffer) {

             var audioCtx = new(window.AudioContext || window.webkitAudioContext)();
             var song = audioCtx.createBufferSource();
             song.buffer = lowPassAudioBuffer;
             song.connect(audioCtx.destination);

             // Save lowPassBuffer in Global Array
             window.lowPassBuffer = song.buffer.getChannelData(0);
             console.log("Low Pass Buffer Rendered!");
            });

        },
        function(e) {});
 }
 request.send();
}


createBuffers('https://askmacgyver.com/test/Maroon5-Moves-Like-Jagger-128bpm.mp3');

You Now Have an Array Buffer of the Low Pass Filtered Song (And Original)

It's comprised of a number of entries, sampleRate (44100 multiplied by the number of seconds of the song).

window.lowPassBuffer  // Low Pass Array Buffer
window.originalBuffer // Original Non Filtered Array Buffer

Trim a 10 Second Clip from the Song

function getClip(length, startTime, data) {

  var clip_length = length * 44100;
  var section = startTime * 44100;
  var newArr = [];

  for (var i = 0; i < clip_length; i++) {
     newArr.push(data[section + i]);
  }

  return newArr;
}

// Overwrite our array buffer to a 10 second clip starting from 00:10s
window.lowPassFilter = getClip(10, 10, lowPassFilter);

Down Sample Your Clip

function getSampleClip(data, samples) {

  var newArray = [];
  var modulus_coefficient = Math.round(data.length / samples);

  for (var i = 0; i < data.length; i++) {
     if (i % modulus_coefficient == 0) {
         newArray.push(data[i]);
     }
  }
  return newArray;
}

// Overwrite our array to down-sampled array.
lowPassBuffer = getSampleClip(lowPassFilter, 300);

Normalize Your Data

function normalizeArray(data) {

 var newArray = [];

 for (var i = 0; i < data.length; i++) {
     newArray.push(Math.abs(Math.round((data[i + 1] - data[i]) * 1000)));
 }

 return newArray;
}

// Overwrite our array to the normalized array
lowPassBuffer = normalizeArray(lowPassBuffer);

Count the Flat Line Groupings

function countFlatLineGroupings(data) {

 var groupings = 0;
 var newArray = normalizeArray(data);

 function getMax(a) {
    var m = -Infinity,
        i = 0,
        n = a.length;

    for (; i != n; ++i) {
        if (a[i] > m) {
            m = a[i];
        }
    }
    return m;
 }

 function getMin(a) {
    var m = Infinity,
        i = 0,
        n = a.length;

    for (; i != n; ++i) {
        if (a[i] < m) {
            m = a[i];
        }
    }
    return m;
 }

 var max = getMax(newArray);
 var min = getMin(newArray);
 var count = 0;
 var threshold = Math.round((max - min) * 0.2);

 for (var i = 0; i < newArray.length; i++) {

   if (newArray[i] > threshold && newArray[i + 1] < threshold && newArray[i + 2] < threshold && newArray[i + 3] < threshold && newArray[i + 6] < threshold) {
        count++;
    }
 }

 return count;
}

// Count the Groupings
countFlatLineGroupings(lowPassBuffer);

Scale 10 Second Grouping Count to 60 Seconds to Derive Beats Per Minute

var final_tempo = countFlatLineGroupings(lowPassBuffer);

// final_tempo will be 21
final_tempo = final_tempo * 6;

console.log("Tempo: " + final_tempo);
// final_tempo will be 126
Preceding answered 28/12, 2016 at 1:50 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.