feat: Implement initial tuner application with core logic, audio processing, and presentation components.
This commit is contained in:
186
src/infrastructure/fft-pitch-detector.ts
Normal file
186
src/infrastructure/fft-pitch-detector.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* FFT-based pitch detection using Web Audio API.
|
||||
* Uses Harmonic Product Spectrum (HPS) for fundamental frequency detection.
|
||||
*/
|
||||
|
||||
import { findFundamentalFromHarmonics } from '../domain/harmonic-analyzer';
|
||||
|
||||
export class FFTPitchDetector {
|
||||
private analyser: AnalyserNode;
|
||||
private sampleRate: number;
|
||||
private frequencyData: Float32Array;
|
||||
private bufferLength: number;
|
||||
|
||||
constructor(analyser: AnalyserNode, sampleRate: number) {
|
||||
this.analyser = analyser;
|
||||
this.sampleRate = sampleRate;
|
||||
this.bufferLength = analyser.frequencyBinCount;
|
||||
this.frequencyData = new Float32Array(this.bufferLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect pitch using FFT frequency analysis.
|
||||
* @returns Detected frequency in Hz, or null if no pitch detected
|
||||
*/
|
||||
detectPitch(): number | null {
|
||||
// Get frequency data from analyser
|
||||
this.analyser.getFloatFrequencyData(this.frequencyData as any);
|
||||
|
||||
// Convert dB to linear magnitude
|
||||
const magnitude = this.convertToMagnitude(this.frequencyData) as Float32Array;
|
||||
|
||||
// Find peak frequencies
|
||||
const peaks = this.findPeaks(magnitude, 5);
|
||||
|
||||
if (peaks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert bin indices to frequencies
|
||||
const frequencies = peaks.map(binIndex => this.binToFrequency(binIndex));
|
||||
|
||||
// Use harmonic analysis to find fundamental
|
||||
const frequency = findFundamentalFromHarmonics(frequencies);
|
||||
|
||||
// Apply Harmonic Product Spectrum for more accuracy
|
||||
const hpsFrequency = this.harmonicProductSpectrum(magnitude);
|
||||
|
||||
// If HPS found a fundamental and it's lower, prefer it
|
||||
if (hpsFrequency && hpsFrequency < frequency) {
|
||||
return hpsFrequency;
|
||||
}
|
||||
|
||||
// Filter unrealistic frequencies (60 Hz to 4000 Hz for musical instruments)
|
||||
if (frequency < 60 || frequency > 4000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return frequency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert frequency data from dB to linear magnitude.
|
||||
*/
|
||||
private convertToMagnitude(dbData: Float32Array): Float32Array {
|
||||
const magnitude = new Float32Array(dbData.length);
|
||||
|
||||
for (let i = 0; i < dbData.length; i++) {
|
||||
// Convert dB to linear: magnitude = 10^(dB/20)
|
||||
magnitude[i] = Math.pow(10, dbData[i] / 20);
|
||||
}
|
||||
|
||||
return magnitude;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find peaks in the magnitude spectrum.
|
||||
*/
|
||||
private findPeaks(magnitude: Float32Array, maxPeaks: number = 5): number[] {
|
||||
const peaks: Array<{ bin: number; magnitude: number }> = [];
|
||||
|
||||
// Find max magnitude for dynamic thresholding
|
||||
let maxMag = 0;
|
||||
for (let i = 0; i < magnitude.length; i++) {
|
||||
if (magnitude[i] > maxMag) {
|
||||
maxMag = magnitude[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Use dynamic threshold (5% of max magnitude)
|
||||
const minMagnitude = maxMag * 0.05;
|
||||
|
||||
// Define frequency range for musical instruments (60 Hz to 4000 Hz)
|
||||
const minFreq = 60; // Lowest note we care about
|
||||
const maxFreq = 4000;
|
||||
const minBin = Math.floor(minFreq * this.bufferLength / (this.sampleRate / 2));
|
||||
const maxBin = Math.floor(maxFreq * this.bufferLength / (this.sampleRate / 2));
|
||||
|
||||
// Find local maxima in the valid frequency range
|
||||
for (let i = Math.max(1, minBin); i < Math.min(magnitude.length - 1, maxBin); i++) {
|
||||
if (magnitude[i] > magnitude[i - 1] &&
|
||||
magnitude[i] > magnitude[i + 1] &&
|
||||
magnitude[i] > minMagnitude) {
|
||||
|
||||
// Use parabolic interpolation for sub-bin accuracy
|
||||
const refinedBin = this.parabolicInterpolation(magnitude, i);
|
||||
peaks.push({ bin: refinedBin, magnitude: magnitude[i] });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by magnitude (descending)
|
||||
peaks.sort((a, b) => b.magnitude - a.magnitude);
|
||||
|
||||
// Return top N peaks
|
||||
return peaks.slice(0, maxPeaks).map(p => p.bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parabolic interpolation for sub-bin frequency accuracy.
|
||||
*/
|
||||
private parabolicInterpolation(magnitude: Float32Array, index: number): number {
|
||||
if (index <= 0 || index >= magnitude.length - 1) {
|
||||
return index;
|
||||
}
|
||||
|
||||
const alpha = magnitude[index - 1];
|
||||
const beta = magnitude[index];
|
||||
const gamma = magnitude[index + 1];
|
||||
|
||||
const p = 0.5 * (alpha - gamma) / (alpha - 2 * beta + gamma);
|
||||
|
||||
return index + p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Harmonic Product Spectrum algorithm for fundamental detection.
|
||||
* Downsamples and multiplies the spectrum to enhance the fundamental.
|
||||
*/
|
||||
private harmonicProductSpectrum(magnitude: Float32Array): number | null {
|
||||
const hpsLength = Math.floor(magnitude.length / 5);
|
||||
const hps = new Float32Array(hpsLength);
|
||||
|
||||
// Initialize HPS with first spectrum
|
||||
for (let i = 0; i < hpsLength; i++) {
|
||||
hps[i] = magnitude[i];
|
||||
}
|
||||
|
||||
// Multiply with downsampled versions (harmonics 2x, 3x, 4x, 5x)
|
||||
for (let harmonic = 2; harmonic <= 5; harmonic++) {
|
||||
for (let i = 0; i < hpsLength; i++) {
|
||||
const bin = i * harmonic;
|
||||
if (bin < magnitude.length) {
|
||||
hps[i] *= magnitude[bin];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the peak in HPS (this is likely the fundamental)
|
||||
let maxBin = 0;
|
||||
let maxValue = hps[0];
|
||||
const minBin = Math.floor(20 * hpsLength / (this.sampleRate / 2)); // Skip very low frequencies
|
||||
|
||||
for (let i = minBin; i < hpsLength; i++) {
|
||||
if (hps[i] > maxValue) {
|
||||
maxValue = hps[i];
|
||||
maxBin = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if peak is strong enough
|
||||
if (maxValue < 0.01) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use parabolic interpolation for accuracy
|
||||
const refinedBin = this.parabolicInterpolation(hps, maxBin);
|
||||
|
||||
return this.binToFrequency(refinedBin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert FFT bin index to frequency in Hz.
|
||||
*/
|
||||
private binToFrequency(bin: number): number {
|
||||
return bin * this.sampleRate / (2 * this.bufferLength);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user