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