├── LICENSE.txt
├── README.md
└── beatdetector.js
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Jakob Stasilowicz
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | BeatDetector.js
2 | =============
3 | ---
4 | A pretty rudimentary but working beat detector. Built using the Web audio api. Based on comparing average shift in freq amplitudes in a current sample to a sample history. Catches heavy beat hits pretty accurately (techno, house, hip hop, that kinda stuff)
5 |
6 | BPM calculation funcionality mostly for kicks: don't use it for anything exact, there are far better options.
7 |
8 | Based on the frequency select-algorithm (the web audio api does fft-calculations for us) but without band splitting from:
9 | http://archive.gamedev.net/archive/reference/programming/features/beatdetection/index.html
10 |
11 | #### Example:
12 | ```
13 | var song = new stasilo.BeatDetector({sens: 5.0,
14 | visualizerFFTSize: 256,
15 | analyserFFTSize: 256,
16 | passFreq: 600,
17 | url: "file.mp3" } );
18 | ```
19 |
20 | ### Usage
21 | To get frequency data for drawing bars and what not:
22 |
23 | ```
24 | song.getAudioFreqData();
25 | ```
26 |
27 | This returns an array of visualizerFFTSize / 2 data values corresponding to frequency amplitudes.
28 |
29 | ### To detect a beat hit
30 |
31 | Call
32 | ```
33 | song.isOnBeat()
34 | ```
35 | from the render loop of your script. Returns true if song is on a beat.
36 |
37 | For everything else, see the source.
38 |
39 |
40 | Settings
41 | ----------
42 | > **sens:**
43 | > Sensitivity of the algorithm. A value between 1 and 16 (1 - low threshold, 16 - high treshold) should do it. Requires a bit of trail and error tweaking for the sweet spot.
44 | > > This setting is required.
45 |
46 | > **url:**
47 | > Url to audio file.
48 | > > Detection defaults to microphone if no url is supplied.
49 | >
50 | > **visualizerFFTSize:**
51 | > Size of fft calculations for visualizations.
52 | Must be a power of two (2^7 = 128, 2^8 = 256, 2^9 = 512, ...)
53 | >> Default value: 256
54 |
55 | > **analyserFFTSize:**
56 | > Size of fft calculation for the algorithm
57 | Must be a power of two (2^7 = 128, 2^8 = 256, 2^9 = 512, ...)
58 | >> Default value: 256
59 |
60 | > **passFreq:**
61 | > Float. If supplied, passes audio through a bandpass filter with a peak at this frequency before passing it on to the algorithm. Suitable for example when a song has a loud treble/mid section and you'd like to detect bass drum beats, in which case a bandpass at 100-800Hz could help you out.
62 | >>Freq chart for common instruments:
63 | http://www.independentrecording.net/irn/resources/freqchart/main_display.htm
64 |
65 | >> Default value: off
66 |
67 | >**loop:**
68 | > Boolean. Whether to loop the sound or not.
69 | > >Default: false.
70 |
71 | >**playbackFinished:**
72 | > A function called at the end of playback.
73 |
74 | >**progress(obj):**
75 | > A callback run while sound is downloading from url. An object of {percent: value, complete: boolean} is passed as an argument. Useful for when loading sounds through ajax.
76 |
77 |
78 |
79 | Browser support
80 | -------------------
81 | Please see: http://caniuse.com/#feat=audio-api
82 |
83 |
84 |
85 |
86 | Contact
87 | -------------------
88 | Jakob Stasilowicz made this. Contact me through kontakt [at] stasilo.se or http:///www.stasilo.se.
--------------------------------------------------------------------------------
/beatdetector.js:
--------------------------------------------------------------------------------
1 | /*
2 | * BeatDetector.js
3 | * written by Jakob Stasilowicz
4 | *
5 | * kontakt [at] stasilo.se
6 | *
7 | * A pretty rudimentary but working beat detector. Built using the Web audio api.
8 | * Based on comparing average shift in freq amplitudes in a current sample to a sample history.
9 | * Catches heavy beat hits pretty accurately (techno, house, hip hop, that kinda stuff)
10 | */
11 |
12 | //set up name space
13 |
14 | if (typeof stasilo == 'undefined')
15 | {
16 | stasilo = {};
17 | }
18 |
19 |
20 | (function()
21 | {
22 | // create audio context
23 | // one per document/page!!
24 |
25 | var context = null;
26 |
27 | try
28 | {
29 | context = new ( window.AudioContext || window.webkitAudioContext )();
30 | }
31 | catch(e)
32 | {
33 | alert('Sorry, the web audio api is not supported by your browser!');
34 | }
35 |
36 | // constructor
37 |
38 | this.BeatDetector = function(settings)
39 | {
40 | if( !(this instanceof stasilo.BeatDetector) )
41 | {
42 | return new BeatDetector(settings);
43 | }
44 |
45 | //globals
46 | this.historyBuffer = [];
47 | this.instantEnergy = 0;
48 | this.prevTime = 0;
49 | this.bpmTable = [];
50 |
51 | this.bpm = {time: 0, counter: 0};
52 |
53 | this.startTime = 0, this.startOffset = 0;
54 | this.settings = settings;
55 |
56 | // check if song download is in progress
57 | this.loading = false;
58 |
59 | // create analyzer node
60 | this.analyser = context.createAnalyser();
61 | this.visualizer = context.createAnalyser();
62 |
63 | this.visualizer.fftSize = (settings.visualizerFFTSize ? settings.visualizerFFTSize : 256);
64 | this.analyser.fftSize = (settings.analyserFFTSize ? settings.analyserFFTSize : 256);
65 |
66 | /*
67 | * 44100 hertz á 16 bit =
68 | * each sample is 16 bits and is taken 44100 times a second
69 | * for each second: 16 * 44100 bits = 705600 bits = 88200 = 44100 * 2 bytes per second of audio (stereo)
70 | *
71 | * The fft in web audio seems to analyze 1024 samples each call =>
72 | * 43 * 1024 = 44032
73 | *
74 | * This means we have to call getByteFrequencyData() 43 times, thus receiving a MAX_COLLECT_SIZE
75 | * of 43 * 128 = 5504 for 1s of audio (in case fft = 256) in the historyBuffer or
76 | * 43 * (fftSize / 2) = MAX_COLLECT_SIZE for a variable fft size.
77 | */
78 |
79 | this.MAX_COLLECT_SIZE = 43 * (this.analyser.fftSize / 2);
80 | this.COLLECT_SIZE = 1;
81 |
82 | //sensitivity of detection
83 | this.sens = 1 + (settings.sens ? settings.sens / 100 : 0.05);
84 |
85 |
86 | //microphone
87 | navigator.getUserMedia = navigator.getUserMedia ||
88 | navigator.webkitGetUserMedia ||
89 | navigator.mozGetUserMedia ||
90 | navigator.msGetUserMedia;
91 |
92 | this.bufferLength = this.analyser.frequencyBinCount;
93 |
94 | //create empty historybuffer
95 | for(i = 0; this.historyBuffer.length < this.MAX_COLLECT_SIZE - this.COLLECT_SIZE - 1; i++)
96 | {
97 | this.historyBuffer.push(1);
98 | }
99 |
100 | // create low pass bandpassFilter node
101 | // used to isolate freq spectrum for beat detection
102 | // optional
103 |
104 | this.bandpassFilter = context.createBiquadFilter();
105 |
106 | this.bandpassFilter.type = (typeof this.bandpassFilter.type === 'string') ? 'bandpass' : 2;
107 | this.bandpassFilter.frequency.value = (settings.passFreq ? settings.passFreq : 400);
108 | this.bandpassFilter.Q.value = 0.5;
109 |
110 | // create gain node
111 | this.gainNode = (context.createGain() || context.createGainNode());
112 |
113 | var self = this; // for later async access
114 |
115 | if(settings.url) // url supplied as soundsource
116 | {
117 | // load the sound
118 | this.soundSource = context.createBufferSource();
119 |
120 | // don't use $.ajax() to keep beatdetector dependency free (even of jquery)
121 | var request = new XMLHttpRequest();
122 |
123 | this.loading = true;
124 | request.open("GET", settings.url, true);
125 | request.responseType = "arraybuffer";
126 |
127 |
128 | //send progress info to callback if avail.
129 | if (typeof this.settings.progress == 'function')
130 | {
131 | request.addEventListener("progress", function(e)
132 | {
133 | var percent = 0;
134 |
135 | if ( e.lengthComputable )
136 | {
137 | percent = ( e.loaded / e.total ) * 100;
138 |
139 | }
140 |
141 | settings.progress( {percent: percent, complete: false} );
142 |
143 | }, false);
144 |
145 | //tell when complete
146 | request.addEventListener("load", function(e)
147 | {
148 | settings.progress({percent: 100, complete: true});
149 |
150 | }, false);
151 | }
152 |
153 | // this loads asynchronously
154 |
155 | request.onload = function()
156 | {
157 | var audioData = request.response;
158 |
159 | // add buffer to sound source
160 | context.decodeAudioData(audioData,
161 | function(buffer)
162 | {
163 | self.soundSource.buffer = self.soundBuffer = buffer;
164 |
165 | //save length of buffer
166 | self.currentDuration = self.soundBuffer.duration;
167 | //self.soundSource.loop = true;
168 |
169 | self.loading = false;
170 | self.startTime = context.currentTime;
171 |
172 | },
173 |
174 | function(e)
175 | {
176 | alert("Error decoding audio data");
177 |
178 | console.log(e);
179 | });
180 | };
181 |
182 | request.send();
183 |
184 | // Connect analyser and context to source
185 | // source -> bandpassFilter -> analyse -> gain -> destination
186 |
187 | this.connectGraph();
188 |
189 |
190 | this.soundSource.start ? this.soundSource.start(0) : this.soundSource.noteOn(0);
191 | }
192 | else //microphone as soundsource
193 | {
194 | function gotStream(stream)
195 | {
196 | self.soundSource = context.createMediaStreamSource(stream);
197 |
198 | self.soundSource.connect(self.analyser);
199 | self.soundSource.connect(self.visualizer);
200 |
201 | self.soundSource.connect(self.gainNode);
202 |
203 | self.gainNode.connect(context.destination);
204 |
205 | self.micStream = stream;
206 | }
207 |
208 | navigator.getUserMedia(
209 | {
210 | "audio":
211 | {
212 | "mandatory":
213 | {
214 | "googEchoCancellation": "false",
215 | "googAutoGainControl": "false",
216 | "googNoiseSuppression": "false",
217 | "googHighpassFilter": "false"
218 | },
219 |
220 | "optional": []
221 | },
222 |
223 | }, gotStream,
224 | function(e)
225 | {
226 | alert('Error getting microphone audio');
227 | console.log(e);
228 | });
229 | }
230 | }
231 |
232 |
233 | //methods
234 |
235 | this.BeatDetector.prototype =
236 | {
237 | setVolume: function(volume)
238 | {
239 | this.gainNode.gain.value = volume * volume;
240 | },
241 |
242 | getVolume: function()
243 | {
244 | return this.gainNode.gain.value;
245 | },
246 |
247 | pause: function()
248 | {
249 | //check if running from url
250 | if(this.soundSource.playbackState === this.soundSource.PLAYING_STATE)
251 | {
252 | this.soundSource.stop(0);
253 |
254 | // measure how much time passed since the last pause/stop.
255 | this.startOffset += (context.currentTime - this.startTime);
256 | }
257 | else if(typeof this.micStream !== 'undefined') //or mic
258 | {
259 | this.micStream.stop();
260 | }
261 | },
262 |
263 |
264 | play: function(offset)
265 | {
266 | // fast forward or rewind if offset is supplied
267 |
268 | this.startOffset += offset;
269 |
270 | if(this.startOffset < 0)
271 | {
272 | this.startOffset = 0;
273 | }
274 |
275 | this.soundSource = context.createBufferSource();
276 | this.soundSource.buffer = this.soundBuffer;
277 |
278 | this.connectGraph();
279 |
280 | // start playback, but make sure we stay in bound of the buffer.
281 |
282 | this.soundSource.start(0, (this.startOffset) % this.soundBuffer.duration);
283 | this.startTime = context.currentTime;
284 |
285 | //this.startOffset += (context.currentTime + offset - this.startTime);
286 | },
287 |
288 | isFinished: function(offset)
289 | {
290 |
291 | var dur = ((this.currentDuration === "undefined") ? 0 : this.currentDuration);
292 |
293 | if( this.getElapsedTime() >= dur && dur != 0) //played whole buffer
294 | {
295 | if(this.soundSource.playbackState === this.soundSource.PLAYING_STATE)
296 | {
297 | this.soundSource.stop(0);
298 |
299 | //run callback if supplied
300 | if (typeof this.settings.playbackFinished == 'function')
301 | {
302 | this.settings.playbackFinished();
303 | }
304 | }
305 |
306 | return true;
307 | }
308 | },
309 |
310 | // Connect audio graph points
311 | connectGraph: function()
312 | {
313 | //this.soundSource.buffer = this.soundBuffer;
314 |
315 | //this.soundSource.loop = true;
316 | //this.soundSource.connect(context.destination);
317 |
318 | if(this.settings.passFreq)
319 | {
320 | this.soundSource.connect(this.bandpassFilter);
321 | this.bandpassFilter.connect(this.analyser);
322 |
323 | console.log("Using bandpass filter");
324 | }
325 | else
326 | {
327 | this.soundSource.connect(this.analyser);
328 | }
329 |
330 | this.soundSource.connect(this.visualizer);
331 | this.soundSource.connect(this.gainNode);
332 | //bandpassFilter.connect(gainNode);
333 |
334 | this.gainNode.connect(context.destination);
335 | //this.gainNode.connect(this.visualizer);
336 |
337 | },
338 |
339 | /*
340 | * Call his from the main render loop. Returns true if song is on a peak/beat,
341 | * false otherwise.
342 | */
343 |
344 | isOnBeat: function()
345 | {
346 | var localAverageEnergy = 0;
347 | var instantCounter = 0;
348 | var isBeat = false;
349 |
350 | var bpmArray = new Uint8Array(this.bufferLength);
351 | this.analyser.getByteFrequencyData(bpmArray); //size = 128 * [0, 256](?)
352 |
353 | // check if audio has finished playing
354 | this.isFinished();
355 |
356 | // fill history buffer
357 | for(var i = 0; i < bpmArray.length - 1; i++, ++instantCounter)
358 | {
359 | this.historyBuffer.push(bpmArray[i]); //add sample to historyBuffer
360 |
361 | this.instantEnergy += bpmArray[i];
362 | }
363 |
364 | //done collecting MAX_COLLECT_SIZE history samples
365 | //have COLLECT_SIZE nr of samples as instant energy value
366 |
367 | if(instantCounter > this.COLLECT_SIZE - 1 &&
368 | this.historyBuffer.length > this.MAX_COLLECT_SIZE - 1)
369 | {
370 | this.instantEnergy = this.instantEnergy / (this.COLLECT_SIZE * (this.analyser.fftSize / 2));
371 |
372 | var average = 0;
373 | for(var i = 0; i < this.historyBuffer.length - 1; i++)
374 | {
375 | average += this.historyBuffer[i];
376 | }
377 |
378 | localAverageEnergy = average/this.historyBuffer.length;
379 |
380 | var timeDiff = context.currentTime - this.prevTime;
381 |
382 | // timeDiff > 2 is out of normal song bpm range, but if it is a multiple of range [0.3, 1.5]
383 | // we probably have missed a beat before but now have a match in the bpm table.
384 |
385 | if(timeDiff > 2 && this.bpmTable.length > 0)
386 | {
387 | //console.log("timediff is now greater than 3");
388 |
389 | //check if we have a multiple of range in bpm table
390 |
391 | for(var j = 0; j < this.bpmTable.length - 1; j++)
392 | {
393 | // mutiply by 10 to avoid float rounding errors
394 | var timeDiffInteger = Math.round( (timeDiff / this.bpmTable[j]['time']) * 1000 );
395 |
396 | // timeDiffInteger should now be a multiple of a number in range [3, 15]
397 | // if we have a match
398 |
399 | if(timeDiffInteger % (Math.round(this.bpmTable[j]['time']) * 1000) == 0)
400 | {
401 | timeDiff = new Number(this.bpmTable[j]['time']);
402 | //console.log("TIMEDIFF MULTIPLE MATCH: " + timeDiff);
403 | }
404 | }
405 | }
406 |
407 |
408 | //still?
409 | if(timeDiff > 3)
410 | {
411 | this.prevTime = timeDiff = 0;
412 |
413 | }
414 |
415 | ////////////////////////
416 | // MAIN BPM HIT CHECK //
417 | ////////////////////////
418 |
419 | // CHECK IF WE HAVE A BEAT BETWEEN 200 AND 40 BPM (every 0.29 to 2s), or else ignore it.
420 | // Also check if we have _any_ found prev beats
421 |
422 | if( context.currentTime > 0.29 && this.instantEnergy > localAverageEnergy &&
423 | ( this.instantEnergy > (this.sens * localAverageEnergy) ) &&
424 | ( ( timeDiff < 2.0 && timeDiff > 0.29 ) || this.prevTime == 0 ) )
425 | {
426 |
427 | isBeat = true;
428 |
429 | this.prevTime = context.currentTime;
430 |
431 | this.bpm =
432 | {
433 | time: timeDiff.toFixed(3),
434 | counter: 1,
435 | };
436 |
437 |
438 | for(var j = 0; j < this.bpmTable.length; j++)
439 | {
440 | //FOUND ANOTHER MATCH FOR ALREADY GUESSED BEAT
441 |
442 | if(this.bpmTable[j]['time'] == this.bpm['time'])
443 | {
444 | this.bpmTable[j]['counter']++;
445 | this.bpm = 0;
446 |
447 | if(this.bpmTable[j]['counter'] > 3 && j < 2)
448 | {
449 | console.log("WE HAVE A BEAT MATCH IN TABLE!!!!!!!!!!");
450 | }
451 |
452 | break;
453 | }
454 | }
455 |
456 | if(this.bpm != 0 || this.bpmTable.length == 0)
457 | {
458 | this.bpmTable.push(this.bpm);
459 | }
460 |
461 | //sort and draw 10 most current bpm-guesses
462 | this.bpmTable.sort(function(a, b)
463 | {
464 | return b['counter'] - a['counter']; //descending sort
465 | });
466 | }
467 |
468 | var temp = this.historyBuffer.slice(0); //get copy of buffer
469 |
470 | this.historyBuffer = []; //clear buffer
471 |
472 | // make room in array by deleting the last COLLECT_SIZE samples.
473 | this.historyBuffer = temp.slice(this.COLLECT_SIZE * (this.analyser.fftSize / 2), temp.length);
474 |
475 | instantCounter = 0;
476 | this.instantEnergy = 0;
477 |
478 | localAverageEnergy = 0;
479 |
480 | }
481 |
482 |
483 | this.debug = "";
484 |
485 | for(i = 0; i < 10; i++)
486 | {
487 | if(i >= this.bpmTable.length)
488 | break;
489 |
490 | this.debug += ('Beat ' + i + ': ' + this.bpmTable[i]['time'] + ', counter: ' + this.bpmTable[i]['counter'] + ', calc. bpm: ' + Math.round(60/this.bpmTable[i]['time']) + '
');
491 | }
492 |
493 | this.debug += ( "history buffer size: " + this.historyBuffer.length + "
");
494 | this.debug += ( "instant energy: " + this.instantEnergy + "
");
495 | this.debug += ( "local energy: " + localAverageEnergy + "
");
496 |
497 | this.debug += ( "bpmArray size: " + bpmArray.length + "
");
498 | this.debug += "sensitivity: " + ( (this.sens - 1) * 100 ).toFixed(2) + "
";
499 |
500 | return isBeat;
501 | },
502 |
503 | getAudioFreqData: function()
504 | {
505 | var dataArray = new Uint8Array(this.bufferLength);
506 |
507 | this.visualizer.getByteFrequencyData(dataArray);
508 |
509 | return dataArray;
510 | },
511 |
512 | getTimeDomainData: function()
513 | {
514 | var dataArray = new Uint8Array(this.bufferLength);
515 |
516 | this.visualizer.getByteTimeDomainData(dataArray);
517 |
518 | return dataArray;
519 | },
520 |
521 | //duration of the current sample
522 | getDuration: function()
523 | {
524 | return (typeof this.soundBuffer === 'undefined' ) ? 0 : this.soundBuffer.duration;
525 | },
526 |
527 | getElapsedTime: function()
528 | {
529 | return ( context.currentTime + this.startOffset - this.startTime );
530 | },
531 |
532 | getDebugData: function()
533 | {
534 | return this.debug;
535 | },
536 |
537 | getFileName: function()
538 | {
539 | var name = this.settings.url.split("/");
540 |
541 | return name[name.length - 1];
542 | },
543 |
544 | getBPMGuess: function()
545 | {
546 | var guesses = allGuesses = 0;
547 | var counter = 0;
548 |
549 | if(this.bpmTable.length <= 2)
550 | {
551 | return -1;
552 | }
553 |
554 | for(var i = 0; i < this.bpmTable.length; i++)
555 | {
556 | allGuesses += (new Number(this.bpmTable[i]['time']));
557 |
558 | if(this.bpmTable[i]['counter'] > 1)
559 | {
560 | guesses += (new Number(this.bpmTable[i]['time']));
561 |
562 | counter++;
563 | }
564 | }
565 |
566 | //i have no idea i don't even....
567 | return { conservative: Math.round( 60 / (guesses/counter) ),
568 | all: Math.round( 60 / (allGuesses/this.bpmTable.length) ) };
569 |
570 | }
571 | };
572 | }).call(stasilo);
573 |
--------------------------------------------------------------------------------