From 9f705ec7addd1db127f4186cbeb90712be6c477c Mon Sep 17 00:00:00 2001 From: Mark Marijnissen Date: Tue, 24 Feb 2015 17:52:47 +0100 Subject: [PATCH 01/15] extracted PitchDetector into a standalone module + GUI enhancements --- LICENSE.txt | 2 +- README.md | 91 ++++++++- {img => example}/forkme.png | Bin example/gui.js | 236 +++++++++++++++++++++++ example/index.html | 71 +++++++ example/whistling3.ogg | Bin 0 -> 60789 bytes index.html | 57 ------ js/pitchdetect.js | 373 ------------------------------------ pitchdetector.js | 365 +++++++++++++++++++++++++++++++++++ 9 files changed, 759 insertions(+), 436 deletions(-) rename {img => example}/forkme.png (100%) create mode 100644 example/gui.js create mode 100644 example/index.html create mode 100644 example/whistling3.ogg delete mode 100644 index.html delete mode 100644 js/pitchdetect.js create mode 100644 pitchdetector.js diff --git a/LICENSE.txt b/LICENSE.txt index 3345269..7cd919c 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014 Chris Wilson +Copyright (c) 2014-2015 Chris Wilson - modified by Mark Marijnissen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index a0d0440..342caa5 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,90 @@ -# Simple pitch detection +# Pitch Detector -I whipped this app up to start experimenting with pitch detection, and also to test live audio input. It used to perform a naive (zero-crossing based) pitch detection algorithm; now it uses a naively-implemented auto-correlation algorithm in realtime, so it should work well with most monophonic waveforms (although strong harmonics will throw it off a bit). It works well with whistling (which has a clear, simple waveform); it also works pretty well to tune my guitar. +> I whipped this app up to start experimenting with pitch detection, and also to test live audio input. It used to perform a naive (zero-crossing based) pitch detection algorithm; now it uses a naively-implemented auto-correlation algorithm in realtime, so it should work well with most monophonic waveforms (although strong harmonics will throw it off a bit). It works well with whistling (which has a clear, simple waveform); it also works pretty well to tune my guitar. +> +> Live instance hosted on https://webaudiodemos.appspot.com/pitchdetect/. +> +> Check it out, feel free to fork, submit pull requests, etc. MIT-Licensed - party on. +> +> -Chris -Live instance hosted on https://webaudiodemos.appspot.com/pitchdetect/. +I've extracted the core logic into a standalone module. -Check it out, feel free to fork, submit pull requests, etc. MIT-Licensed - party on. +The GUI is now seperate (see `/example/gui.js`). I've also enhanced the display to visualize the detection algorithm. --Chris +- Mark + +## Usage + +Drop `pitchdetector.js` in your page, or use CommonJS modules (i.e. browserify, webpack) to require the file. + +First, create a PitchDetector: +```javascript +var detector = new PitchDetector({ + // Audio Context (Required) + context: new AudioContext(), + + // Input AudioNode (Required) + input: audioBufferNode, // default: Microphone input + + // Output AudioNode (Optional) + output: AudioNode, // default: no output + + // Callback on pitch detection (Optional) + callback: function(frequency, pitchDetector) { }, + + // Minimal signal strength (RMS, Optional) + minRms: 0.01, + + // Minimal Correlation for early detection (Optional) + minCorrelation: 0.9, + + // Only detect pitch once: + stopAfterDetection: false + + // Buffer length + length: 1024, + + // Limit frequency range (Optional): + minNote: 69, // MIDI note number + maxNote: 80, + + minFrequency: 440, // Frequency in Hz + maxFrequency: 20000, + + minPeriod: 2, // Actual distance in audio buffer + maxPeriod: 512 // --> convert to frequency: frequency = sampleRate / period + + // Start right away + start: true // default: false +}) +``` + +Then, start the pitch detection. It is tied to RequestAnimationFrame +```javascript +detector.start() +``` + +If you're done, you can stop or destroy the detector: +```javascript +detector.stop() +detector.destroy() +``` + +You can also query the latest data: +```javascript +detector.getFrequency() // --> 440hz +detector.getNoteNumber() // --> 69 +detector.getNoteString() // --> "A4" +detector.getPeriod() // --> 100 +detector.getDetune() // --> 0 +detector.getCorrelation() // --> 0.95 +``` + +Note that the callback gives you a reference to the pitchDetector, so you can do: +```javascript +var callback = function(frequency,detector) { + detector.getDetune(); + // etc +} +``` diff --git a/img/forkme.png b/example/forkme.png similarity index 100% rename from img/forkme.png rename to example/forkme.png diff --git a/example/gui.js b/example/gui.js new file mode 100644 index 0000000..b5d1126 --- /dev/null +++ b/example/gui.js @@ -0,0 +1,236 @@ +/* +The MIT License (MIT) + +Copyright (c) 2014 Chris Wilson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +window.AudioContext = window.AudioContext || window.webkitAudioContext; + +var audioContext = null; +var pitchDetector = null; + +var theBuffer = null; + +var DEBUGCANVAS = null; +var detectorElem, + canvas, + pitchElem, + noteElem, + detuneElem, + detuneAmount; + +window.onload = function() { + audioContext = new AudioContext(); + + var request = new XMLHttpRequest(); + request.open("GET", "./whistling3.ogg", true); + request.responseType = "arraybuffer"; + request.onload = function() { + audioContext.decodeAudioData( request.response, function(buffer) { + theBuffer = buffer; + } ); + }; + request.send(); + + detectorElem = document.getElementById( "detector" ); + DEBUGCANVAS = document.getElementById( "waveform" ); + if (DEBUGCANVAS) { + canvas = DEBUGCANVAS.getContext("2d"); + canvas.strokeStyle = "black"; + canvas.lineWidth = 1; + } + pitchElem = document.getElementById( "pitch" ); + noteElem = document.getElementById( "note" ); + detuneElem = document.getElementById( "detune" ); + detuneAmount = document.getElementById( "detune_amt" ); + + detectorElem.ondragenter = function () { + this.classList.add("droptarget"); + return false; }; + detectorElem.ondragleave = function () { this.classList.remove("droptarget"); return false; }; + detectorElem.ondrop = function (e) { + this.classList.remove("droptarget"); + e.preventDefault(); + theBuffer = null; + + var reader = new FileReader(); + reader.onload = function (event) { + audioContext.decodeAudioData( event.target.result, function(buffer) { + theBuffer = buffer; + }, function(){alert("error loading!");} ); + + }; + reader.onerror = function (event) { + alert("Error: " + reader.error ); + }; + reader.readAsArrayBuffer(e.dataTransfer.files[0]); + return false; + }; +}; + +function toggleOscillator() { + if(pitchDetector) pitchDetector.destroy(); + sourceNode = audioContext.createOscillator(); + sourceNode.frequency = 440; + sourceNode.start(0); + pitchDetector = new PitchDetector({ + context: audioContext, + callback: draw, + input: sourceNode, + maxFrequency: 500, + minFrequency: 300, + //minNote: 60, + //maxNote: 80, + //note: 69, + //output: audioContext.destination, + start: true + }); +} + +function toggleLiveInput() { + if(pitchDetector) pitchDetector.destroy(); + pitchDetector = new PitchDetector({ + context: audioContext, + callback: draw, + maxNote: 100, + minNote: 50, + minRms: 0.1, + // default input node is microphone + start: true + }); +} + +function togglePlayback() { + if(pitchDetector) pitchDetector.destroy(); + + var sourceNode = audioContext.createBufferSource(); + sourceNode.buffer = theBuffer; + sourceNode.loop = true; + sourceNode.start(0); + + pitchDetector = new PitchDetector({ + context: audioContext, + callback: draw, + input: sourceNode, + maxNote: 100, + minNote: 60, + output: audioContext.destination, + start: true + }); +} + +function stop(){ + if(pitchDetector) pitchDetector.destroy(); + pitchDetector = null; +} + +function draw( pitch ) { + if(!pitchDetector || !pitchDetector.buffer) return; + var buf = pitchDetector.buffer; + var i = 0, val = 0, len = 0; + + if (DEBUGCANVAS) { // This draws the current waveform, useful for debugging + var start = pitchDetector.periods[0]; + var end = pitchDetector.periods[pitchDetector.periods.length-1]; + + canvas.clearRect(0,0,512,256); + + canvas.fillStyle = "yellow"; + canvas.fillRect(start,0,end-start,(1-pitchDetector.minCorrelation) * 256); + + canvas.fillStyle = "#EEEEEE"; + var height = pitchDetector.rms * 256; + canvas.fillRect(0,256-height,512,height); + + canvas.strokeStyle = "black"; + canvas.beginPath(); + canvas.moveTo(0,256 - pitchDetector.minRms * 256); + canvas.lineTo(512,256 - pitchDetector.minRms * 256); + canvas.stroke(); + + canvas.strokeStyle = "green"; + canvas.beginPath(); + canvas.moveTo(start,0); + canvas.lineTo(start,256); + canvas.moveTo(end,0); + canvas.lineTo(end,256); + canvas.moveTo(start,256 - 256 * pitchDetector.minCorrelation); + canvas.lineTo(end,256 - 256 * pitchDetector.minCorrelation); + canvas.stroke(); + + canvas.beginPath(); + canvas.strokeStyle = "black"; + for(i = 0, len = pitchDetector.getPeriod() + 1; i + + +Pitch Detector + + + + + + + +
+ + + + +
+
+ +
+
--Hz
+
--
+
-- cents ♭ cents ♯
+
+ +
+ +

