├── 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 | --------------------------------------------------------------------------------