From 7c7efdb3edf82af8674374bd2ca0c0511e43d4c0 Mon Sep 17 00:00:00 2001 From: Matthew Huntington Date: Sun, 9 Feb 2025 21:45:06 -0500 Subject: [PATCH] much better pitch detection --- analyze.js | 183 ++++++++++++++++++++++++++++------------------------- app.js | 10 +++ index.html | 9 +-- 3 files changed, 108 insertions(+), 94 deletions(-) diff --git a/analyze.js b/analyze.js index 30c2703..334dd69 100644 --- a/analyze.js +++ b/analyze.js @@ -1,101 +1,110 @@ -document.querySelector('#analyze-pitch button').addEventListener('click', startListening); +function startPitchDetection(callback) { + // Check for browser support + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + console.error("getUserMedia is not supported in this browser."); + return; + } -const generateCentsString = (centDifference) => { - const flatSharpChar = (centDifference > 0) ? '♯' : '♭' - return flatSharpChar + Math.abs(parseInt(centDifference)).toString().padStart(2, '0') -} + // Request access to the microphone + navigator.mediaDevices.getUserMedia({ audio: true }) + .then(stream => { + // Create an audio context and an analyser node + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 2048; // Set FFT size; higher values offer more precision but more latency -const centsArray = [] + // Connect the microphone stream to the analyser node + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); -const getMedianCents = (cents) => { - centsArray.push(cents) - if(centsArray.length > 50){ - centsArray.shift() - } - return centsArray[Math.floor(centsArray.length/2)] -} + // Create a buffer to hold the time domain data + const bufferLength = analyser.fftSize; + const buffer = new Float32Array(bufferLength); -function startListening() { - navigator.mediaDevices.getUserMedia({ audio: true, video: false }) - .then(stream => { - const audioContext = new (window.AudioContext || window.webkitAudioContext)(); - const analyser = audioContext.createAnalyser(); - const microphone = audioContext.createMediaStreamSource(stream); - const scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1); + /** + * Autocorrelation algorithm to estimate pitch from the audio buffer. + * Returns the estimated pitch in Hz, or -1 if no pitch is detected. + */ + function autoCorrelate(buf, sampleRate) { + const SIZE = buf.length; + let rms = 0; - analyser.smoothingTimeConstant = 0.3; - analyser.fftSize = 2048; + // Compute Root Mean Square (RMS) to check if there's enough signal + for (let i = 0; i < SIZE; i++) { + const val = buf[i]; + rms += val * val; + } + rms = Math.sqrt(rms / SIZE); + if (rms < 0.01) // Signal too weak – likely silence + return -1; - microphone.connect(analyser); - analyser.connect(scriptProcessor); - scriptProcessor.connect(audioContext.destination); + // Trim the buffer to remove noise at the beginning and end + let r1 = 0, r2 = SIZE - 1; + for (let i = 0; i < SIZE; i++) { + if (Math.abs(buf[i]) < 0.2) { r1 = i; break; } + } + for (let i = 1; i < SIZE; i++) { + if (Math.abs(buf[SIZE - i]) < 0.2) { r2 = SIZE - i; break; } + } + const trimmedBuffer = buf.slice(r1, r2); + const trimmedSize = trimmedBuffer.length; - scriptProcessor.onaudioprocess = () => { - const buffer = new Float32Array(analyser.fftSize); - analyser.getFloatTimeDomainData(buffer); - const pitch = autoCorrelate(buffer, audioContext.sampleRate); - if(pitch != -1){ - const heardTone = teoria.note.fromFrequency(pitch) - const generatedTone = teoria.note.fromFrequency(newTone) - if(heardTone.note.toString() === generatedTone.note.toString()){ - const medianCents = Math.floor(getMedianCents(heardTone.cents-generatedTone.cents)) - document.querySelector('#analyze-pitch input[type="range"]').value = medianCents - document.querySelector('#analyze-pitch label').innerText = medianCents - } - } - }; - }) - .catch(err => { - console.error('Error accessing microphone: ' + err); - }); -} + // Calculate the autocorrelation of the trimmed buffer + const correlations = new Array(trimmedSize).fill(0); + for (let lag = 0; lag < trimmedSize; lag++) { + for (let i = 0; i < trimmedSize - lag; i++) { + correlations[lag] += trimmedBuffer[i] * trimmedBuffer[i + lag]; + } + } -function autoCorrelate(buffer, sampleRate) { - const SIZE = buffer.length; - const MAX_SAMPLES = Math.floor(SIZE / 2); - const MIN_SAMPLES = 0; - const GOOD_ENOUGH_CORRELATION = 0.9; + // Find the first dip in the autocorrelation – skip lags before this point + let d = 0; + while (d < correlations.length - 1 && correlations[d] > correlations[d + 1]) { + d++; + } - let bestOffset = -1; - let bestCorrelation = 0; - let rms = 0; - let foundGoodCorrelation = false; - let correlations = new Array(MAX_SAMPLES); + // Search for the peak correlation after the dip + let maxVal = -1, maxPos = -1; + for (let i = d; i < correlations.length; i++) { + if (correlations[i] > maxVal) { + maxVal = correlations[i]; + maxPos = i; + } + } - for (let i = 0; i < SIZE; i++) { - const val = buffer[i]; - rms += val * val; - } - rms = Math.sqrt(rms / SIZE); + // Parabolic interpolation for a more accurate peak estimate + let T0 = maxPos; + if (T0 > 0 && T0 < correlations.length - 1) { + const x1 = correlations[T0 - 1]; + const x2 = correlations[T0]; + const x3 = correlations[T0 + 1]; + const a = (x1 + x3 - 2 * x2) / 2; + const b = (x3 - x1) / 2; + if (a !== 0) { + T0 = T0 - b / (2 * a); + } + } - if (rms < 0.01) // not enough signal - return -1; + // Convert lag to frequency + const pitch = sampleRate / T0; + return pitch; + } - let lastCorrelation = 1; - for (let offset = MIN_SAMPLES; offset < MAX_SAMPLES; offset++) { - let correlation = 0; + // Continuously update and detect pitch + function updatePitch() { + // Get the latest time-domain data + analyser.getFloatTimeDomainData(buffer); + // Estimate pitch using our autocorrelation function + const pitch = autoCorrelate(buffer, audioContext.sampleRate); + // Pass the detected pitch (in Hz) to the provided callback + callback(pitch); + // Continue the update loop + requestAnimationFrame(updatePitch); + } - for (let i = 0; i < MAX_SAMPLES; i++) { - correlation += Math.abs((buffer[i]) - (buffer[i + offset])); - } - correlation = 1 - (correlation / MAX_SAMPLES); - correlations[offset] = correlation; - - if ((correlation > GOOD_ENOUGH_CORRELATION) && (correlation > lastCorrelation)) { - foundGoodCorrelation = true; - if (correlation > bestCorrelation) { - bestCorrelation = correlation; - bestOffset = offset; - } - } else if (foundGoodCorrelation) { - const shift = (correlations[bestOffset + 1] - correlations[bestOffset - 1]) / correlations[bestOffset]; - return sampleRate / (bestOffset + (8 * shift)); - } - lastCorrelation = correlation; - } - if (bestCorrelation > 0.01) { - return sampleRate / bestOffset; - } - return -1; + updatePitch(); + }) + .catch(err => { + console.error("Error accessing the microphone: ", err); + }); } - diff --git a/app.js b/app.js index da44a82..8fb7dbb 100644 --- a/app.js +++ b/app.js @@ -74,3 +74,13 @@ minInput.addEventListener('change', ()=>{ maxInput.addEventListener('change', ()=>{ highest = maxInput.value }) + +startPitchDetection((heardPitch)=>{ + if(newTone) { + const centsDifference = 1200 * Math.log2(heardPitch / newTone) + document.querySelector('#analyze-pitch input[type="range"]').value = centsDifference + } + if(heardPitch === -1){ + document.querySelector('#analyze-pitch input[type="range"]').value = 0 + } +}); diff --git a/index.html b/index.html index d478922..1001df4 100644 --- a/index.html +++ b/index.html @@ -5,16 +5,13 @@ + -