/** * Autocorrelation-based pitch detection * Fast, reliable fallback algorithm for monophonic pitch detection */ import { WindowFunction, SpectralAnalysis } from './dsp'; /** * Autocorrelation configuration */ export interface AutocorrelationConfig { sampleRate?: number; minFrequency?: number; maxFrequency?: number; windowType?: 'hann' | 'hamming' | 'blackman' | 'rectangular'; threshold?: number; usePreEmphasis?: boolean; preEmphasisCoeff?: number; useCenterClipping?: boolean; centerClippingThreshold?: number; } /** * Autocorrelation result */ export interface AutocorrelationResult { frequency: number; confidence: number; lag: number; autocorrelation: Float32Array; peaks: number[]; } /** * Autocorrelation pitch detector */ export class AutocorrelationDetector { private config: Required; private window: Float32Array; constructor(config: AutocorrelationConfig = {}) { this.config = { sampleRate: config.sampleRate ?? 44100, minFrequency: config.minFrequency ?? 50, maxFrequency: config.maxFrequency ?? 2000, windowType: config.windowType ?? 'hann', threshold: config.threshold ?? 0.3, usePreEmphasis: config.usePreEmphasis ?? true, preEmphasisCoeff: config.preEmphasisCoeff ?? 0.97, useCenterClipping: config.useCenterClipping ?? false, centerClippingThreshold: config.centerClippingThreshold ?? 0.3, }; // Create window dynamically based on signal length this.window = WindowFunction.create(2048, this.config.windowType); } /** * Detect pitch using autocorrelation */ detectPitch(signal: Float32Array): AutocorrelationResult | null { // Preprocess signal let processed = this.preprocessSignal(signal); // Compute autocorrelation const autocorrelation = this.computeAutocorrelation(processed); // Find peaks in autocorrelation const peaks = this.findPeaks(autocorrelation); if (peaks.length === 0) { return null; } // Select best peak const bestPeak = this.selectBestPeak(peaks, autocorrelation); if (!bestPeak) { return null; } // Calculate frequency and confidence const frequency = this.config.sampleRate / bestPeak.lag; const confidence = Math.min(1.0, bestPeak.strength); // Cap at 1.0 return { frequency, confidence, lag: bestPeak.lag, autocorrelation, peaks: peaks.map(p => p.lag), }; } /** * Preprocess signal for better autocorrelation */ private preprocessSignal(signal: Float32Array): Float32Array { let processed = new Float32Array(signal); // Apply pre-emphasis filter if (this.config.usePreEmphasis) { processed = new Float32Array(this.applyPreEmphasis(processed)); } // Apply center clipping if (this.config.useCenterClipping) { processed = new Float32Array(this.applyCenterClipping(processed)); } // Apply window - create window for signal length if (processed.length !== this.window.length) { this.window = WindowFunction.create(processed.length, this.config.windowType); } processed = new Float32Array(WindowFunction.apply(processed, this.window)); return processed; } /** * Apply pre-emphasis filter */ private applyPreEmphasis(signal: Float32Array): Float32Array { const filtered = new Float32Array(signal.length); filtered[0] = signal[0]; for (let i = 1; i < signal.length; i++) { filtered[i] = signal[i] - this.config.preEmphasisCoeff * signal[i - 1]; } return filtered as Float32Array; } /** * Apply center clipping */ private applyCenterClipping(signal: Float32Array): Float32Array { const clipped = new Float32Array(signal.length); const threshold = this.config.centerClippingThreshold; for (let i = 0; i < signal.length; i++) { const val = signal[i]; if (val > threshold) { clipped[i] = val - threshold; } else if (val < -threshold) { clipped[i] = val + threshold; } else { clipped[i] = 0; } } return clipped as Float32Array; } /** * Compute autocorrelation function */ private computeAutocorrelation(signal: Float32Array): Float32Array { const n = signal.length; const autocorr = new Float32Array(n); // Calculate autocorrelation for each lag for (let lag = 0; lag < n; lag++) { let sum = 0; let count = 0; for (let i = 0; i < n - lag; i++) { sum += signal[i] * signal[i + lag]; count++; } autocorr[lag] = count > 0 ? sum / count : 0; } // Normalize by the zero-lag autocorrelation (signal energy) const maxValue = autocorr[0]; if (maxValue > 0) { for (let i = 0; i < n; i++) { autocorr[i] /= maxValue; } } return autocorr; } /** * Find peaks in autocorrelation */ private findPeaks(autocorr: Float32Array): Array<{ lag: number; strength: number }> { const minLag = Math.floor(this.config.sampleRate / this.config.maxFrequency); const maxLag = Math.ceil(this.config.sampleRate / this.config.minFrequency); const peaks: Array<{ lag: number; strength: number }> = []; // Find local maxima for (let lag = minLag; lag <= maxLag && lag < autocorr.length; lag++) { if (this.isLocalMaximum(autocorr, lag)) { const strength = autocorr[lag]; if (strength > this.config.threshold) { peaks.push({ lag, strength }); } } } return peaks; } /** * Check if index is a local maximum */ private isLocalMaximum(autocorr: Float32Array, index: number): boolean { if (index <= 0 || index >= autocorr.length - 1) { return false; } const center = autocorr[index]; const left = autocorr[index - 1]; const right = autocorr[index + 1]; return center > left && center > right; } /** * Select best peak from candidates */ private selectBestPeak( peaks: Array<{ lag: number; strength: number }>, autocorr: Float32Array ): { lag: number; strength: number } | null { if (peaks.length === 0) { return null; } // Sort by strength (descending) peaks.sort((a, b) => b.strength - a.strength); // Apply octave error correction const correctedPeaks = this.correctOctaveErrors(peaks, autocorr); return correctedPeaks[0] || null; } /** * Correct octave errors by checking harmonic relationships */ private correctOctaveErrors( peaks: Array<{ lag: number; strength: number }>, autocorr: Float32Array ): Array<{ lag: number; strength: number }> { if (peaks.length <= 1) { return peaks; } const corrected: Array<{ lag: number; strength: number }> = []; const usedLags = new Set(); for (const peak of peaks) { if (usedLags.has(peak.lag)) { continue; } // Check for octave relationships let isOctave = false; for (const otherPeak of peaks) { if (otherPeak.lag === peak.lag) continue; const ratio = Math.max(peak.lag, otherPeak.lag) / Math.min(peak.lag, otherPeak.lag); // Check if ratio is close to 2, 3, 4, etc. (octave relationships) if (Math.abs(ratio - Math.round(ratio)) < 0.1 && Math.round(ratio) >= 2) { // Prefer the fundamental (longer lag) if (peak.lag > otherPeak.lag) { corrected.push(peak); usedLags.add(otherPeak.lag); } else { corrected.push(otherPeak); usedLags.add(peak.lag); } isOctave = true; break; } } if (!isOctave) { corrected.push(peak); usedLags.add(peak.lag); } } return corrected; } /** * Detect pitch with sub-sample accuracy using parabolic interpolation */ detectPitchInterpolated(signal: Float32Array): AutocorrelationResult | null { const result = this.detectPitch(signal); if (!result) { return null; } // Apply parabolic interpolation around the peak const lag = result.lag; const autocorr = result.autocorrelation; if (lag > 0 && lag < autocorr.length - 1) { const alpha = autocorr[lag - 1]; const beta = autocorr[lag]; const gamma = autocorr[lag + 1]; const p = 0.5 * (alpha - gamma) / (alpha - 2 * beta + gamma); const interpolatedLag = lag + p; const interpolatedFreq = this.config.sampleRate / interpolatedLag; return { ...result, frequency: interpolatedFreq, lag: interpolatedLag, }; } return result; } /** * Batch pitch detection */ detectPitchBatch(signals: Float32Array[]): (AutocorrelationResult | null)[] { return signals.map(signal => this.detectPitch(signal)); } /** * Update configuration */ updateConfig(config: Partial): void { this.config = { ...this.config, ...config }; // Recreate window if type changed if (config.windowType && config.windowType !== this.config.windowType) { this.window = WindowFunction.create(2048, this.config.windowType); } } /** * Get current configuration */ getConfig(): Readonly> { return { ...this.config }; } } /** * Quick utility: Detect pitch using autocorrelation */ export function detectPitchAutocorrelation( signal: Float32Array, sampleRate: number = 44100, options?: AutocorrelationConfig ): number | null { const detector = new AutocorrelationDetector({ sampleRate, ...options, }); const result = detector.detectPitch(signal); return result ? result.frequency : null; } /** * Autocorrelation utilities */ export const AutocorrelationUtils = { /** * Calculate pitch confidence from autocorrelation strength */ calculateConfidence(strength: number, threshold: number = 0.3): number { return Math.max(0, Math.min(1, (strength - threshold) / (1 - threshold))); }, /** * Check if autocorrelation result is reliable */ isReliable(result: AutocorrelationResult, minConfidence: number = 0.5): boolean { return result.confidence >= minConfidence; }, /** * Convert lag to frequency */ lagToFrequency(lag: number, sampleRate: number): number { return sampleRate / lag; }, /** * Convert frequency to lag */ frequencyToLag(frequency: number, sampleRate: number): number { return Math.round(sampleRate / frequency); }, /** * Find fundamental frequency from harmonic series */ findFundamentalFromHarmonics(peaks: number[], sampleRate: number): number | null { if (peaks.length === 0) { return null; } // Sort peaks by frequency const frequencies = peaks.map(peak => sampleRate / peak).sort((a, b) => a - b); // Find the lowest frequency that has strong harmonics for (let i = 0; i < frequencies.length; i++) { const fundamental = frequencies[i]; let harmonicCount = 0; // Check for harmonics (2f, 3f, 4f, etc.) for (let h = 2; h <= 8; h++) { const harmonic = fundamental * h; const tolerance = fundamental * 0.05; // 5% tolerance if (frequencies.some(f => Math.abs(f - harmonic) < tolerance)) { harmonicCount++; } } // If we found at least 2 harmonics, this is likely the fundamental if (harmonicCount >= 2) { return fundamental; } } // Fall back to lowest frequency return frequencies[0]; }, };