Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I use JS WebAudioAPI for beat detection?

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!

like image 643
undefined Avatar asked May 07 '15 20:05

undefined


People also ask

What is the WebAudio API?

The WebAudio API is a high-level JavaScript API for processing and synthesizing audio in web applications. The actual processing will take place underlying implementation, such as Assembly, C, C++. The API consists on a graph, which redirect single or multiple input Sources into a Destination.

Which browsers support the audio API?

Not all browsers with support for the Audio API also support media streams (e.g. microphone input). See the getUserMedia/Streams API data for support for that feature. Firefox versions < 25 support an alternative, deprecated audio API. Chrome support went through some changes as of version 36.

What is beat detection and how do I use it?

Let’s define beat detection as determining (1) the location of significant drum hits within a song in order to (2) establish a tempo, in beats per minute (BPM). If you’re not familiar with using the WebAudio API and creating buffers, this tutorial will get you up to speed. We’ll use Go from Grimes as our sample track for the examples below.

How to get audio from a webcontext in JavaScript?

We can use the tag <audio> to fetch the sound we want to load. Using the createMediaElementSource helper function from the WebContext, we can create a MediaElementAudioSourceNode to load the sound. And then, obtain the tag from JavaScript and use it on the createMediaElementSource:


2 Answers

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:

  • reading a local file over the FileReader API
  • decoding the file as audio data using the AudioContext API

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;
}

// http://stackoverflow.com/questions/1669190/javascript-min-max-array-values
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>
like image 187
Etheryte Avatar answered Sep 18 '22 14:09

Etheryte


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
like image 27
Timothy Moody Avatar answered Sep 18 '22 14:09

Timothy Moody