+Y-Axis: Correlation Score. +
+X-Axis: Signal Period, 2-512 samples (22.05 kHz - 83 Hz, F10 - E2) +
+Green lines: Range of pitch detection. +
+Yellow Area: Detection Area (minimum correlation required for detection) +
+Gray Area: Signal Strength (RMS) +
+Black line: Minimal Signal Strength +

+
+ + +Fork me on GitHub + + + diff --git a/example/whistling3.ogg b/example/whistling3.ogg new file mode 100644 index 0000000000000000000000000000000000000000..492986c693a9ecde116d08206077f66506cfecbf GIT binary patch literal 60789 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 - - -Pitch Detector - - - - - - - - - - - - -
-
--Hz
-
--
- -
--cents ♭cents ♯
-
- - -Fork me on GitHub - - - - diff --git a/js/pitchdetect.js b/js/pitchdetect.js deleted file mode 100644 index fc607b0..0000000 --- a/js/pitchdetect.js +++ /dev/null @@ -1,373 +0,0 @@ -/* -The MIT License (MIT) - -Copyright (c) 2014 Chris Wilson - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ - -window.AudioContext = window.AudioContext || window.webkitAudioContext; - -var audioContext = null; -var isPlaying = false; -var sourceNode = null; -var analyser = null; -var theBuffer = null; -var DEBUGCANVAS = null; -var mediaStreamSource = null; -var detectorElem, - canvasElem, - waveCanvas, - pitchElem, - noteElem, - detuneElem, - detuneAmount; - -window.onload = function() { - audioContext = new AudioContext(); - MAX_SIZE = Math.max(4,Math.floor(audioContext.sampleRate/5000)); // corresponds to a 5kHz signal - var request = new XMLHttpRequest(); - request.open("GET", "../sounds/whistling3.ogg", true); - request.responseType = "arraybuffer"; - request.onload = function() { - audioContext.decodeAudioData( request.response, function(buffer) { - theBuffer = buffer; - } ); - } - request.send(); - - detectorElem = document.getElementById( "detector" ); - canvasElem = document.getElementById( "output" ); - DEBUGCANVAS = document.getElementById( "waveform" ); - if (DEBUGCANVAS) { - waveCanvas = DEBUGCANVAS.getContext("2d"); - waveCanvas.strokeStyle = "black"; - waveCanvas.lineWidth = 1; - } - pitchElem = document.getElementById( "pitch" ); - noteElem = document.getElementById( "note" ); - detuneElem = document.getElementById( "detune" ); - detuneAmount = document.getElementById( "detune_amt" ); - - detectorElem.ondragenter = function () { - this.classList.add("droptarget"); - return false; }; - detectorElem.ondragleave = function () { this.classList.remove("droptarget"); return false; }; - detectorElem.ondrop = function (e) { - this.classList.remove("droptarget"); - e.preventDefault(); - theBuffer = null; - - var reader = new FileReader(); - reader.onload = function (event) { - audioContext.decodeAudioData( event.target.result, function(buffer) { - theBuffer = buffer; - }, function(){alert("error loading!");} ); - - }; - reader.onerror = function (event) { - alert("Error: " + reader.error ); - }; - reader.readAsArrayBuffer(e.dataTransfer.files[0]); - return false; - }; - - - -} - -function error() { - alert('Stream generation failed.'); -} - -function getUserMedia(dictionary, callback) { - try { - navigator.getUserMedia = - navigator.getUserMedia || - navigator.webkitGetUserMedia || - navigator.mozGetUserMedia; - navigator.getUserMedia(dictionary, callback, error); - } catch (e) { - alert('getUserMedia threw exception :' + e); - } -} - -function gotStream(stream) { - // Create an AudioNode from the stream. - mediaStreamSource = audioContext.createMediaStreamSource(stream); - - // Connect it to the destination. - analyser = audioContext.createAnalyser(); - analyser.fftSize = 2048; - mediaStreamSource.connect( analyser ); - updatePitch(); -} - -function toggleOscillator() { - if (isPlaying) { - //stop playing and return - sourceNode.stop( 0 ); - sourceNode = null; - analyser = null; - isPlaying = false; - if (!window.cancelAnimationFrame) - window.cancelAnimationFrame = window.webkitCancelAnimationFrame; - window.cancelAnimationFrame( rafID ); - return "play oscillator"; - } - sourceNode = audioContext.createOscillator(); - - analyser = audioContext.createAnalyser(); - analyser.fftSize = 2048; - sourceNode.connect( analyser ); - analyser.connect( audioContext.destination ); - sourceNode.start(0); - isPlaying = true; - isLiveInput = false; - updatePitch(); - - return "stop"; -} - -function toggleLiveInput() { - if (isPlaying) { - //stop playing and return - sourceNode.stop( 0 ); - sourceNode = null; - analyser = null; - isPlaying = false; - if (!window.cancelAnimationFrame) - window.cancelAnimationFrame = window.webkitCancelAnimationFrame; - window.cancelAnimationFrame( rafID ); - } - getUserMedia( - { - "audio": { - "mandatory": { - "googEchoCancellation": "false", - "googAutoGainControl": "false", - "googNoiseSuppression": "false", - "googHighpassFilter": "false" - }, - "optional": [] - }, - }, gotStream); -} - -function togglePlayback() { - if (isPlaying) { - //stop playing and return - sourceNode.stop( 0 ); - sourceNode = null; - analyser = null; - isPlaying = false; - if (!window.cancelAnimationFrame) - window.cancelAnimationFrame = window.webkitCancelAnimationFrame; - window.cancelAnimationFrame( rafID ); - return "start"; - } - - sourceNode = audioContext.createBufferSource(); - sourceNode.buffer = theBuffer; - sourceNode.loop = true; - - analyser = audioContext.createAnalyser(); - analyser.fftSize = 2048; - sourceNode.connect( analyser ); - analyser.connect( audioContext.destination ); - sourceNode.start( 0 ); - isPlaying = true; - isLiveInput = false; - updatePitch(); - - return "stop"; -} - -var rafID = null; -var tracks = null; -var buflen = 1024; -var buf = new Float32Array( buflen ); - -var noteStrings = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; - -function noteFromPitch( frequency ) { - var noteNum = 12 * (Math.log( frequency / 440 )/Math.log(2) ); - return Math.round( noteNum ) + 69; -} - -function frequencyFromNoteNumber( note ) { - return 440 * Math.pow(2,(note-69)/12); -} - -function centsOffFromPitch( frequency, note ) { - return Math.floor( 1200 * Math.log( frequency / frequencyFromNoteNumber( note ))/Math.log(2) ); -} - -// this is a float version of the algorithm below - but it's not currently used. -/* -function autoCorrelateFloat( buf, sampleRate ) { - var MIN_SAMPLES = 4; // corresponds to an 11kHz signal - var MAX_SAMPLES = 1000; // corresponds to a 44Hz signal - var SIZE = 1000; - var best_offset = -1; - var best_correlation = 0; - var rms = 0; - - if (buf.length < (SIZE + MAX_SAMPLES - MIN_SAMPLES)) - return -1; // Not enough data - - for (var i=0;i best_correlation) { - best_correlation = correlation; - best_offset = offset; - } - } - if ((rms>0.1)&&(best_correlation > 0.1)) { - console.log("f = " + sampleRate/best_offset + "Hz (rms: " + rms + " confidence: " + best_correlation + ")"); - } -// var best_frequency = sampleRate/best_offset; -} -*/ - -var MIN_SAMPLES = 0; // will be initialized when AudioContext is created. - -function autoCorrelate( buf, sampleRate ) { - var SIZE = buf.length; - var MAX_SAMPLES = Math.floor(SIZE/2); - var best_offset = -1; - var best_correlation = 0; - var rms = 0; - var foundGoodCorrelation = false; - var correlations = new Array(MAX_SAMPLES); - - for (var i=0;i0.9) && (correlation > lastCorrelation)) { - foundGoodCorrelation = true; - if (correlation > best_correlation) { - best_correlation = correlation; - best_offset = offset; - } - } else if (foundGoodCorrelation) { - // short-circuit - we found a good correlation, then a bad one, so we'd just be seeing copies from here. - // Now we need to tweak the offset - by interpolating between the values to the left and right of the - // best offset, and shifting it a bit. This is complex, and HACKY in this code (happy to take PRs!) - - // we need to do a curve fit on correlations[] around best_offset in order to better determine precise - // (anti-aliased) offset. - - // we know best_offset >=1, - // since foundGoodCorrelation cannot go to true until the second pass (offset=1), and - // we can't drop into this clause until the following pass (else if). - var shift = (correlations[best_offset+1] - correlations[best_offset-1])/correlations[best_offset]; - return sampleRate/(best_offset+(8*shift)); - } - lastCorrelation = correlation; - } - if (best_correlation > 0.01) { - // console.log("f = " + sampleRate/best_offset + "Hz (rms: " + rms + " confidence: " + best_correlation + ")") - return sampleRate/best_offset; - } - return -1; -// var best_frequency = sampleRate/best_offset; -} - -function updatePitch( time ) { - var cycles = new Array; - analyser.getFloatTimeDomainData( buf ); - var ac = autoCorrelate( buf, audioContext.sampleRate ); - // TODO: Paint confidence meter on canvasElem here. - - if (DEBUGCANVAS) { // This draws the current waveform, useful for debugging - waveCanvas.clearRect(0,0,512,256); - waveCanvas.strokeStyle = "red"; - waveCanvas.beginPath(); - waveCanvas.moveTo(0,0); - waveCanvas.lineTo(0,256); - waveCanvas.moveTo(128,0); - waveCanvas.lineTo(128,256); - waveCanvas.moveTo(256,0); - waveCanvas.lineTo(256,256); - waveCanvas.moveTo(384,0); - waveCanvas.lineTo(384,256); - waveCanvas.moveTo(512,0); - waveCanvas.lineTo(512,256); - waveCanvas.stroke(); - waveCanvas.strokeStyle = "black"; - waveCanvas.beginPath(); - waveCanvas.moveTo(0,buf[0]); - for (var i=1;i<512;i++) { - waveCanvas.lineTo(i,128+(buf[i]*128)); - } - waveCanvas.stroke(); - } - - if (ac == -1) { - detectorElem.className = "vague"; - pitchElem.innerText = "--"; - noteElem.innerText = "-"; - detuneElem.className = ""; - detuneAmount.innerText = "--"; - } else { - detectorElem.className = "confident"; - pitch = ac; - pitchElem.innerText = Math.round( pitch ) ; - var note = noteFromPitch( pitch ); - noteElem.innerHTML = noteStrings[note%12]; - var detune = centsOffFromPitch( pitch, note ); - if (detune == 0 ) { - detuneElem.className = ""; - detuneAmount.innerHTML = "--"; - } else { - if (detune < 0) - detuneElem.className = "flat"; - else - detuneElem.className = "sharp"; - detuneAmount.innerHTML = Math.abs( detune ); - } - } - - if (!window.requestAnimationFrame) - window.requestAnimationFrame = window.webkitRequestAnimationFrame; - rafID = window.requestAnimationFrame( updatePitch ); -} diff --git a/pitchdetector.js b/pitchdetector.js new file mode 100644 index 0000000..d71b863 --- /dev/null +++ b/pitchdetector.js @@ -0,0 +1,365 @@ + +/* +The MIT License (MIT) + +Copyright (c) 2014-2015 Chris Wilson, modified by Mark Marijnissen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +(function(){ +var noteStrings = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + +function frequencyToNote( frequency ) { + var noteNum = 12 * (Math.log( frequency / 440 )/Math.log(2) ); + return Math.round( noteNum ) + 69; +} + +function frequencyToString( frequency ){ + var note = frequencyToNote(frequency); + return noteStrings[note % 12] + Math.floor((note-12) / 12); +} + +function noteToFrequency( note ) { + return 440 * Math.pow(2,(note-69)/12); +} + +function noteToPeriod (note, sampleRate) { + return sampleRate / noteToFrequency(note); +} + +function centsOffFromPitch( frequency, note ) { + return Math.floor( 1200 * Math.log( frequency / noteToFrequency( note ))/Math.log(2) ); +} + +function getLiveInput(callback){ + try { + navigator.getUserMedia( + { + "audio": { + "mandatory": { + "googEchoCancellation": "false", + "googAutoGainControl": "false", + "googNoiseSuppression": "false", + "googHighpassFilter": "false" + }, + "optional": [] + }, + }, function(stream){ + var input = audioContext.createMediaStreamSource(stream); + callback(null,input); + }, function(error){ + callback(error,null); + }); + } catch(e) { + callback(e,null); + } +} + +// prefix fixes +var requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame; +navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; + +function PitchDetector(options){ + options = options || {}; + this.context = options.context; + this.sampleRate = this.context.sampleRate; + this.callback = options.callback || PitchDetector.defaultCallback.bind(this); + + this.minCorrelation = options.minCorrelation || 0.9; + this.minRms = options.minRms || 0.01; + this.stopAfterDetection = options.stopAfterDetection || false; + + this.buffer = new Float32Array( options.length || 1024 ); + this.MAX_SAMPLES = Math.floor(this.buffer.length/2); + + if(options.note){ + var period = Math.round(noteToPeriod(options.note,this.sampleRate)); + options.minPeriod = period - 1; + options.maxPeriod = period + 1; + } + if(options.minNote){ + options.maxPeriod = Math.round(noteToPeriod(options.minNote,this.sampleRate)); + } + if(options.maxNote){ + options.minPeriod = Math.round(noteToPeriod(options.maxNote,this.sampleRate)); + } + if(options.minFrequency) { + options.maxPeriod = Math.floor(this.sampleRate / options.minFrequency); + } + if(options.maxFrequency) { + options.minPeriod = Math.ceil(this.sampleRate / options.maxFrequency); + } + if(!options.periods){ + this.periods = []; + var minPeriod = options.minPeriod || 2; + var maxPeriod = this.MAX_SAMPLES; + if(options.maxPeriod && options.maxPeriod < maxPeriod){ + maxPeriod = options.maxPeriod; + } + if(maxPeriod - minPeriod < 2){ + minPeriod = Math.floor(minPeriod - 1); + maxPeriod = Math.ceil(maxPeriod + 1); + } + for(var i = minPeriod; i <= maxPeriod; i++){ + this.periods.push(i); + } + } else { + this.periods = options.periods; + } + + if(!options.input){ + var self = this; + getLiveInput(function(err,input){ + if(err){ + console.error('getUserMedia error:',err); + } else { + self.input = input; + self.start(); + } + }); + } else { + this.input = options.input; + } + + if(options.destroy){ + this.destroyCallback = options.destroy; + } + if(options.output){ + this.output = options.output; + } + + this.correlations = new Array(this.MAX_SAMPLES); + this.update = this.update.bind(this); + this.started = false; + this.frequency = -1; + + if(options.start){ + this.start(); + } +} + +PitchDetector.defaultCallback = function(frequency){ + console.log('Detected frequency:',frequency,this.getPeriod(),this.getNoteNumber(),this.getNoteString()); +}; + +PitchDetector.prototype.start = function(){ + if(!this.analyser && this.input){ + this.analyser = this.context.createAnalyser(); + this.analyser.fftSize = this.buffer.length * 2; + this.input.connect(this.analyser); + if(this.output){ + this.analyser.connect(this.output); + } + } + this.started = true; + requestAnimationFrame(this.update); +}; + +PitchDetector.prototype.update = function(){ + var value = -1; + if(this.analyser) { + this.analyser.getFloatTimeDomainData(this.buffer); + value = this.autoCorrelate(); + if(value > -1){ + this.frequency = value; + if(this.stopAfterDetection === true){ + this.started = false; + } + } + } + if(this.callback){ + this.callback(value,this); + } + if(this.started === true){ + requestAnimationFrame(this.update); + } + return value; +}; + +PitchDetector.prototype.stop = function(){ + this.started = false; +}; + +// Free op resources +// +// Note: It's not tested if it actually frees up resources +PitchDetector.prototype.destroy = function(){ + this.stop(); + if(this.destroyCallback){ + this.destroyCallback(); + } + if(this.input && this.input.stop){ + try { + this.input.stop(0); + } catch(e){} + } + if(this.input) this.input.disconnect(); + this.input = null; + this.analyser = null; + this.context = null; + this.buffer = null; + this.correlations = null; +}; + +/** + * Sync methoc to retrieve latest pitch in various forms: + */ + +PitchDetector.prototype.getFrequency = function(){ + return this.frequency; +}; + +PitchDetector.prototype.getNoteNumber = function(){ + return frequencyToNote(this.frequency); +}; + +PitchDetector.prototype.getNoteString = function(){ + return frequencyToString(this.frequency); +}; + +PitchDetector.prototype.getPeriod = function(){ + return this.period; +}; + +PitchDetector.prototype.getCorrelation = function(){ + return this.correlation || 0; +}; + + +PitchDetector.prototype.getDetune = function(){ + return centsOffFromPitch(this.frequency,frequencyToNote(this.frequency)); +}; + +/** + * AutoCorrelate algorithm + */ +PitchDetector.prototype.autoCorrelate = function AutoCorrelate(){ + var best_offset = -1; + var best_correlation = 0; + var last_correlation = 1; + var rms = 0; + var i = 0; + var j = 0; + var found_correlation = false; + var BUFFER_LENGTH = this.buffer.length; + var PERIOD_LENGTH = this.periods.length; + var MAX_SAMPLES = this.MAX_SAMPLES; + + // Check if there is enough signal + for (i=0; i< BUFFER_LENGTH;i++) { + rms += this.buffer[i]*this.buffer[i]; + } + rms = Math.sqrt(rms/ BUFFER_LENGTH); + this.rms = rms; + + if (rms< this.minRms) // not enough signal + return -1; + + + /** + * Test different periods (i.e. frequencies) + * + * Buffer: |----------------------------------------| (1024) + * i: | 1 44.1 kHz + * || 2 22.05 kHz + * |-| 3 14.7 kHz + * |--| 4 11 kHz + * ... + * |-------------------| 512 86hz + * + * + * frequency = sampleRate / period + * period = sampleRate / frequency + * + * + */ + for (i=0; i < PERIOD_LENGTH; i++) { + var period = this.periods[i]; + var correlation = 0; + + /** + * + * Calculate sum-of-differences + * + * Buffer: |-------------------|--------------------| (1024) + * j: + * |---| 0 + * |---| 1 + * |---| 2 + * ... + * |---| 512 + * + * sum-of-differences + */ + for (j=0; j < MAX_SAMPLES; j++) { + correlation += Math.abs((this.buffer[j])-(this.buffer[j+period])); + } + + // average-difference = sum-of-differences / MAX_SAMPLES + // correlation = 1 - average-difference + correlation = 1 - (correlation/MAX_SAMPLES); + + this.correlations[period] = correlation; // store it, for the tweaking we need to do below. + + + // early stop-condition if we have a strong signal + if(i > 1 && correlation > best_correlation){ + best_correlation = correlation; + best_period = period; + if(correlation > this.minCorrelation){ + found_correlation = true; + } + } else if (found_correlation){ + // short-circuit - we found a good correlation, then a bad one, so we'd just be seeing copies from here. + // (because auto-correlate also finds lower octaves, they have a period of 2 * best_period) + // + // Now we need to tweak the period - by interpolating between the values to the left and right of the + // best period, and shifting it a bit. This is complex, and HACKY in this code (happy to take PRs!) - + // we need to do a curve fit on this.correlations[] around best_period in order to better determine precise + // (anti-aliased) period. + + // we know best_period >=1, + // since found_correlation cannot go to true until the second pass (period=1), and + // we can't drop into this clause until the following pass (else if). + var shift = (this.correlations[best_period+1] - this.correlations[best_period-1]) /this.correlations[best_period]; + this.period = best_period; + this.correlation = best_correlation; + return this.sampleRate/(best_period+(8*shift)); + } + last_correlation = correlation; + } + + // worst-case scenario + if (best_correlation > 0.01) { + // console.log("f = " + this.sampleRate/best_period + "Hz (rms: " + rms + " confidence: " + best_correlation + ")") + this.period = best_period; + this.correlation = best_correlation; + return this.sampleRate/best_period; + } + return -1; +}; + +// Export on Window or as CommonJS module +if(typeof module !== 'undefined') { + module.exports = PitchDetector; +} else { + window.PitchDetector = PitchDetector; +} +})(); \ No newline at end of file From 54562ff10c863cbca64b0a6e5db995c021ce5ae7 Mon Sep 17 00:00:00 2001 From: Mark Marijnissen Date: Tue, 24 Feb 2015 17:53:45 +0100 Subject: [PATCH 02/15] remove red vertical lines --- example/gui.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/example/gui.js b/example/gui.js index b5d1126..bba94e9 100644 --- a/example/gui.js +++ b/example/gui.js @@ -187,19 +187,19 @@ function draw( pitch ) { canvas.stroke(); - canvas.strokeStyle = "red"; - canvas.beginPath(); - canvas.moveTo(0,0); - canvas.lineTo(0,256); - canvas.moveTo(128,0); - canvas.lineTo(128,256); - canvas.moveTo(256,0); - canvas.lineTo(256,256); - canvas.moveTo(384,0); - canvas.lineTo(384,256); - canvas.moveTo(512,0); - canvas.lineTo(512,256); - canvas.stroke(); + // canvas.strokeStyle = "red"; + // canvas.beginPath(); + // canvas.moveTo(0,0); + // canvas.lineTo(0,256); + // canvas.moveTo(128,0); + // canvas.lineTo(128,256); + // canvas.moveTo(256,0); + // canvas.lineTo(256,256); + // canvas.moveTo(384,0); + // canvas.lineTo(384,256); + // canvas.moveTo(512,0); + // canvas.lineTo(512,256); + // canvas.stroke(); // canvas.strokeStyle = "red"; // canvas.beginPath(); From 05f655bf71af754d041b7d48ace96611efc4b923 Mon Sep 17 00:00:00 2001 From: Mark Marijnissen Date: Tue, 24 Feb 2015 17:57:20 +0100 Subject: [PATCH 03/15] add package.json for NPM registration --- package.json | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 package.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..f4feb49 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "pitch-detector", + "version": "0.1.0", + "description": "Detect pitch with the auto-correlation algorithm using the Web Audio API", + "main": "pitchdetector.js", + "directories": { + "example": "example" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://github.com/markmarijnissen/PitchDetect.git" + }, + "keywords": [ + "pitch", + "web", + "audio", + "auto-correlation" + ], + "authors": ["Chris Wilson","Mark Marijnissen"], + "license": "MIT", + "bugs": { + "url": "https://github.com/markmarijnissen/PitchDetect/issues" + }, + "homepage": "https://github.com/markmarijnissen/PitchDetect" +} From d3b8405ace66b42589d589969562ea64c8471a14 Mon Sep 17 00:00:00 2001 From: Mark Marijnissen Date: Tue, 24 Feb 2015 18:01:19 +0100 Subject: [PATCH 04/15] add online demo url to readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 342caa5..c313f13 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ I've extracted the core logic into a standalone module. The GUI is now seperate (see `/example/gui.js`). I've also enhanced the display to visualize the detection algorithm. +See demo at http://lab.madebymark.nl/pitch-detector/example/. + - Mark ## Usage From 990618b96738f6a39325efcbe2c7c0e75acf4855 Mon Sep 17 00:00:00 2001 From: Mark Marijnissen Date: Wed, 25 Feb 2015 17:16:17 +0100 Subject: [PATCH 05/15] update options and algorithms --- example/gui.js | 402 +++++++++++++++++++++++++++------------------ example/index.html | 131 +++++++++++++-- pitchdetector.js | 360 +++++++++++++++++++++++++++------------- 3 files changed, 609 insertions(+), 284 deletions(-) diff --git a/example/gui.js b/example/gui.js index bba94e9..c1a48ed 100644 --- a/example/gui.js +++ b/example/gui.js @@ -24,44 +24,83 @@ SOFTWARE. window.AudioContext = window.AudioContext || window.webkitAudioContext; -var audioContext = null; -var pitchDetector = null; +$(function(){ + // Global Variables + var audioContext = new AudioContext(); + var osc = null; + var options = { }; + var needsReset = true; + var pitchDetector = null; + var theBuffer = null; -var theBuffer = null; + // Form Input Elements + var inputs = { + input: $('#input'), + notes: $('#notes'), + output: $('#output'), + minRms: $('#minrms'), + normalize: $('#normalize'), + detection: $('#detection'), + minCorrelationIncrease: $('#strength'), + minCorrelation: $('#correlation'), + range: $('#range'), + min: $('#min'), + max: $('#max'), + draw: $('#draw'), + stopAfterDetection: $('#stopAfterDetection') + }; -var DEBUGCANVAS = null; -var detectorElem, - canvas, - pitchElem, - noteElem, - detuneElem, - detuneAmount; + // GUI Elements + var gui = { + detector: $('#detector'), + canvas: $('#waveform'), + pitch: $('#pitch'), + note: $('#note'), + detuneBox: $('#detune'), + detune: $('#detune_amt') + }; -window.onload = function() { - audioContext = new AudioContext(); + // Canvas Element + canvasEl = $("#waveform").get(0); + canvas = canvasEl.getContext("2d"); - var request = new XMLHttpRequest(); - request.open("GET", "./whistling3.ogg", true); - request.responseType = "arraybuffer"; - request.onload = function() { - audioContext.decodeAudioData( request.response, function(buffer) { - theBuffer = buffer; - } ); - }; - request.send(); + // Show/Hide Stuff on Form Change + inputs.input.change(function(e){ + needsReset = true; + var val = inputs.input.val(); + if(val === 'mic') { + $('#notes').removeClass('invisible'); + } else { + $('#notes').addClass('invisible'); + } + }); - detectorElem = document.getElementById( "detector" ); - DEBUGCANVAS = document.getElementById( "waveform" ); - if (DEBUGCANVAS) { - canvas = DEBUGCANVAS.getContext("2d"); - canvas.strokeStyle = "black"; - canvas.lineWidth = 1; - } - pitchElem = document.getElementById( "pitch" ); - noteElem = document.getElementById( "note" ); - detuneElem = document.getElementById( "detune" ); - detuneAmount = document.getElementById( "detune_amt" ); + inputs.output.change(function(e){ + needsReset = true; + }); + + inputs.range.change(function(e){ + var val = inputs.range.val(); + if(val !== 'none') { + $('.range').removeClass('hidden'); + } else { + $('.range').addClass('hidden'); + } + }); + + inputs.detection.change(function(e){ + var val = inputs.detection.val(); + $('.strength').addClass('hidden'); + $('.correlation').addClass('hidden'); + if(val === 'strength') { + $('.strength').removeClass('hidden'); + } else if(val === 'correlation') { + $('.correlation').removeClass('hidden'); + } + }); + // Drag & Drop audio files + var detectorElem = gui.detector.get(0); detectorElem.ondragenter = function () { this.classList.add("droptarget"); return false; }; @@ -84,153 +123,198 @@ window.onload = function() { reader.readAsArrayBuffer(e.dataTransfer.files[0]); return false; }; -}; - -function toggleOscillator() { - if(pitchDetector) pitchDetector.destroy(); - sourceNode = audioContext.createOscillator(); - sourceNode.frequency = 440; - sourceNode.start(0); - pitchDetector = new PitchDetector({ - context: audioContext, - callback: draw, - input: sourceNode, - maxFrequency: 500, - minFrequency: 300, - //minNote: 60, - //maxNote: 80, - //note: 69, - //output: audioContext.destination, - start: true - }); -} - -function toggleLiveInput() { - if(pitchDetector) pitchDetector.destroy(); - pitchDetector = new PitchDetector({ - context: audioContext, - callback: draw, - maxNote: 100, - minNote: 50, - minRms: 0.1, - // default input node is microphone - start: true - }); -} - -function togglePlayback() { - if(pitchDetector) pitchDetector.destroy(); - - var sourceNode = audioContext.createBufferSource(); - sourceNode.buffer = theBuffer; - sourceNode.loop = true; - sourceNode.start(0); - - pitchDetector = new PitchDetector({ - context: audioContext, - callback: draw, - input: sourceNode, - maxNote: 100, - minNote: 60, - output: audioContext.destination, - start: true - }); -} - -function stop(){ - if(pitchDetector) pitchDetector.destroy(); - pitchDetector = null; -} - -function draw( pitch ) { - if(!pitchDetector || !pitchDetector.buffer) return; - var buf = pitchDetector.buffer; - var i = 0, val = 0, len = 0; - - if (DEBUGCANVAS) { // This draws the current waveform, useful for debugging - var start = pitchDetector.periods[0]; + + // Get example audio file + var request = new XMLHttpRequest(); + request.open("GET", "./whistling3.ogg", true); + request.responseType = "arraybuffer"; + request.onload = function() { + audioContext.decodeAudioData( request.response, function(buffer) { + theBuffer = buffer; + console.log('loaded audio'); + } ); + }; + request.send(); + + // Global Methods + window.stopNote = function stopNote(){ + if(osc) { + osc.stop(); + osc.disconnect(); + osc = null; + } + }; + + window.playNote = function playNote(freq){ + stopNote(); + osc = audioContext.createOscillator(); + osc.connect(audioContext.destination); + osc.frequency.value = freq; + osc.start(0); + }; + + window.stop = function stop(){ + if(pitchDetector) pitchDetector.destroy(); + pitchDetector = null; + }; + + window.start = function start(){ + if(needsReset && pitchDetector) { + pitchDetector.destroy(); + pitchDetector = null; + } + + var input = inputs.input.val(); + var sourceNode; + if(input === 'osc'){ + sourceNode = audioContext.createOscillator(); + sourceNode.frequency.value = 440; + sourceNode.start(); + } else if(input === 'audio'){ + sourceNode = audioContext.createBufferSource(); + sourceNode.buffer = theBuffer; + sourceNode.loop = true; + sourceNode.start(0); + } else { + inputs.output.prop('checked', false); + } + options.input = sourceNode; + + if(inputs.output.is(':checked')){ + options.output = audioContext.destination; + } + + options.minRms = 1.0 * inputs.minRms.val() || 0.01; + var normalize = inputs.normalize.val(); + if(normalize !== 'none'){ + options.normalize = normalize; + } + + options.stopAfterDetection = inputs.stopAfterDetection.is(':checked'); + + var detection = inputs.detection.val(); + if(detection === 'correlation'){ + options.minCorrelationIncrease = false; + options.minCorrelation = inputs.minCorrelation.val() * 1.0; + } else if(detection === 'strength') { + options.minCorrelation = false; + options.minCorrelationIncrease = inputs.minCorrelationIncrease.val() * 1.0; + } + + var range = inputs.range.val();// Frequency, Period, Note + if(range !== 'none'){ + options['min'+range] = inputs.min.val() * 1.0; + options['max'+range] = inputs.max.val() * 1.0; + } + + options.onDebug = false; + options.onDetect = false; + options[inputs.draw.val()] = draw; + + options.context = audioContext; + if(needsReset || !pitchDetector){ + pitchDetector = new PitchDetector(options); + needsReset = false; + } else { + delete options.context; + pitchDetector.setOptions(options); + } + delete options.context; + $('#settings').text(JSON.stringify(options,null,4)); + window.pitchDetector = pitchDetector; + }; + + function draw( stats ) { + if(!pitchDetector || !pitchDetector.buffer) return; + var buf = pitchDetector.buffer; + var i = 0, val = 0, len = 0; + var start = pitchDetector.periods[0]; var end = pitchDetector.periods[pitchDetector.periods.length-1]; + var width = end-start; canvas.clearRect(0,0,512,256); - canvas.fillStyle = "yellow"; - canvas.fillRect(start,0,end-start,(1-pitchDetector.minCorrelation) * 256); - + // AREA: Draw Pitch Detection Area + if(pitchDetector.options.minCorrelation){ + canvas.fillStyle = "yellow"; + canvas.fillRect(start,0,width,(1-pitchDetector.options.minCorrelation) * 256); + } else if(pitchDetector.options.minCorrelationIncrease) { + canvas.fillStyle = "#EEEEFF"; + canvas.fillRect(0,0,512,(1-pitchDetector.options.minCorrelationIncrease) * 256); + } + + // AREA: Draw RMS canvas.fillStyle = "#EEEEEE"; - var height = pitchDetector.rms * 256; - canvas.fillRect(0,256-height,512,height); + val = stats.rms * 256; + canvas.fillRect(0,256-val,512,val); - canvas.strokeStyle = "black"; + // AREA: Draw Correlations canvas.beginPath(); - canvas.moveTo(0,256 - pitchDetector.minRms * 256); - canvas.lineTo(512,256 - pitchDetector.minRms * 256); + canvas.strokeStyle = "black"; + if(pitchDetector.options.minCorrelation || pitchDetector.options.minCorrelationIncrease){ + len = stats.best_period + 1; + } else { + len = pitchDetector.correlations.length; + } + for(i = 0; i +
- - - - +

