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..d281e32 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,190 @@ -# 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. +Based on Chris Wilson's work, an improved pitch detector. The Pitch Detector calculates auto-correlation score for a range of frequencies. -Live instance hosted on https://webaudiodemos.appspot.com/pitchdetect/. +See demo at http://lab.madebymark.nl/pitch-detector/example/. -Check it out, feel free to fork, submit pull requests, etc. MIT-Licensed - party on. +## Examples --Chris +* The **Y-Axis** is the auto-correlation score. +* The **X-Axis** is the frequency range, from high (22 +Khz) to low (83 Hz). +* The **Red bar** is the signal strength (RMS). The little dark red line is the minimal RMS. + +Detect best auto-correlation of all frequencies: + +![Auto-Correlation scores](example/example1.png) + +Detect the first peak auto-correlation, which is the highest frequency. Auto-correlation often detects lower octaves (and harmonies), so we can just stop after the first peak. + +![Auto-Correlation scores, detect the first peak correlation](example/example2.png) + +Detect a sudden increase in correlation: + +![Auto-Correlation scores, detect the first increase in correlation](example/example3.png) + +## Usage + +* `pitchdetector.js` contains the PitchDetector (logic only) +* `pitchdetectorcanvas.js` allows you to visualize pitch detection on a canvas. +* `example/gui.js` is a playground to test and tweak the pitch detector. + +Drop `pitchdetector.js` in your page, or require the CommonJS module using Webpack or Browserify. + +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 + + // interpolate frequency (Optional) + // + // Auto-correlation is calculated for different (discrete) signal periods + // The true frequency is often in-beween two periods. + // + // We can interpolate (very hacky) by looking at neighbours of the best + // auto-correlation period and shifting the frequency a bit towards the + // highest neighbour. + interpolateFrequency: true, // default: true + + // Callback on pitch detection (Optional) + onDetect: function(stats, pitchDetector) { + stats.frequency // 440 + stats.detected // --> true + stats.worst_correlation // 0.03 - local minimum, not global minimum! + stats.best_correlation // 0.98 + stats.worst_period // 80 + stats.best_period // 100 + stats.time // 2.2332 - audioContext.currentTime + stats.rms // 0.02 + }, + + // Debug Callback for visualisation (Optional) + onDebug: function(stats, pitchDetector) { }, + + // Minimal signal strength (RMS, Optional) + minRms: 0.01, + + // Detect pitch only with minimal correlation of: (Optional) + minCorrelation: 0.9, + + // Detect pitch only if correlation increases with at least: (Optional) + minCorreationIncrease: 0.5, + + // Note: you cannot use minCorrelation and minCorreationIncrease + // at the same time! + + // Signal Normalization (Optional) + normalize: "rms", // or "peak". default: undefined + + // Only detect pitch once: (Optional) + stopAfterDetection: false, + + // Buffer length (Optional) + length: 1024, // default 1024 + + // Limit range (Optional): + minNote: 69, // by MIDI note number + maxNote: 80, + + minFrequency: 440, // by Frequency in Hz + maxFrequency: 20000, + + minPeriod: 2, // by period (i.e. actual distance of calculation 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 detected pitch: +```javascript +detector.getFrequency() // --> 440hz +detector.getNoteNumber() // --> 69 +detector.getNoteString() // --> "A4" +detector.getPeriod() // --> 100 +detector.getDetune() // --> 0 +detector.getCorrelation() // --> 0.95 +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 large frequency range to detect a difference. The frequency range must be large enough to include both a low and high auto-correlation. + +## Ideas to improve algorithm: + +* Draw an "optimal" auto-correlation shape, and calculate mean squared error (MSE) from measured auto-correlation score. When MSE is low enough, a pitch is detected. (or a combination of pitches!) +* Implement DTMF demodulation as example +* Learn a shape by recording auto-correlation scores of the perfect example. The resulting shape is the average of all recordes samples. Calculate standard deviation to see if the signal can be detected reliably. + +## Changelog + +### 0.2.0 (26/02/2015) + +* Used ScriptProcessingNode for faster analysis. Callbacks are still tied to the requestAnimationFrame. +* Extracted the Canvas draw function into a seperate file. + +### 0.1.0 (25/02/2015) + +* Extract core logic (pitchdetector.js) from the GUI code (example/gui.js) +* Add a new heuristic: detect a sudden increase in auto-correlation (when approaching the target frequency). +* Added signal normalization (peak or rms) +* Updated canvas visualization to draw correlation scores for every frequency. + +## Contribute + +I first want to check if the original author, Chris Wilson, is willing to pull my fork. So please check out the original version at https://github.com/cwilso/PitchDetect. + +## Credits + +Original code from [Chris Wilson](https://github.com/cwilso), improvements (see changelog) by [Mark Marijnissen](https://github.com/markmarijnissen) + +## Contact +- @markmarijnissen +- http://www.madebymark.nl +- info@madebymark.nl + +© 2015 - Mark Marijnissen & Chris Wilson diff --git a/example/example1.png b/example/example1.png new file mode 100644 index 0000000..1fb4438 Binary files /dev/null and b/example/example1.png differ diff --git a/example/example2.png b/example/example2.png new file mode 100644 index 0000000..f43b112 Binary files /dev/null and b/example/example2.png differ diff --git a/example/example3.png b/example/example3.png new file mode 100644 index 0000000..726294f Binary files /dev/null and b/example/example3.png differ 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..8125815 --- /dev/null +++ b/example/gui.js @@ -0,0 +1,288 @@ +/* +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; + +$(function(){ + // Global Variables + var audioContext = new AudioContext(); + var osc = null; + var options = { start: true }; + var needsReset = true; + var pitchDetector = null; + var theBuffer = null; + + // Form Input Elements + var inputs = { + input: $('#input'), + notes: $('#notes'), + output: $('#output'), + length: $('#length'), + minRms: $('#minrms'), + normalize: $('#normalize'), + detection: $('#detection'), + minCorrelationIncrease: $('#strength'), + minCorrelation: $('#correlation'), + range: $('#range'), + min: $('#min'), + max: $('#max'), + draw: $('#draw'), + stopAfterDetection: $('#stopAfterDetection') + }; + + + var data = JSON.parse(localStorage.getItem('pitch-detector-settings')) || {}; + for(var x in data){ + inputs[x].val(data[x]); + } + + // GUI Elements + var gui = { + detector: $('#detector'), + canvas: $('#waveform'), + pitch: $('#pitch'), + note: $('#note'), + detuneBox: $('#detune'), + detune: $('#detune_amt') + }; + + // Canvas Element + canvasEl = $("#waveform").get(0); + canvas = canvasEl.getContext("2d"); + window.savePic = function(){ + window.open(canvasEl.toDataURL("image/png")); + }; + + // 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'); + } + }); + + inputs.output.change(function(e){ + needsReset = true; + }); + + inputs.length.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; }; + 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; + }; + + // 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(0); + } 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.length = inputs.length.val() * 1; + options.stopAfterDetection = inputs.stopAfterDetection.is(':checked'); + + for(var key in options){ + if(/^(min|max)/.test(key)){ + delete options[key]; + } + } + + options.minRms = 1.0 * inputs.minRms.val() || 0.01; + var normalize = inputs.normalize.val(); + if(normalize !== 'none'){ + options.normalize = normalize; + } else { + options.normalize = false; + } + + var detection = inputs.detection.val(); + options.minCorrelationIncrease = false; + options.minCorrelation = false; + if(detection === 'correlation'){ + options.minCorrelation = inputs.minCorrelation.val() * 1.0; + } else if(detection === 'strength') { + 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){ + console.log('created PitchDetector',options); + pitchDetector = new PitchDetector(options); + needsReset = false; + } else { + pitchDetector.setOptions(options,true); + } + delete options.context; + delete options.output; + delete options.input; + $('#settings').text(JSON.stringify(options,null,4)); + window.pitchDetector = pitchDetector; + + var data = {}; + for(x in inputs){ + var el = inputs[x]; + data[x] = el.val(); + } + localStorage.setItem('pitch-detector-settings',JSON.stringify(data)); + }; + + function draw( stats, detector ) { + PitchDetectorCanvasDraw(canvas, stats, detector); + + // Update Pitch Detection GUI + if (!stats.detected) { + gui.detector.attr('class','vague'); + gui.pitch.text('--'); + gui.note.text('-'); + gui.detuneBox.attr('class',''); + gui.detune.text('--'); + } else { + gui.detector.attr('class','confident'); + var note = detector.getNoteNumber(); + var detune = detector.getDetune(); + gui.pitch.text( Math.round( stats.frequency ) ); + gui.note.text(detector.getNoteString()); + if (detune === 0){ + gui.detuneBox.attr('class',''); + gui.detune.text('--'); + } else { + if (detune < 0) + gui.detuneBox.attr('class','flat'); + else + gui.detuneBox.attr('class','sharp'); + gui.detune.text(Math.abs( detune )); + } + } + } + +}); \ No newline at end of file diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..f5802b8 --- /dev/null +++ b/example/index.html @@ -0,0 +1,188 @@ + + + +Pitch Detector + + + + + + + + + +
+

