/** * Peak detection for pitch estimation * Builds on DSP utilities to find fundamental frequencies */ import { FFT, WindowFunction, SpectralAnalysis, type WindowType } from './dsp'; /** * Detected peak with frequency and strength */ export interface Peak { bin: number; frequency: number; magnitude: number; interpolatedBin?: number; interpolatedFrequency?: number; } /** * Peak detection configuration */ export interface PeakDetectionConfig { sampleRate?: number; fftSize?: number; windowType?: WindowType; threshold?: number; // Relative to max peak (0-1) minFrequency?: number; // Hz maxFrequency?: number; // Hz maxPeaks?: number; useInterpolation?: boolean; } /** * Peak detector for finding dominant frequencies */ export class PeakDetector { private config: Required; private fft: FFT; private window: Float32Array; constructor(config: PeakDetectionConfig = {}) { this.config = { sampleRate: config.sampleRate ?? 44100, fftSize: config.fftSize ?? 4096, windowType: config.windowType ?? 'hann', threshold: config.threshold ?? 0.3, minFrequency: config.minFrequency ?? 50, maxFrequency: config.maxFrequency ?? 2000, maxPeaks: config.maxPeaks ?? 10, useInterpolation: config.useInterpolation ?? true, }; this.fft = new FFT(this.config.fftSize); this.window = WindowFunction.create(this.config.fftSize, this.config.windowType); } /** * Find peaks in audio signal */ findPeaks(signal: Float32Array): Peak[] { // Ensure signal matches FFT size (pad or truncate) const paddedSignal = this.ensureCorrectSize(signal); // Apply window const windowed = WindowFunction.apply(paddedSignal, this.window); // Compute FFT const result = this.fft.compute(windowed, this.config.sampleRate); // Only use positive frequencies (first half of spectrum) const halfSize = this.config.fftSize / 2; const magnitude = result.magnitude.slice(0, halfSize); const frequencies = result.frequencies.slice(0, halfSize); // Convert frequency range to bin range const minBin = this.frequencyToBin(this.config.minFrequency); const maxBin = this.frequencyToBin(this.config.maxFrequency); // Find peaks in spectrum const peakIndices = SpectralAnalysis.findPeaks( magnitude.slice(minBin, maxBin + 1), this.config.threshold, 2 // minimum distance between peaks ).map(idx => idx + minBin); // Offset by minBin // Convert to Peak objects and sort by magnitude const peaks: Peak[] = peakIndices.map(bin => { const peak: Peak = { bin, frequency: frequencies[bin], magnitude: magnitude[bin], }; // Apply parabolic interpolation for better accuracy if (this.config.useInterpolation) { peak.interpolatedBin = SpectralAnalysis.parabolicInterpolation(magnitude, bin); peak.interpolatedFrequency = SpectralAnalysis.binToFrequency( peak.interpolatedBin, this.config.sampleRate, this.config.fftSize ); } return peak; }); // Sort by magnitude (strongest first) and limit to maxPeaks peaks.sort((a, b) => b.magnitude - a.magnitude); return peaks.slice(0, this.config.maxPeaks); } /** * Find fundamental frequency using HPS (Harmonic Product Spectrum) */ findFundamentalHPS(signal: Float32Array, harmonics: number = 5): Peak | null { // Ensure signal matches FFT size (pad or truncate) const paddedSignal = this.ensureCorrectSize(signal); // Apply window const windowed = WindowFunction.apply(paddedSignal, this.window); // Compute FFT const result = this.fft.compute(windowed, this.config.sampleRate); // Only use positive frequencies const halfSize = this.config.fftSize / 2; const magnitude = result.magnitude.slice(0, halfSize); // Apply Harmonic Product Spectrum const hps = SpectralAnalysis.harmonicProductSpectrum(magnitude, harmonics); // Find peaks in HPS const minBin = this.frequencyToBin(this.config.minFrequency); const maxBin = this.frequencyToBin(this.config.maxFrequency); const peakIndices = SpectralAnalysis.findPeaks( hps.slice(minBin, maxBin + 1), this.config.threshold, 3 ).map(idx => idx + minBin); if (peakIndices.length === 0) { return null; } // Get the strongest peak let maxMag = 0; let maxIdx = peakIndices[0]; for (const idx of peakIndices) { if (hps[idx] > maxMag) { maxMag = hps[idx]; maxIdx = idx; } } const peak: Peak = { bin: maxIdx, frequency: SpectralAnalysis.binToFrequency(maxIdx, this.config.sampleRate, this.config.fftSize), magnitude: maxMag, }; // Interpolate if (this.config.useInterpolation) { peak.interpolatedBin = SpectralAnalysis.parabolicInterpolation(hps, maxIdx); peak.interpolatedFrequency = SpectralAnalysis.binToFrequency( peak.interpolatedBin, this.config.sampleRate, this.config.fftSize ); } return peak; } /** * Find fundamental frequency using autocorrelation */ findFundamentalACF(signal: Float32Array): Peak | null { const acf = SpectralAnalysis.autocorrelation(signal); // Find first valley (where ACF drops below threshold) const threshold = 0.5; let valleyIndex = 1; for (let i = 1; i < acf.length / 2; i++) { if (acf[i] < threshold * acf[0]) { valleyIndex = i; break; } } // Find maximum after valley within frequency range const minLag = Math.floor(this.config.sampleRate / this.config.maxFrequency); const maxLag = Math.ceil(this.config.sampleRate / this.config.minFrequency); const searchStart = Math.max(valleyIndex, minLag); const searchEnd = Math.min(acf.length - 1, maxLag); let maxCorr = 0; let bestLag = searchStart; for (let lag = searchStart; lag <= searchEnd; lag++) { if (acf[lag] > maxCorr) { maxCorr = acf[lag]; bestLag = lag; } } if (maxCorr < this.config.threshold * acf[0]) { return null; } const frequency = this.config.sampleRate / bestLag; return { bin: bestLag, frequency, magnitude: maxCorr / acf[0], // Normalized correlation }; } /** * Detect if signal is likely voiced (periodic) or unvoiced (noise) */ isVoiced(signal: Float32Array, threshold: number = 0.3): boolean { const acf = SpectralAnalysis.autocorrelation(signal); // Skip the zero-lag peak and find the next significant peak let maxCorr = 0; for (let i = 10; i < acf.length / 2; i++) { if (acf[i] > maxCorr) { maxCorr = acf[i]; } } // Normalize by zero-lag value const normalizedMax = maxCorr / acf[0]; return normalizedMax > threshold; } /** * Get harmonics of a fundamental frequency */ getHarmonics(fundamental: Peak, numHarmonics: number = 5): Peak[] { const harmonics: Peak[] = [fundamental]; for (let h = 2; h <= numHarmonics; h++) { const harmonicFreq = fundamental.frequency * h; if (harmonicFreq > this.config.maxFrequency) { break; } harmonics.push({ bin: this.frequencyToBin(harmonicFreq), frequency: harmonicFreq, magnitude: 0, // Would need to look up actual magnitude }); } return harmonics; } /** * Update configuration */ updateConfig(config: Partial): void { this.config = { ...this.config, ...config }; // Recreate FFT if size changed if (config.fftSize && config.fftSize !== this.fft['size']) { this.fft = new FFT(config.fftSize); this.window = WindowFunction.create(this.config.fftSize, this.config.windowType); } // Recreate window if type changed if (config.windowType && config.windowType !== this.config.windowType) { this.window = WindowFunction.create(this.config.fftSize, this.config.windowType); } } /** * Get current configuration */ getConfig(): Readonly> { return { ...this.config }; } private frequencyToBin(frequency: number): number { return SpectralAnalysis.frequencyToBin(frequency, this.config.sampleRate, this.config.fftSize); } /** * Ensure signal is the correct size for FFT (pad or truncate) */ private ensureCorrectSize(signal: Float32Array): Float32Array { if (signal.length === this.config.fftSize) { return signal; } const result = new Float32Array(this.config.fftSize); if (signal.length > this.config.fftSize) { // Truncate result.set(signal.slice(0, this.config.fftSize)); } else { // Zero-pad result.set(signal); } return result; } } /** * Quick utility: Find fundamental frequency in a signal */ export function findFundamental( signal: Float32Array, sampleRate: number = 44100, method: 'hps' | 'acf' = 'hps' ): number | null { const detector = new PeakDetector({ sampleRate }); const peak = method === 'hps' ? detector.findFundamentalHPS(signal) : detector.findFundamentalACF(signal); if (!peak) { return null; } return peak.interpolatedFrequency ?? peak.frequency; }