Pitch Detector

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
input: + + + - Test Tones: + + + + + +
output: + +
minRms: + Minimal signal strength (Black Line) +
normalize: + +
Pitch Detection: + + + +
Pitch Detection Range: + + +
Visualize: + +
stopAfterDetection + +
+
+ + +
+

@@ -50,18 +159,12 @@

-Y-Axis: Correlation Score. -
-X-Axis: Signal Period, 2-512 samples (22.05 kHz - 83 Hz, F10 - E2) -
-Green lines: Range of pitch detection. -
-Yellow Area: Detection Area (minimum correlation required for detection) -
-Gray Area: Signal Strength (RMS) +Y-Axis: Auto-Correlation Score.
-Black line: Minimal Signal Strength +X-Axis: Signal period. 2-512 samples (22.05 kHz - 83 Hz, F10 - E2)

+

Pitch Detector Settings:

+

 
diff --git a/pitchdetector.js b/pitchdetector.js index d71b863..f7e7c67 100644 --- a/pitchdetector.js +++ b/pitchdetector.js @@ -48,7 +48,7 @@ function centsOffFromPitch( frequency, note ) { return Math.floor( 1200 * Math.log( frequency / noteToFrequency( note ))/Math.log(2) ); } -function getLiveInput(callback){ +function getLiveInput(context,callback){ try { navigator.getUserMedia( { @@ -62,12 +62,14 @@ function getLiveInput(callback){ "optional": [] }, }, function(stream){ - var input = audioContext.createMediaStreamSource(stream); - callback(null,input); + var liveInputNode = context.createMediaStreamSource(stream); + callback(null,liveInputNode); }, function(error){ + console.error('getUserMedia error',error); callback(error,null); }); } catch(e) { + console.error('getUserMedia exception',e); callback(e,null); } } @@ -77,89 +79,144 @@ var requestAnimationFrame = window.requestAnimationFrame || window.webkitRequest navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; function PitchDetector(options){ - options = options || {}; - this.context = options.context; - this.sampleRate = this.context.sampleRate; - this.callback = options.callback || PitchDetector.defaultCallback.bind(this); - this.minCorrelation = options.minCorrelation || 0.9; - this.minRms = options.minRms || 0.01; - this.stopAfterDetection = options.stopAfterDetection || false; + // Options: + this.options = { + minRms: 0.01, + stopAfterDetection: false, + normalize: false, + minCorrelation: false, + minCorrelationIncrease: false + }; + + // Internal Variables + this.context = options.context; // AudioContext + this.sampleRate = this.context.sampleRate; // sampleRate + this.buffer = new Float32Array( options.length || 1024 ); // buffer array + this.MAX_SAMPLES = Math.floor(this.buffer.length/2); // MAX_SAMPLES number + this.correlations = new Array(this.MAX_SAMPLES); // correlation array + this.update = this.update.bind(this); // update function (bound to this) + this.started = false; // state flag (to cancel requestAnimationFrame) + this.input = null; // Audio Input Node + this.output = null; // Audio Output Node + + // Stats: + this.stats = { + detected: true, + frequency: -1, + best_period: 0, + worst_period: 0, + best_correlation: 0.0, + worst_correlation: 0.0, + rms: 0.0, + }; + + // Set input + if(!options.input){ + var self = this; + getLiveInput(this.context,function(err,input){ + if(err){ + console.error('getUserMedia error:',err); + } else { + self.input = input; + self.start(); + } + }); + } else { + this.input = options.input; + } + + // Set output + if(options.output){ + this.output = options.output; + } + + // Set options + options.input = undefined; // 'input' option only allowed in constructor + options.output = undefined; // 'output' option only allowed in constructor + options.context = undefined; // 'context' option only allowed in constructor + options.length = undefined; // 'length' option only allowed in constructor + this.setOptions(options); +} + +PitchDetector.prototype.setOptions = function(options){ + var self = this; - this.buffer = new Float32Array( options.length || 1024 ); - this.MAX_SAMPLES = Math.floor(this.buffer.length/2); + // Override options (if defined) + ['minCorrelation','minCorrelationIncrease','minRms', + 'normalize', + 'onDebug','onDetect','onDestroy' + ].forEach(function(option){ + if(typeof options[option] !== 'undefined') { + self.options[option] = options[option]; + } + }); + + // Warn if you're setting Constructor-only options! + ['input','output','length','context'].forEach(function(option){ + if(typeof options[option] !== 'undefined'){ + console.warn('PitchDetector: Cannot set option "'+option+'"" after construction!'); + } + }); + // Set frequency domain (i.e. min-max period to detect frequencies on) + var minPeriod = options.minPeriod || this.options.minPeriod || 2; + var maxPeriod = options.maxPeriod || this.options.maxPeriod || this.MAX_SAMPLES; if(options.note){ var period = Math.round(noteToPeriod(options.note,this.sampleRate)); - options.minPeriod = period - 1; - options.maxPeriod = period + 1; + minPeriod = period - 1; + maxPeriod = period + 1; } if(options.minNote){ - options.maxPeriod = Math.round(noteToPeriod(options.minNote,this.sampleRate)); + maxPeriod = Math.round(noteToPeriod(options.minNote,this.sampleRate)); } if(options.maxNote){ - options.minPeriod = Math.round(noteToPeriod(options.maxNote,this.sampleRate)); + minPeriod = Math.round(noteToPeriod(options.maxNote,this.sampleRate)); } if(options.minFrequency) { - options.maxPeriod = Math.floor(this.sampleRate / options.minFrequency); + maxPeriod = Math.floor(this.sampleRate / options.minFrequency); } if(options.maxFrequency) { - options.minPeriod = Math.ceil(this.sampleRate / options.maxFrequency); + minPeriod = Math.ceil(this.sampleRate / options.maxFrequency); } - if(!options.periods){ + if(options.periods){ + this.periods = options.periods; + } else { this.periods = []; - var minPeriod = options.minPeriod || 2; - var maxPeriod = this.MAX_SAMPLES; - if(options.maxPeriod && options.maxPeriod < maxPeriod){ - maxPeriod = options.maxPeriod; - } + maxPeriod = Math.min(maxPeriod,this.MAX_SAMPLES); + minPeriod = Math.max(2,minPeriod); if(maxPeriod - minPeriod < 2){ minPeriod = Math.floor(minPeriod - 1); maxPeriod = Math.ceil(maxPeriod + 1); } + this.options.minPeriod = minPeriod; + this.options.maxPeriod = maxPeriod; for(var i = minPeriod; i <= maxPeriod; i++){ this.periods.push(i); } - } else { - this.periods = options.periods; } - if(!options.input){ - var self = this; - getLiveInput(function(err,input){ - if(err){ - console.error('getUserMedia error:',err); - } else { - self.input = input; - self.start(); - } - }); - } else { - this.input = options.input; + // keep track of stats for visualization + if(options.onDebug){ + this.debug = { + detected: false, + frequency: -1, + best_period: 0, + worst_period: 0, + best_correlation: 0.0, + worst_correlation: 0.0, + rms: 0.0, + }; } - if(options.destroy){ - this.destroyCallback = options.destroy; - } - if(options.output){ - this.output = options.output; - } - - this.correlations = new Array(this.MAX_SAMPLES); - this.update = this.update.bind(this); - this.started = false; - this.frequency = -1; - + // Autostart if(options.start){ this.start(); } -} - -PitchDetector.defaultCallback = function(frequency){ - console.log('Detected frequency:',frequency,this.getPeriod(),this.getNoteNumber(),this.getNoteString()); }; PitchDetector.prototype.start = function(){ + // Wait until input is defined (when waiting for microphone) if(!this.analyser && this.input){ this.analyser = this.context.createAnalyser(); this.analyser.fftSize = this.buffer.length * 2; @@ -168,29 +225,31 @@ PitchDetector.prototype.start = function(){ this.analyser.connect(this.output); } } - this.started = true; - requestAnimationFrame(this.update); + if(!this.started){ + this.started = true; + requestAnimationFrame(this.update); + } }; PitchDetector.prototype.update = function(){ - var value = -1; if(this.analyser) { this.analyser.getFloatTimeDomainData(this.buffer); - value = this.autoCorrelate(); - if(value > -1){ - this.frequency = value; - if(this.stopAfterDetection === true){ + var detectedPitch = this.autoCorrelate(); + if(detectedPitch){ + if(this.options.stopAfterDetection === true){ this.started = false; } + if(this.options.onDetect){ + this.options.onDetect(this.stats,this); + } } } - if(this.callback){ - this.callback(value,this); + if(this.options.onDebug){ + this.options.onDebug(this.debug,this); } if(this.started === true){ requestAnimationFrame(this.update); } - return value; }; PitchDetector.prototype.stop = function(){ @@ -202,8 +261,8 @@ PitchDetector.prototype.stop = function(){ // Note: It's not tested if it actually frees up resources PitchDetector.prototype.destroy = function(){ this.stop(); - if(this.destroyCallback){ - this.destroyCallback(); + if(this.options.onDestroy){ + this.options.onDestroy(); } if(this.input && this.input.stop){ try { @@ -223,41 +282,65 @@ PitchDetector.prototype.destroy = function(){ */ PitchDetector.prototype.getFrequency = function(){ - return this.frequency; + return this.stats.frequency; }; PitchDetector.prototype.getNoteNumber = function(){ - return frequencyToNote(this.frequency); + return frequencyToNote(this.stats.frequency); }; PitchDetector.prototype.getNoteString = function(){ - return frequencyToString(this.frequency); + return frequencyToString(this.stats.frequency); }; PitchDetector.prototype.getPeriod = function(){ - return this.period; + return this.stats.best_period; }; PitchDetector.prototype.getCorrelation = function(){ - return this.correlation || 0; + return this.stats.best_correlation; }; +PitchDetector.prototype.getCorrelationIncrease = function(){ + return this.stats.best_correlation - this.stats.worst_correlation; +}; PitchDetector.prototype.getDetune = function(){ - return centsOffFromPitch(this.frequency,frequencyToNote(this.frequency)); + return centsOffFromPitch(this.stats.frequency,frequencyToNote(this.stats.frequency)); }; /** * AutoCorrelate algorithm */ PitchDetector.prototype.autoCorrelate = function AutoCorrelate(){ - var best_offset = -1; + // Keep track of best period/correlation + var best_period = 0; var best_correlation = 0; + + // Keep track of local minima (i.e. nearby low correlation) + var worst_period = 0; + var worst_correlation = 1; + + // Remember previous correlation to determine if + // we're ascending (i.e. getting near a frequency in the signal) + // or descending (i.e. moving away from a frequency in the signal) var last_correlation = 1; + + // iterators + var i = 0; // for the different periods we're checking + var j = 0; // for the different "windows" we're checking + var period = 0; // current period we're checking. + + // calculated stuff var rms = 0; - var i = 0; - var j = 0; - var found_correlation = false; + var correlation = 0; + var peak = 0; + + // early stop algorithm + var found_pitch = !this.options.minCorrelationIncrease && !this.options.minCorrelation; + + // Constants + var NORMALIZE = 1; var BUFFER_LENGTH = this.buffer.length; var PERIOD_LENGTH = this.periods.length; var MAX_SAMPLES = this.MAX_SAMPLES; @@ -265,13 +348,22 @@ PitchDetector.prototype.autoCorrelate = function AutoCorrelate(){ // Check if there is enough signal for (i=0; i< BUFFER_LENGTH;i++) { rms += this.buffer[i]*this.buffer[i]; + // determine peak volume + if(this.buffer[i] > peak) peak = this.buffer[i]; } rms = Math.sqrt(rms/ BUFFER_LENGTH); - this.rms = rms; - if (rms< this.minRms) // not enough signal - return -1; + // Abort if not enough signal + if (rms< this.options.minRms) { + return false; + } + // Normalize (if configured) + if(this.options.normalize === 'rms') { + NORMALIZE = 2*rms; + } else if(this.options.normalize === 'peak') { + NORMALIZE = peak; + } /** * Test different periods (i.e. frequencies) @@ -291,12 +383,18 @@ PitchDetector.prototype.autoCorrelate = function AutoCorrelate(){ * */ for (i=0; i < PERIOD_LENGTH; i++) { - var period = this.periods[i]; - var correlation = 0; + period = this.periods[i]; + correlation = 0; /** * - * Calculate sum-of-differences + * Sum all differences + * + * Version 1: Use absolute difference + * Version 2: Use squared difference. + * + * Version 2 exagerates differences, which is a good property. + * So we'll use version 2. * * Buffer: |-------------------|--------------------| (1024) * j: @@ -309,51 +407,91 @@ PitchDetector.prototype.autoCorrelate = function AutoCorrelate(){ * sum-of-differences */ for (j=0; j < MAX_SAMPLES; j++) { - correlation += Math.abs((this.buffer[j])-(this.buffer[j+period])); + // Version 1: Absolute values + correlation += Math.abs((this.buffer[j])-(this.buffer[j+period])) / NORMALIZE; + + // Version 2: Squared values (exagarates difference, works better) + //correlation += Math.pow((this.buffer[j]-this.buffer[j+period]) / NORMALIZE,2); } - // average-difference = sum-of-differences / MAX_SAMPLES - // correlation = 1 - average-difference + // Version 1: Absolute values correlation = 1 - (correlation/MAX_SAMPLES); - - this.correlations[period] = correlation; // store it, for the tweaking we need to do below. - - // early stop-condition if we have a strong signal - if(i > 1 && correlation > best_correlation){ + // Version 2: Squared values + //correlation = 1 - Math.sqrt(correlation/MAX_SAMPLES); + + // Save Correlation + this.correlations[period] = correlation; + + // We're descending (i.e. moving towards frequencies that are NOT in here) + if(last_correlation > correlation){ + + // We already found a good correlation, so early stop! + if(this.options.minCorrelation && best_correlation > this.options.minCorrelation) { + found_pitch = true; + break; + } + + // We already found a good correlationIncrease, so early stop! + if(this.options.minCorrelationIncrease && best_correlation - worst_correlation > this.options.minCorrelationIncrease){ + found_pitch = true; + break; + } + + // Save the worst correlation of the latest descend (local minima) + worst_correlation = correlation; + worst_period = period; + + // we're ascending, and found a new high! + } else if (correlation > best_correlation){ best_correlation = correlation; best_period = period; - if(correlation > this.minCorrelation){ - found_correlation = true; - } - } else if (found_correlation){ - // short-circuit - we found a good correlation, then a bad one, so we'd just be seeing copies from here. - // (because auto-correlate also finds lower octaves, they have a period of 2 * best_period) - // + } + + last_correlation = correlation; + } + + if(this.options.onDebug){ + this.debug.detected = false; + this.debug.rms = rms; + this.debug.best_period = best_period; + this.debug.worst_period = worst_period; + this.debug.best_correlation = best_correlation; + this.debug.worst_correlation = worst_correlation; + this.debug.frequency = best_period > 0? this.sampleRate/best_period: 0; + } + + if (best_correlation > 0.01 && found_pitch) { + this.stats.best_period = best_period; + this.stats.worst_period = worst_period; + this.stats.best_correlation = best_correlation; + this.stats.worst_correlation = worst_correlation; + this.stats.rms = rms; + + // console.log("f = " + this.sampleRate/best_period + "Hz (rms: " + rms + " confidence: " + best_correlation + ")") + var shift = 0; + if(this.correlations[best_period+1] && this.correlations[best_period-1]){ // Now we need to tweak the period - by interpolating between the values to the left and right of the // best period, and shifting it a bit. This is complex, and HACKY in this code (happy to take PRs!) - // we need to do a curve fit on this.correlations[] around best_period in order to better determine precise // (anti-aliased) period. // we know best_period >=1, - // since found_correlation cannot go to true until the second pass (period=1), and + // since found_pitch cannot go to true until the second pass (period=1), and // we can't drop into this clause until the following pass (else if). - var shift = (this.correlations[best_period+1] - this.correlations[best_period-1]) /this.correlations[best_period]; - this.period = best_period; - this.correlation = best_correlation; - return this.sampleRate/(best_period+(8*shift)); + shift = (this.correlations[best_period+1] - this.correlations[best_period-1]) / best_correlation; + shift = shift * 8; } - last_correlation = correlation; - } + this.stats.frequency = this.sampleRate/(best_period + shift); - // worst-case scenario - if (best_correlation > 0.01) { - // console.log("f = " + this.sampleRate/best_period + "Hz (rms: " + rms + " confidence: " + best_correlation + ")") - this.period = best_period; - this.correlation = best_correlation; - return this.sampleRate/best_period; + if(this.options.onDebug){ + this.debug.detected = true; + this.debug.frequency = this.stats.frequency; + } + return true; + } else { + return false; } - return -1; }; // Export on Window or as CommonJS module From 4f852e409794d3d2e53a4718e17632d0854f53b8 Mon Sep 17 00:00:00 2001 From: Mark Marijnissen Date: Wed, 25 Feb 2015 18:10:26 +0100 Subject: [PATCH 06/15] few bugfixes and update readme --- README.md | 63 ++++++++++++++++++++++++++++++++++++++-------- example/gui.js | 26 +++++++++++++------ example/index.html | 21 +++++++++++++--- pitchdetector.js | 27 ++++++++++++++------ 4 files changed, 110 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c313f13..6b19ecf 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,36 @@ var detector = new PitchDetector({ output: AudioNode, // default: no output // Callback on pitch detection (Optional) - callback: function(frequency, pitchDetector) { }, + // You can also query the results using public methods. + onDetect: function(stats, pitchDetector) { + stats.frequency + stats.detected + stats.worst_correlation // worst correlation BEFORE the best correlation (local minimum, not global minimum!) + stats.best_correlation + stats.worst_period + stats.best_period + stats.time // audioContext currentTime of detection + stats.rms + }, + + // Debug Callback for visualisation + onDebug: function(stats, pitchDetector) { }, // Minimal signal strength (RMS, Optional) minRms: 0.01, - // Minimal Correlation for early detection (Optional) + // Detect pitch only with minimal correlation of: minCorrelation: 0.9, + // Detect pitch only if correlation increases with at least: + minCorreationIncrease: 0.5, + + // Note: you cannot use minCorrelation and minCorreationIncrease + // at the same time! + + // Signal Normalization + normalize: "rms" // or "peak". default: undefined + // Only detect pitch once: stopAfterDetection: false @@ -73,7 +95,7 @@ detector.stop() detector.destroy() ``` -You can also query the latest data: +You can also query the latest detected pitch: ```javascript detector.getFrequency() // --> 440hz detector.getNoteNumber() // --> 69 @@ -81,12 +103,33 @@ detector.getNoteString() // --> "A4" detector.getPeriod() // --> 100 detector.getDetune() // --> 0 detector.getCorrelation() // --> 0.95 -``` - -Note that the callback gives you a reference to the pitchDetector, so you can do: -```javascript -var callback = function(frequency,detector) { - detector.getDetune(); - // etc +detector.getCorrelationIncrease() // --> 0.95 + +// or raw data +detector.stats = { + stats.frequency + stats.detected + stats.worst_correlation + stats.best_correlation + stats.worst_period + stats.best_period + stats.rms } ``` + +## Tips & Tricks + +### Always use an optimization + +* `minCorrelation` is the most reliable +* `minCorreationIncrease` can sometimes give better results. + +### Use `RMS` or `Peak` normalization with `minCorrelationIncrease` + +The increase in correlation strongly depends on signal volume. Therefore, normalizing using `RMS` or `Peak` can make `minCorrelationIncrease` work much better. + +### Set a frequency range + +If you know what you're looking or, set a frequency range. + +**Warning:** `minCorrelationIncrease` needs a bigger frequency range, because needs it detects target frequency when higher frequencies have a very **low correlation**! (Therefore the correlation increase from "bad frequency" to "target frequency" is high). \ No newline at end of file diff --git a/example/gui.js b/example/gui.js index c1a48ed..7dd084a 100644 --- a/example/gui.js +++ b/example/gui.js @@ -28,7 +28,7 @@ $(function(){ // Global Variables var audioContext = new AudioContext(); var osc = null; - var options = { }; + var options = { start: true }; var needsReset = true; var pitchDetector = null; var theBuffer = null; @@ -38,6 +38,7 @@ $(function(){ input: $('#input'), notes: $('#notes'), output: $('#output'), + length: $('#length'), minRms: $('#minrms'), normalize: $('#normalize'), detection: $('#detection'), @@ -79,6 +80,10 @@ $(function(){ needsReset = true; }); + inputs.length.change(function(e){ + needsReset = true; + }); + inputs.range.change(function(e){ var val = inputs.range.val(); if(val !== 'none') { @@ -169,7 +174,7 @@ $(function(){ if(input === 'osc'){ sourceNode = audioContext.createOscillator(); sourceNode.frequency.value = 440; - sourceNode.start(); + sourceNode.start(0); } else if(input === 'audio'){ sourceNode = audioContext.createBufferSource(); sourceNode.buffer = theBuffer; @@ -184,6 +189,8 @@ $(function(){ options.output = audioContext.destination; } + options.length = inputs.length.val() * 1; + options.minRms = 1.0 * inputs.minRms.val() || 0.01; var normalize = inputs.normalize.val(); if(normalize !== 'none'){ @@ -213,13 +220,18 @@ $(function(){ options.context = audioContext; if(needsReset || !pitchDetector){ + console.log('created PitchDetector',options); pitchDetector = new PitchDetector(options); needsReset = false; } else { delete options.context; + delete options.output; + delete options.input; pitchDetector.setOptions(options); } delete options.context; + delete options.output; + delete options.input; $('#settings').text(JSON.stringify(options,null,4)); window.pitchDetector = pitchDetector; }; @@ -227,12 +239,12 @@ $(function(){ function draw( stats ) { if(!pitchDetector || !pitchDetector.buffer) return; var buf = pitchDetector.buffer; - var i = 0, val = 0, len = 0; + var i = 0, val = 0, len = 0, bufferlen = pitchDetector.MAX_SAMPLES; var start = pitchDetector.periods[0]; var end = pitchDetector.periods[pitchDetector.periods.length-1]; var width = end-start; - canvas.clearRect(0,0,512,256); + canvas.clearRect(0,0,bufferlen,256); // AREA: Draw Pitch Detection Area if(pitchDetector.options.minCorrelation){ @@ -240,13 +252,13 @@ $(function(){ canvas.fillRect(start,0,width,(1-pitchDetector.options.minCorrelation) * 256); } else if(pitchDetector.options.minCorrelationIncrease) { canvas.fillStyle = "#EEEEFF"; - canvas.fillRect(0,0,512,(1-pitchDetector.options.minCorrelationIncrease) * 256); + canvas.fillRect(0,0,bufferlen,(1-pitchDetector.options.minCorrelationIncrease) * 256); } // AREA: Draw RMS canvas.fillStyle = "#EEEEEE"; val = stats.rms * 256; - canvas.fillRect(0,256-val,512,val); + canvas.fillRect(0,256-val,bufferlen,val); // AREA: Draw Correlations canvas.beginPath(); @@ -287,7 +299,7 @@ $(function(){ canvas.beginPath(); val = 256 - (stats.best_correlation - stats.worst_correlation) * 256; canvas.moveTo(0,val); - canvas.lineTo(512,val); + canvas.lineTo(bufferlen,val); canvas.stroke(); } diff --git a/example/index.html b/example/index.html index 1ca84a5..e356ed7 100644 --- a/example/index.html +++ b/example/index.html @@ -69,6 +69,21 @@

Pitch Detector

+ + length: + + + Audio Buffer Length + + minRms: @@ -89,9 +104,9 @@

Pitch Detector

Pitch Detection: