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
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);
});
}

@ -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
}
});

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

Loading…
Cancel
Save