UNPKG

bluesharp-pitch-detection

Version:

High-accuracy pitch detection algorithms for musical applications

531 lines (451 loc) 19.7 kB
/* * Copyright (c) 2023 Christian Kierdorf * * Permission is hereby granted, free of charge, to any person obtaining a * copy of this software and associated documentation files (the "Software"), * to deal in the Software without restriction, including without limitation * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or * sell copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * */ import ChordDetectionResult from './ChordDetectionResult.js'; /** * Implementation of a spectral-based algorithm for chord detection. * * This class provides a modern approach to detect multiple pitches (chords) * in an audio signal using spectral analysis. * * The algorithm works by: * 1. Applying a window function to the audio data * 2. Computing the FFT to get the frequency spectrum * 3. Finding peaks in the spectrum that correspond to pitches * 4. Filtering and refining the detected pitches */ class ChordDetector { // Constants static NO_DETECTED_PITCH = -1; // Indicates no pitch detected static DEFAULT_MIN_FREQUENCY = 80.0; // Default minimum frequency in Hz static DEFAULT_MAX_FREQUENCY = 4835.0; // Default maximum frequency in Hz /** * The minimum amplitude threshold for peak detection in the spectrum. * Peaks with amplitudes below this threshold will not be considered. */ static PEAK_THRESHOLD = 0.05; /** * The minimum distance between peaks in Hz. * Peaks that are closer than this distance will be merged. */ static MIN_PEAK_DISTANCE_HZ = 25.0; /** * The maximum number of pitches to detect. */ static MAX_PITCHES = 4; /** * The threshold for spectral flatness to distinguish between tonal sounds and noise. * Values above this threshold indicate noise. */ static SPECTRAL_FLATNESS_THRESHOLD = 0.4; /** * Represents the tolerance level used to identify and filter out harmonic frequencies * in the pitch detection process within audio signals. * * This constant defines the threshold ratio within which frequencies are considered * close enough to be treated as harmonics of a fundamental frequency. */ static HARMONIC_TOLERANCE = 0.05; // Configurable properties static minFrequency = ChordDetector.DEFAULT_MIN_FREQUENCY; static maxFrequency = ChordDetector.DEFAULT_MAX_FREQUENCY; /** * Sets the minimum frequency that can be detected (in Hz). * @param {number} frequency - The minimum frequency in Hz */ static setMinFrequency(frequency) { ChordDetector.minFrequency = frequency; } /** * Gets the minimum frequency that can be detected (in Hz). * @returns {number} The minimum frequency in Hz */ static getMinFrequency() { return ChordDetector.minFrequency; } /** * Sets the maximum frequency that can be detected (in Hz). * @param {number} frequency - The maximum frequency in Hz */ static setMaxFrequency(frequency) { ChordDetector.maxFrequency = frequency; } /** * Gets the maximum frequency that can be detected (in Hz). * @returns {number} The maximum frequency in Hz */ static getMaxFrequency() { return ChordDetector.maxFrequency; } /** * Detects multiple pitches (chord) in an audio signal. * * @param {Array<number>} audioData - An array of audio signal data. * @param {number} sampleRate - The sample rate of the audio signal in Hz. * @returns {Object} An object containing the detected pitches and confidence value. */ static detectChord(audioData, sampleRate) { // Prepare for FFT (needs power of 2 size) const fftSize = Math.max(1024, ChordDetector.nextPowerOfTwo(audioData.length)); const fftInput = new Array(fftSize * 2).fill(0); // Complex numbers (real, imag) // Apply window function and prepare FFT input for (let i = 0; i < audioData.length; i++) { fftInput[i * 2] = audioData[i] * ChordDetector.hannWindow(i, audioData.length); fftInput[i * 2 + 1] = 0; // Imaginary part is zero } // Perform FFT ChordDetector.fft(fftInput, fftSize); // Calculate magnitude spectrum const magnitudeSpectrum = new Array(Math.floor(fftSize / 2)); for (let i = 0; i < Math.floor(fftSize / 2); i++) { const real = fftInput[i * 2]; const imag = fftInput[i * 2 + 1]; magnitudeSpectrum[i] = Math.sqrt(real * real + imag * imag); } // Calculate spectral flatness to distinguish between tonal sounds and noise const spectralFlatness = ChordDetector.calculateSpectralFlatness(magnitudeSpectrum, sampleRate); // If the spectral flatness is high, it's likely noise if (spectralFlatness > ChordDetector.SPECTRAL_FLATNESS_THRESHOLD) { return new ChordDetectionResult([], 0.0); } // Normalize the spectrum const maxMagnitude = Math.max(...magnitudeSpectrum); for (let i = 0; i < magnitudeSpectrum.length; i++) { magnitudeSpectrum[i] /= maxMagnitude; } // Find peaks in the spectrum let peaks = ChordDetector.findPeaks(magnitudeSpectrum, sampleRate, fftSize); // Filter peaks based on frequency range and threshold peaks = ChordDetector.filterPeaks(peaks); // Filter harmonics to avoid overtones and prioritize fundamental frequencies peaks = ChordDetector.filterHarmonics(peaks); // Prioritize lower frequencies over higher harmonics peaks = ChordDetector.prioritizeLowerFrequencies(peaks); // Merge peaks that are too close peaks = ChordDetector.mergePeaks(peaks); // Limit the number of peaks if (peaks.length > ChordDetector.MAX_PITCHES) { peaks = peaks.slice(0, ChordDetector.MAX_PITCHES); } // Extract pitches from peaks const pitches = peaks.map(peak => peak.frequency); // Calculate confidence based on the strength of the peaks const confidence = peaks.length === 0 ? 0.0 : peaks.reduce((sum, peak) => sum + peak.magnitude, 0) / peaks.length; return new ChordDetectionResult(pitches, confidence); } /** * Finds peaks in the magnitude spectrum that correspond to pitches. * * @param {Array<number>} magnitudeSpectrum - The magnitude spectrum from FFT * @param {number} sampleRate - The sample rate of the audio signal in Hz * @param {number} fftSize - The size of the FFT * @returns {Array<Object>} An array of peaks found in the spectrum */ static findPeaks(magnitudeSpectrum, sampleRate, fftSize) { const peaks = []; // Skip the first few bins (DC and very low frequencies) const startBin = Math.max(1, Math.floor(ChordDetector.minFrequency * fftSize / sampleRate)); const endBin = Math.min(magnitudeSpectrum.length - 1, Math.floor(ChordDetector.maxFrequency * fftSize / sampleRate)); for (let i = startBin + 1; i < endBin - 1; i++) { // Check if this is a local maximum if (magnitudeSpectrum[i] > magnitudeSpectrum[i - 1] && magnitudeSpectrum[i] > magnitudeSpectrum[i + 1] && magnitudeSpectrum[i] > ChordDetector.PEAK_THRESHOLD) { // Refine the peak position using parabolic interpolation const refinedBin = ChordDetector.parabolicInterpolation(magnitudeSpectrum, i); const frequency = refinedBin * sampleRate / fftSize; // Add the peak to the list peaks.push({ frequency, magnitude: magnitudeSpectrum[i] }); } } // Sort peaks by magnitude (descending) peaks.sort((p1, p2) => p2.magnitude - p1.magnitude); return peaks; } /** * Filters peaks based on frequency range and threshold. * * @param {Array<Object>} peaks - The list of peaks to filter * @returns {Array<Object>} The filtered list of peaks */ static filterPeaks(peaks) { return peaks.filter(peak => peak.frequency >= ChordDetector.minFrequency && peak.frequency <= ChordDetector.maxFrequency ); } /** * Merges peaks that are too close to each other. * * @param {Array<Object>} peaks - The list of peaks to merge * @returns {Array<Object>} The merged list of peaks */ static mergePeaks(peaks) { if (peaks.length === 0) { return peaks; } const mergedPeaks = []; let currentPeak = peaks[0]; for (let i = 1; i < peaks.length; i++) { const nextPeak = peaks[i]; // Check if the peaks are too close if (Math.abs(nextPeak.frequency - currentPeak.frequency) < ChordDetector.MIN_PEAK_DISTANCE_HZ) { // Merge the peaks (weighted average based on magnitude) const totalMagnitude = currentPeak.magnitude + nextPeak.magnitude; const mergedFrequency = (currentPeak.frequency * currentPeak.magnitude + nextPeak.frequency * nextPeak.magnitude) / totalMagnitude; currentPeak = { frequency: mergedFrequency, magnitude: totalMagnitude }; } else { // Add the current peak and move to the next one mergedPeaks.push(currentPeak); currentPeak = nextPeak; } } // Add the last peak mergedPeaks.push(currentPeak); return mergedPeaks; } /** * Finds the next power of two greater than or equal to the given number. * * @param {number} n - The number to find the next power of two for * @returns {number} The next power of two */ static nextPowerOfTwo(n) { let power = 1; while (power < n) { power *= 2; } return power; } /** * Calculates the spectral flatness of a magnitude spectrum. * Spectral flatness is the ratio of the geometric mean to the arithmetic mean * of the spectrum, and is a good measure of how "noisy" a sound is. * Values close to 0 indicate tonal sounds, while values close to 1 indicate noise. * * @param {Array<number>} magnitudeSpectrum - The magnitude spectrum to calculate flatness for * @param {number} sampleRate - The sample rate of the audio signal in Hz * @returns {number} The spectral flatness value between 0 and 1 */ static calculateSpectralFlatness(magnitudeSpectrum, sampleRate) { // Skip the first few bins (DC and very low frequencies) const startBin = Math.max(1, Math.floor(ChordDetector.minFrequency * magnitudeSpectrum.length / (sampleRate / 2))); const endBin = Math.min(magnitudeSpectrum.length - 1, Math.floor(ChordDetector.maxFrequency * magnitudeSpectrum.length / (sampleRate / 2))); let sum = 0.0; let logSum = 0.0; let count = 0; // Calculate arithmetic mean and geometric mean for (let i = startBin; i <= endBin; i++) { // Add a small value to avoid log(0) const value = magnitudeSpectrum[i] + 1e-10; sum += value; logSum += Math.log(value); count++; } if (count === 0 || sum === 0) { return 1.0; // Maximum flatness (noise) } const arithmeticMean = sum / count; const geometricMean = Math.exp(logSum / count); // Calculate spectral flatness return geometricMean / arithmeticMean; } /** * Filters out harmonic frequencies by checking each peak against deeper fundamental frequencies. * Harmonics are identified by their frequency ratios (integer multiples). * * @param {Array<Object>} peaks - The list of detected peaks to filter * @returns {Array<Object>} The filtered list of peaks (without harmonics) */ static filterHarmonics(peaks) { const filteredPeaks = []; for (let i = 0; i < peaks.length; i++) { let isHarmonic = false; for (let j = 0; j < i; j++) { const ratio = peaks[i].frequency / peaks[j].frequency; // If the peak frequency is within a harmonic series, filter it out // Special case for octaves (ratio ~2.0): don't filter them out if (Math.abs(ratio - 2.0) < 0.1) { // Allow octaves to pass through continue; } if (Math.abs(ratio - Math.round(ratio)) < ChordDetector.HARMONIC_TOLERANCE) { if (ratio > 5.0) { // Unrealistic harmonic continue; // Accept it as a separate tone } if (peaks[i].magnitude < peaks[j].magnitude * 0.3) { // Adjusted amplitude ratio to suppress harmonics isHarmonic = true; break; } } } if (!isHarmonic) { filteredPeaks.push(peaks[i]); } } return filteredPeaks; } /** * Prioritizes lower frequencies by comparing relative amplitudes of peaks. * This ensures that higher harmonics are deprioritized if similar strength exists at lower frequencies. * * @param {Array<Object>} peaks - The list of detected peaks to prioritize * @returns {Array<Object>} The ordered and prioritized list of peaks */ static prioritizeLowerFrequencies(peaks) { // Sort by frequency (ascending) peaks.sort((p1, p2) => p1.frequency - p2.frequency); const prioritizedPeaks = []; for (const peak of peaks) { let isOverridden = false; for (const lowerPeak of prioritizedPeaks) { if (peak.frequency > lowerPeak.frequency && peak.magnitude < lowerPeak.magnitude * 0.6) { // Adjusted amplitude ratio (factor 0.6) isOverridden = true; break; } } if (!isOverridden) { prioritizedPeaks.push(peak); } } return prioritizedPeaks; } /** * Applies parabolic interpolation to refine a peak index. * This method is used to improve the accuracy of peak detection. * * @param {Array<number>} values - An array of values representing the function to interpolate * @param {number} peakIndex - The index of the detected peak in the values array * @returns {number} The refined peak index as a number, adjusted using parabolic interpolation */ static parabolicInterpolation(values, peakIndex) { if (peakIndex <= 0 || peakIndex >= values.length - 1) { return peakIndex; } const x0 = values[peakIndex - 1]; const x1 = values[peakIndex]; const x2 = values[peakIndex + 1]; // Calculate the adjustment using parabolic interpolation const denominator = x0 - 2 * x1 + x2; // Avoid division by zero or very small values if (Math.abs(denominator) < 1e-10) { return peakIndex; } let adjustment = 0.5 * (x0 - x2) / denominator; // Limit the adjustment to a reasonable range to avoid extreme values if (Math.abs(adjustment) > 1) { adjustment = 0; } return peakIndex + adjustment; } /** * Applies a Hann window function to the sample at the given index. * * @param {number} index - The index of the sample * @param {number} size - The total number of samples * @returns {number} The window coefficient */ static hannWindow(index, size) { return 0.5 * (1 - Math.cos(2 * Math.PI * index / (size - 1))); } /** * Performs an in-place Fast Fourier Transform (FFT) on the input data. * This is a radix-2 decimation-in-time FFT algorithm. * * @param {Array<number>} data - The input/output data array (complex numbers as pairs of real, imaginary) * @param {number} n - The size of the FFT (number of complex numbers) */ static fft(data, n) { // Bit-reversal permutation let shift = 1; while (shift < n) { shift <<= 1; } shift >>= 1; // Bit reversal for (let i = 0; i < n; i++) { const j = ChordDetector.bitReverse(i, shift); if (j > i) { // Swap real parts let temp = data[i * 2]; data[i * 2] = data[j * 2]; data[j * 2] = temp; // Swap imaginary parts temp = data[i * 2 + 1]; data[i * 2 + 1] = data[j * 2 + 1]; data[j * 2 + 1] = temp; } } // Cooley-Tukey FFT for (let len = 2; len <= n; len <<= 1) { const angle = -2 * Math.PI / len; const wReal = Math.cos(angle); const wImag = Math.sin(angle); for (let i = 0; i < n; i += len) { let uReal = 1.0; let uImag = 0.0; for (let j = 0; j < len / 2; j++) { const p = i + j; const q = i + j + len / 2; const pReal = data[p * 2]; const pImag = data[p * 2 + 1]; const qReal = data[q * 2]; const qImag = data[q * 2 + 1]; // Temporary values for the multiplication const tempReal = uReal * qReal - uImag * qImag; const tempImag = uReal * qImag + uImag * qReal; // Update data data[q * 2] = pReal - tempReal; data[q * 2 + 1] = pImag - tempImag; data[p * 2] = pReal + tempReal; data[p * 2 + 1] = pImag + tempImag; // Update u const nextUReal = uReal * wReal - uImag * wImag; const nextUImag = uReal * wImag + uImag * wReal; uReal = nextUReal; uImag = nextUImag; } } } } /** * Reverses the bits of an integer value up to the given shift. * * @param {number} value - The value to reverse * @param {number} shift - The bit position to reverse up to * @returns {number} The bit-reversed value */ static bitReverse(value, shift) { let result = 0; while (shift > 0) { result = (result << 1) | (value & 1); value >>= 1; shift >>= 1; } return result; } } export default ChordDetector;