much better pitch detection

master
Matthew Huntington 11 months ago
parent 755354ce7f
commit 7c7efdb3ed

@ -1,101 +1,110 @@
document.querySelector('#analyze-pitch button').addEventListener('click', startListening); function startPitchDetection(callback) {
// Check for browser support
const generateCentsString = (centDifference) => { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const flatSharpChar = (centDifference > 0) ? '♯' : '♭' console.error("getUserMedia is not supported in this browser.");
return flatSharpChar + Math.abs(parseInt(centDifference)).toString().padStart(2, '0') return;
}
const centsArray = []
const getMedianCents = (cents) => {
centsArray.push(cents)
if(centsArray.length > 50){
centsArray.shift()
}
return centsArray[Math.floor(centsArray.length/2)]
} }
function startListening() { // Request access to the microphone
navigator.mediaDevices.getUserMedia({ audio: true, video: false }) navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => { .then(stream => {
// Create an audio context and an analyser node
const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const analyser = audioContext.createAnalyser(); const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(stream); analyser.fftSize = 2048; // Set FFT size; higher values offer more precision but more latency
const scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1);
analyser.smoothingTimeConstant = 0.3; // Connect the microphone stream to the analyser node
analyser.fftSize = 2048; const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
microphone.connect(analyser); // Create a buffer to hold the time domain data
analyser.connect(scriptProcessor); const bufferLength = analyser.fftSize;
scriptProcessor.connect(audioContext.destination); const buffer = new Float32Array(bufferLength);
scriptProcessor.onaudioprocess = () => { /**
const buffer = new Float32Array(analyser.fftSize); * Autocorrelation algorithm to estimate pitch from the audio buffer.
analyser.getFloatTimeDomainData(buffer); * Returns the estimated pitch in Hz, or -1 if no pitch is detected.
const pitch = autoCorrelate(buffer, audioContext.sampleRate); */
if(pitch != -1){ function autoCorrelate(buf, sampleRate) {
const heardTone = teoria.note.fromFrequency(pitch) const SIZE = buf.length;
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);
});
}
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;
let bestOffset = -1;
let bestCorrelation = 0;
let rms = 0; let rms = 0;
let foundGoodCorrelation = false;
let correlations = new Array(MAX_SAMPLES);
// Compute Root Mean Square (RMS) to check if there's enough signal
for (let i = 0; i < SIZE; i++) { for (let i = 0; i < SIZE; i++) {
const val = buffer[i]; const val = buf[i];
rms += val * val; rms += val * val;
} }
rms = Math.sqrt(rms / SIZE); rms = Math.sqrt(rms / SIZE);
if (rms < 0.01) // Signal too weak likely silence
if (rms < 0.01) // not enough signal
return -1; return -1;
let lastCorrelation = 1; // Trim the buffer to remove noise at the beginning and end
for (let offset = MIN_SAMPLES; offset < MAX_SAMPLES; offset++) { let r1 = 0, r2 = SIZE - 1;
let correlation = 0; 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;
// 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];
}
}
for (let i = 0; i < MAX_SAMPLES; i++) { // Find the first dip in the autocorrelation skip lags before this point
correlation += Math.abs((buffer[i]) - (buffer[i + offset])); let d = 0;
while (d < correlations.length - 1 && correlations[d] > correlations[d + 1]) {
d++;
} }
correlation = 1 - (correlation / MAX_SAMPLES);
correlations[offset] = correlation;
if ((correlation > GOOD_ENOUGH_CORRELATION) && (correlation > lastCorrelation)) { // Search for the peak correlation after the dip
foundGoodCorrelation = true; let maxVal = -1, maxPos = -1;
if (correlation > bestCorrelation) { for (let i = d; i < correlations.length; i++) {
bestCorrelation = correlation; if (correlations[i] > maxVal) {
bestOffset = offset; maxVal = correlations[i];
maxPos = i;
} }
} else if (foundGoodCorrelation) {
const shift = (correlations[bestOffset + 1] - correlations[bestOffset - 1]) / correlations[bestOffset];
return sampleRate / (bestOffset + (8 * shift));
} }
lastCorrelation = correlation;
// 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 (bestCorrelation > 0.01) {
return sampleRate / bestOffset;
} }
return -1;
// Convert lag to frequency
const pitch = sampleRate / T0;
return pitch;
} }
// 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);
}
updatePitch();
})
.catch(err => {
console.error("Error accessing the microphone: ", err);
});
}

@ -74,3 +74,13 @@ minInput.addEventListener('change', ()=>{
maxInput.addEventListener('change', ()=>{ maxInput.addEventListener('change', ()=>{
highest = maxInput.value 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
}
});

@ -5,16 +5,13 @@
<title></title> <title></title>
<script src="https://unpkg.com/tone"></script> <script src="https://unpkg.com/tone"></script>
<script src="teoria.js"></script> <script src="teoria.js"></script>
<script src="analyze.js"></script>
<script src="app.js" defer></script> <script src="app.js" defer></script>
<script src="analyze.js" defer></script>
<style> <style>
body { body {
font-family: monospace; font-family: monospace;
line-height:2em; line-height:2em;
} }
label {
display:block;
}
input[type="range"] { input[type="range"] {
-webkit-appearance: none; -webkit-appearance: none;
border: 1px solid black; border: 1px solid black;
@ -39,9 +36,7 @@
</section> </section>
<section id="analyze-pitch"> <section id="analyze-pitch">
<h2>Pitch Analyzer</h2> <h2>Pitch Analyzer</h2>
<button>Analyze</button> <input type="range" min="-100" max="100" value="0"/>
<label></label>
<input type="range" min="-50" max="50" value="0"/>
</section> </section>
</body> </body>
</html> </html>

Loading…
Cancel
Save