Pitch Detector

+

+ Calculated using the auto-correlation algorithm for every frequency. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
input: + + + - Test Tones: + + + + + +
output: + +
length: + + Audio Buffer Length +
minRms: + Minimal signal strength (Red) +
normalize: + +
Pitch Detection: + + + +
Pitch Detection Range: + + + (Green) +
Visualize: + +
stopAfterDetection + +
+
+ + +
+ +
+
+ +
+
--Hz
+
--
+
-- cents ♭ cents ♯
+
+ +
+ +

+Y-Axis: RMS (Red), Auto-Correlation Increase (Blue), Auto-Correlation Score (Black). +
+X-Axis: Frequency range (2-512 samples = 22.05 kHz - 83 Hz = F10 - E2) and detection area (Green) +

+

Pitch Detector Settings:

+

+
+ +Fork me on GitHub + + + diff --git a/example/whistling3.ogg b/example/whistling3.ogg new file mode 100644 index 0000000..492986c Binary files /dev/null and b/example/whistling3.ogg differ diff --git a/index.html b/index.html deleted file mode 100644 index 907bf60..0000000 --- a/index.html +++ /dev/null @@ -1,57 +0,0 @@ - - - -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/package.json b/package.json new file mode 100644 index 0000000..513e6b9 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "pitch-detector", + "version": "0.2.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" +} diff --git a/pitchdetector.js b/pitchdetector.js new file mode 100644 index 0000000..eb6d517 --- /dev/null +++ b/pitchdetector.js @@ -0,0 +1,535 @@ + +/* +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(context,callback){ + try { + navigator.getUserMedia( + { + "audio": { + "mandatory": { + "googEchoCancellation": "false", + "googAutoGainControl": "false", + "googNoiseSuppression": "false", + "googHighpassFilter": "false" + }, + }, + }, function(stream){ + 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); + } +} + +// prefix fixes +var requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame; +navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; + +function PitchDetector(options){ + + // Options: + this.options = { + minRms: 0.01, + interpolateFrequency: true, + stopAfterDetection: false, + normalize: false, + minCorrelation: false, + length: options.length, + 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(options.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: false, + frequency: -1, + best_period: 0, + worst_period: 0, + best_correlation: 0.0, + worst_correlation: 0.0, + time: 0.0, + rms: 0.0, + }; + + this.lastOnDetect = 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,ignoreConstructorOnlyProperties){ + var self = this; + + // Override options (if defined) + ['minCorrelation','minCorrelationIncrease','minRms', + 'normalize','stopAfterDetection','interpolateFrequency', + 'onDebug','onDetect','onDestroy' + ].forEach(function(option){ + if(typeof options[option] !== 'undefined') { + self.options[option] = options[option]; + } + }); + + if(ignoreConstructorOnlyProperties !== true){ + // 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)); + minPeriod = period; + maxPeriod = period; + } + if(options.minNote){ + maxPeriod = Math.round(noteToPeriod(options.minNote,this.sampleRate)); + } + if(options.maxNote){ + minPeriod = Math.round(noteToPeriod(options.maxNote,this.sampleRate)); + } + if(options.minFrequency) { + maxPeriod = Math.floor(this.sampleRate / options.minFrequency); + } + if(options.maxFrequency) { + minPeriod = Math.ceil(this.sampleRate / options.maxFrequency); + } + if(options.periods){ + this.periods = options.periods; + } else { + this.periods = []; + if(maxPeriod < minPeriod) { + var tmp = maxPeriod; + maxPeriod = minPeriod; + minPeriod = tmp; + } + var range = [1,1]; + if(this.options.minCorrelation){ + range = [1,1]; + } else if(this.options.minCorrelationIncrease){ + range = [10,1]; + } + if(maxPeriod - minPeriod < 1 + range[0] + range[1]){ + minPeriod = Math.floor(minPeriod - range[0]); + maxPeriod = Math.ceil(maxPeriod + range[1]); + } + maxPeriod = Math.min(maxPeriod,this.MAX_SAMPLES); + minPeriod = Math.max(2,minPeriod); + this.options.minPeriod = minPeriod; + this.options.maxPeriod = maxPeriod; + for(var i = minPeriod; i <= maxPeriod; i++){ + this.periods.push(i); + } + } + + // 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, + time: 0.0, + rms: 0.0, + }; + } + + // Autostart + if(options.start){ + this.start(); + } +}; + +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; + + this.analyser = this.context.createScriptProcessor(this.options.length); + this.analyser.onaudioprocess = this.autoCorrelate.bind(this); + this.input.connect(this.analyser); + if(this.output){ + this.analyser.connect(this.output); + } else { + // webkit but, it requires an output.... + // var dummyOutput = this.context.createGain(); + // dummyOutput.gain.value= 0; + // dummyOutput.connect(this.context.destination); + var dummyOutput = this.context.createAnalyser(); + dummyOutput.fftSize = 32; + this.analyser.connect(dummyOutput); + } + } + if(!this.started){ + this.started = true; + requestAnimationFrame(this.update); + } +}; +PitchDetector.prototype.update = function(event){ + if(this.lastOnDetect !== this.stats.time){ + this.lastOnDetect = this.stats.time; + if(this.options.onDetect){ + this.options.onDetect(this.stats,this); + } + } + if(this.options.onDebug){ + this.options.onDebug(this.debug,this); + } + if(this.started === true){ + requestAnimationFrame(this.update); + } +}; + +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.options.onDestroy){ + this.options.onDestroy(); + } + if(this.input && this.input.stop){ + try { + this.input.stop(0); + } catch(e){} + } + if(this.input) this.input.disconnect(); + if(this.analyser) this.analyser.disconnect(); + this.input = null; + this.analyser = null; + this.context = null; + this.buffer = null; +}; + +/** + * Sync methoc to retrieve latest pitch in various forms: + */ + +PitchDetector.prototype.getFrequency = function(){ + return this.stats.frequency; +}; + +PitchDetector.prototype.getNoteNumber = function(){ + return frequencyToNote(this.stats.frequency); +}; + +PitchDetector.prototype.getNoteString = function(){ + return frequencyToString(this.stats.frequency); +}; + +PitchDetector.prototype.getPeriod = function(){ + return this.stats.best_period; +}; + +PitchDetector.prototype.getCorrelation = function(){ + 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.stats.frequency,frequencyToNote(this.stats.frequency)); +}; + +/** + * AutoCorrelate algorithm + */ +PitchDetector.prototype.autoCorrelate = function AutoCorrelate(event){ + if(!this.started) return; + + // 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 correlation = 0; + var peak = 0; + + // early stop algorithm + var found_pitch = !this.options.minCorrelationIncrease && !this.options.minCorrelation; + var find_local_maximum = this.options.minCorrelationIncrease; + + // Constants + this.buffer = event.inputBuffer.getChannelData(0); + var NORMALIZE = 1; + 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]; + // determine peak volume + if(this.buffer[i] > peak) peak = this.buffer[i]; + } + rms = Math.sqrt(rms/ BUFFER_LENGTH); + + // 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) + * + * 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++) { + period = this.periods[i]; + correlation = 0; + + /** + * + * 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: + * |---| 0 + * |---| 1 + * |---| 2 + * ... + * |---| 512 + * + * sum-of-differences + */ + for (j=0; j < MAX_SAMPLES; j++) { + // 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); + } + + // Version 1: Absolute values + correlation = 1 - (correlation/MAX_SAMPLES); + + // 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(find_local_maximum || correlation > best_correlation){ + best_correlation = correlation; + best_period = period; + } + + last_correlation = correlation; + } + + if(this.options.onDebug){ + this.debug.detected = false; + this.debug.rms = rms; + this.debug.time = this.context.currentTime; + 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.detected = true; + 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.time = this.context.currentTime; + this.stats.rms = rms; + + var shift = 0; + if(this.options.interpolateFrequency && i >= 3 && period >= best_period + 1 && 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_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). + shift = (this.correlations[best_period+1] - this.correlations[best_period-1]) / best_correlation; + shift = shift * 8; + } + this.stats.frequency = this.sampleRate/(best_period + shift); + + if(this.options.onDebug){ + this.debug.detected = true; + this.debug.frequency = this.stats.frequency; + } + if(this.options.stopAfterDetection){ + this.started = false; + } + return true; + } else { + return false; + } +}; + +// Export on Window or as CommonJS module +if(typeof module !== 'undefined') { + module.exports = PitchDetector; +} else { + window.PitchDetector = PitchDetector; +} +})(); diff --git a/pitchdetectorcanvas.js b/pitchdetectorcanvas.js new file mode 100644 index 0000000..7f98054 --- /dev/null +++ b/pitchdetectorcanvas.js @@ -0,0 +1,99 @@ +(function(){ + var PitchDetectorCanvasDraw = function PitchDetectorCanvasDraw(canvas,stats,pitchDetector){ + if(!pitchDetector || !pitchDetector.buffer) return; + var buf = pitchDetector.buffer; + var i = 0, val = 0, len = 0, start = 20, end = 50; + if(pitchDetector.periods){ + start = pitchDetector.periods[0] + 20; + end = pitchDetector.periods[pitchDetector.periods.length-1] + 20; + } + var width = end-start; + + canvas.clearRect(0,0,512,256); + canvas.fillStyle = "#EEFFEE"; + canvas.fillRect(start,0,width,256); + + // AREA: Draw Pitch Detection Area + if(pitchDetector.options.minCorrelation){ + canvas.fillStyle = "yellow"; + canvas.fillRect(start,0,width,(1-pitchDetector.options.minCorrelation) * 256); + } + + // AREA: Draw Correlations + if(pitchDetector.correlations){ + canvas.beginPath(); + canvas.strokeStyle = "black"; + if(pitchDetector.options.minCorrelation || pitchDetector.options.minCorrelationIncrease){ + len = Math.max(stats.worst_period,stats.best_period + 1); + } else { + len = pitchDetector.correlations.length; + } + for(i = 0; i