├── img └── forkme.png ├── README.md ├── LICENSE.txt ├── index.html └── js └── pitchdetect.js /img/forkme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cwilso/PitchDetect/HEAD/img/forkme.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple pitch detection 2 | 3 | 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. 4 | 5 | Live instance hosted on Github at https://cwilso.github.io/PitchDetect/. 6 | 7 | Check it out, feel free to fork, submit pull requests, etc. MIT-Licensed - party on. 8 | 9 | -Chris 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Chris Wilson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pitch Detector 5 | 6 | 21 | 22 | 23 | 24 | 25 |

26 |

27 | 28 | 29 | 30 | 31 |

32 | 33 |
34 |
--Hz
35 |
--
36 | 37 |
--cents ♭cents ♯
38 |
39 | 40 | 43 | Fork me on GitHub 44 | 45 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /js/pitchdetect.js: -------------------------------------------------------------------------------- 1 | /* 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2014 Chris Wilson 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | window.AudioContext = window.AudioContext || window.webkitAudioContext; 26 | 27 | var audioContext = null; 28 | var isPlaying = false; 29 | var sourceNode = null; 30 | var analyser = null; 31 | var theBuffer = null; 32 | var DEBUGCANVAS = null; 33 | var mediaStreamSource = null; 34 | var detectorElem, 35 | canvasElem, 36 | waveCanvas, 37 | pitchElem, 38 | noteElem, 39 | detuneElem, 40 | detuneAmount; 41 | 42 | window.onload = function() { 43 | audioContext = new AudioContext(); 44 | MAX_SIZE = Math.max(4,Math.floor(audioContext.sampleRate/5000)); // corresponds to a 5kHz signal 45 | 46 | detectorElem = document.getElementById( "detector" ); 47 | canvasElem = document.getElementById( "output" ); 48 | DEBUGCANVAS = document.getElementById( "waveform" ); 49 | if (DEBUGCANVAS) { 50 | waveCanvas = DEBUGCANVAS.getContext("2d"); 51 | waveCanvas.strokeStyle = "black"; 52 | waveCanvas.lineWidth = 1; 53 | } 54 | pitchElem = document.getElementById( "pitch" ); 55 | noteElem = document.getElementById( "note" ); 56 | detuneElem = document.getElementById( "detune" ); 57 | detuneAmount = document.getElementById( "detune_amt" ); 58 | 59 | detectorElem.ondragenter = function () { 60 | this.classList.add("droptarget"); 61 | return false; }; 62 | detectorElem.ondragleave = function () { this.classList.remove("droptarget"); return false; }; 63 | detectorElem.ondrop = function (e) { 64 | this.classList.remove("droptarget"); 65 | e.preventDefault(); 66 | theBuffer = null; 67 | 68 | var reader = new FileReader(); 69 | reader.onload = function (event) { 70 | audioContext.decodeAudioData( event.target.result, function(buffer) { 71 | theBuffer = buffer; 72 | }, function(){alert("error loading!");} ); 73 | 74 | }; 75 | reader.onerror = function (event) { 76 | alert("Error: " + reader.error ); 77 | }; 78 | reader.readAsArrayBuffer(e.dataTransfer.files[0]); 79 | return false; 80 | }; 81 | 82 | fetch('whistling3.ogg') 83 | .then((response) => { 84 | if (!response.ok) { 85 | throw new Error(`HTTP error, status = ${response.status}`); 86 | } 87 | return response.arrayBuffer(); 88 | }).then((buffer) => audioContext.decodeAudioData(buffer)).then((decodedData) => { 89 | theBuffer = decodedData; 90 | }); 91 | 92 | } 93 | 94 | function startPitchDetect() { 95 | // grab an audio context 96 | audioContext = new AudioContext(); 97 | 98 | // Attempt to get audio input 99 | navigator.mediaDevices.getUserMedia( 100 | { 101 | "audio": { 102 | "mandatory": { 103 | "googEchoCancellation": "false", 104 | "googAutoGainControl": "false", 105 | "googNoiseSuppression": "false", 106 | "googHighpassFilter": "false" 107 | }, 108 | "optional": [] 109 | }, 110 | }).then((stream) => { 111 | // Create an AudioNode from the stream. 112 | mediaStreamSource = audioContext.createMediaStreamSource(stream); 113 | 114 | // Connect it to the destination. 115 | analyser = audioContext.createAnalyser(); 116 | analyser.fftSize = 2048; 117 | mediaStreamSource.connect( analyser ); 118 | updatePitch(); 119 | }).catch((err) => { 120 | // always check for errors at the end. 121 | console.error(`${err.name}: ${err.message}`); 122 | alert('Stream generation failed.'); 123 | }); 124 | } 125 | 126 | function toggleOscillator() { 127 | if (isPlaying) { 128 | //stop playing and return 129 | sourceNode.stop( 0 ); 130 | sourceNode = null; 131 | analyser = null; 132 | isPlaying = false; 133 | if (!window.cancelAnimationFrame) 134 | window.cancelAnimationFrame = window.webkitCancelAnimationFrame; 135 | window.cancelAnimationFrame( rafID ); 136 | return "play oscillator"; 137 | } 138 | sourceNode = audioContext.createOscillator(); 139 | 140 | analyser = audioContext.createAnalyser(); 141 | analyser.fftSize = 2048; 142 | sourceNode.connect( analyser ); 143 | analyser.connect( audioContext.destination ); 144 | sourceNode.start(0); 145 | isPlaying = true; 146 | isLiveInput = false; 147 | updatePitch(); 148 | 149 | return "stop"; 150 | } 151 | 152 | function toggleLiveInput() { 153 | if (isPlaying) { 154 | //stop playing and return 155 | sourceNode.stop( 0 ); 156 | sourceNode = null; 157 | analyser = null; 158 | isPlaying = false; 159 | if (!window.cancelAnimationFrame) 160 | window.cancelAnimationFrame = window.webkitCancelAnimationFrame; 161 | window.cancelAnimationFrame( rafID ); 162 | } 163 | getUserMedia( 164 | { 165 | "audio": { 166 | "mandatory": { 167 | "googEchoCancellation": "false", 168 | "googAutoGainControl": "false", 169 | "googNoiseSuppression": "false", 170 | "googHighpassFilter": "false" 171 | }, 172 | "optional": [] 173 | }, 174 | }, gotStream); 175 | } 176 | 177 | function togglePlayback() { 178 | if (isPlaying) { 179 | //stop playing and return 180 | sourceNode.stop( 0 ); 181 | sourceNode = null; 182 | analyser = null; 183 | isPlaying = false; 184 | if (!window.cancelAnimationFrame) 185 | window.cancelAnimationFrame = window.webkitCancelAnimationFrame; 186 | window.cancelAnimationFrame( rafID ); 187 | return "start"; 188 | } 189 | 190 | sourceNode = audioContext.createBufferSource(); 191 | sourceNode.buffer = theBuffer; 192 | sourceNode.loop = true; 193 | 194 | analyser = audioContext.createAnalyser(); 195 | analyser.fftSize = 2048; 196 | sourceNode.connect( analyser ); 197 | analyser.connect( audioContext.destination ); 198 | sourceNode.start( 0 ); 199 | isPlaying = true; 200 | isLiveInput = false; 201 | updatePitch(); 202 | 203 | return "stop"; 204 | } 205 | 206 | var rafID = null; 207 | var tracks = null; 208 | var buflen = 2048; 209 | var buf = new Float32Array( buflen ); 210 | 211 | var noteStrings = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; 212 | 213 | function noteFromPitch( frequency ) { 214 | var noteNum = 12 * (Math.log( frequency / 440 )/Math.log(2) ); 215 | return Math.round( noteNum ) + 69; 216 | } 217 | 218 | function frequencyFromNoteNumber( note ) { 219 | return 440 * Math.pow(2,(note-69)/12); 220 | } 221 | 222 | function centsOffFromPitch( frequency, note ) { 223 | return Math.floor( 1200 * Math.log( frequency / frequencyFromNoteNumber( note ))/Math.log(2) ); 224 | } 225 | 226 | // this is the previously used pitch detection algorithm. 227 | /* 228 | var MIN_SAMPLES = 0; // will be initialized when AudioContext is created. 229 | var GOOD_ENOUGH_CORRELATION = 0.9; // this is the "bar" for how close a correlation needs to be 230 | 231 | function autoCorrelate( buf, sampleRate ) { 232 | var SIZE = buf.length; 233 | var MAX_SAMPLES = Math.floor(SIZE/2); 234 | var best_offset = -1; 235 | var best_correlation = 0; 236 | var rms = 0; 237 | var foundGoodCorrelation = false; 238 | var correlations = new Array(MAX_SAMPLES); 239 | 240 | for (var i=0;iGOOD_ENOUGH_CORRELATION) && (correlation > lastCorrelation)) { 258 | foundGoodCorrelation = true; 259 | if (correlation > best_correlation) { 260 | best_correlation = correlation; 261 | best_offset = offset; 262 | } 263 | } else if (foundGoodCorrelation) { 264 | // short-circuit - we found a good correlation, then a bad one, so we'd just be seeing copies from here. 265 | // Now we need to tweak the offset - by interpolating between the values to the left and right of the 266 | // best offset, and shifting it a bit. This is complex, and HACKY in this code (happy to take PRs!) - 267 | // we need to do a curve fit on correlations[] around best_offset in order to better determine precise 268 | // (anti-aliased) offset. 269 | 270 | // we know best_offset >=1, 271 | // since foundGoodCorrelation cannot go to true until the second pass (offset=1), and 272 | // we can't drop into this clause until the following pass (else if). 273 | var shift = (correlations[best_offset+1] - correlations[best_offset-1])/correlations[best_offset]; 274 | return sampleRate/(best_offset+(8*shift)); 275 | } 276 | lastCorrelation = correlation; 277 | } 278 | if (best_correlation > 0.01) { 279 | // console.log("f = " + sampleRate/best_offset + "Hz (rms: " + rms + " confidence: " + best_correlation + ")") 280 | return sampleRate/best_offset; 281 | } 282 | return -1; 283 | // var best_frequency = sampleRate/best_offset; 284 | } 285 | */ 286 | 287 | function autoCorrelate( buf, sampleRate ) { 288 | // Implements the ACF2+ algorithm 289 | var SIZE = buf.length; 290 | var rms = 0; 291 | 292 | for (var i=0;ic[d+1]) d++; 315 | var maxval=-1, maxpos=-1; 316 | for (var i=d; i maxval) { 318 | maxval = c[i]; 319 | maxpos = i; 320 | } 321 | } 322 | var T0 = maxpos; 323 | 324 | var x1=c[T0-1], x2=c[T0], x3=c[T0+1]; 325 | a = (x1 + x3 - 2*x2)/2; 326 | b = (x3 - x1)/2; 327 | if (a) T0 = T0 - b/(2*a); 328 | 329 | return sampleRate/T0; 330 | } 331 | 332 | function updatePitch( time ) { 333 | var cycles = new Array; 334 | analyser.getFloatTimeDomainData( buf ); 335 | var ac = autoCorrelate( buf, audioContext.sampleRate ); 336 | // TODO: Paint confidence meter on canvasElem here. 337 | 338 | if (DEBUGCANVAS) { // This draws the current waveform, useful for debugging 339 | waveCanvas.clearRect(0,0,512,256); 340 | waveCanvas.strokeStyle = "red"; 341 | waveCanvas.beginPath(); 342 | waveCanvas.moveTo(0,0); 343 | waveCanvas.lineTo(0,256); 344 | waveCanvas.moveTo(128,0); 345 | waveCanvas.lineTo(128,256); 346 | waveCanvas.moveTo(256,0); 347 | waveCanvas.lineTo(256,256); 348 | waveCanvas.moveTo(384,0); 349 | waveCanvas.lineTo(384,256); 350 | waveCanvas.moveTo(512,0); 351 | waveCanvas.lineTo(512,256); 352 | waveCanvas.stroke(); 353 | waveCanvas.strokeStyle = "black"; 354 | waveCanvas.beginPath(); 355 | waveCanvas.moveTo(0,buf[0]); 356 | for (var i=1;i<512;i++) { 357 | waveCanvas.lineTo(i,128+(buf[i]*128)); 358 | } 359 | waveCanvas.stroke(); 360 | } 361 | 362 | if (ac == -1) { 363 | detectorElem.className = "vague"; 364 | pitchElem.innerText = "--"; 365 | noteElem.innerText = "-"; 366 | detuneElem.className = ""; 367 | detuneAmount.innerText = "--"; 368 | } else { 369 | detectorElem.className = "confident"; 370 | pitch = ac; 371 | pitchElem.innerText = Math.round( pitch ) ; 372 | var note = noteFromPitch( pitch ); 373 | noteElem.innerHTML = noteStrings[note%12]; 374 | var detune = centsOffFromPitch( pitch, note ); 375 | if (detune == 0 ) { 376 | detuneElem.className = ""; 377 | detuneAmount.innerHTML = "--"; 378 | } else { 379 | if (detune < 0) 380 | detuneElem.className = "flat"; 381 | else 382 | detuneElem.className = "sharp"; 383 | detuneAmount.innerHTML = Math.abs( detune ); 384 | } 385 | } 386 | 387 | if (!window.requestAnimationFrame) 388 | window.requestAnimationFrame = window.webkitRequestAnimationFrame; 389 | rafID = window.requestAnimationFrame( updatePitch ); 390 | } 391 | --------------------------------------------------------------------------------