import { describe, it, expect, beforeEach } from 'vitest'; import { PeakDetector, findFundamental, type Peak } from './peak-detection'; /** * Generate a sine wave */ function generateSineWave( frequency: number, sampleRate: number, duration: number ): Float32Array { const numSamples = Math.floor(duration * sampleRate); const signal = new Float32Array(numSamples); for (let i = 0; i < numSamples; i++) { signal[i] = Math.sin((2 * Math.PI * frequency * i) / sampleRate); } return signal; } /** * Generate a complex tone with harmonics */ function generateComplexTone( fundamental: number, harmonics: number[], sampleRate: number, duration: number ): Float32Array { const numSamples = Math.floor(duration * sampleRate); const signal = new Float32Array(numSamples); // Add fundamental for (let i = 0; i < numSamples; i++) { signal[i] = Math.sin((2 * Math.PI * fundamental * i) / sampleRate); } // Add harmonics for (const harmonic of harmonics) { for (let i = 0; i < numSamples; i++) { signal[i] += 0.5 * Math.sin((2 * Math.PI * fundamental * harmonic * i) / sampleRate); } } // Normalize const max = Math.max(...signal.map(Math.abs)); for (let i = 0; i < numSamples; i++) { signal[i] /= max; } return signal; } describe('PeakDetector', () => { let detector: PeakDetector; const sampleRate = 44100; beforeEach(() => { detector = new PeakDetector({ sampleRate, fftSize: 4096 }); }); describe('constructor', () => { it('should create detector with default config', () => { const config = detector.getConfig(); expect(config.sampleRate).toBe(44100); expect(config.fftSize).toBe(4096); expect(config.windowType).toBe('hann'); expect(config.threshold).toBe(0.3); }); it('should accept custom config', () => { const customDetector = new PeakDetector({ sampleRate: 48000, fftSize: 2048, windowType: 'hamming', threshold: 0.5, }); const config = customDetector.getConfig(); expect(config.sampleRate).toBe(48000); expect(config.fftSize).toBe(2048); expect(config.windowType).toBe('hamming'); expect(config.threshold).toBe(0.5); }); }); describe('findPeaks', () => { it('should find peak for single sine wave', () => { const frequency = 440; // A4 const signal = generateSineWave(frequency, sampleRate, 0.1); const peaks = detector.findPeaks(signal); expect(peaks.length).toBeGreaterThan(0); // The strongest peak should be near 440 Hz const strongestPeak = peaks[0]; expect(strongestPeak.frequency).toBeGreaterThan(400); expect(strongestPeak.frequency).toBeLessThan(480); }); it('should find multiple peaks for complex tone', () => { const fundamental = 220; // A3 const signal = generateComplexTone(fundamental, [2, 3, 4], sampleRate, 0.1); const peaks = detector.findPeaks(signal); expect(peaks.length).toBeGreaterThan(1); }); it('should use interpolation when enabled', () => { const signal = generateSineWave(440, sampleRate, 0.1); const peaks = detector.findPeaks(signal); const peak = peaks[0]; expect(peak.interpolatedFrequency).toBeDefined(); expect(peak.interpolatedBin).toBeDefined(); }); it('should respect frequency range', () => { const customDetector = new PeakDetector({ sampleRate, minFrequency: 400, maxFrequency: 500, }); const signal = generateSineWave(440, sampleRate, 0.1); const peaks = customDetector.findPeaks(signal); expect(peaks.length).toBeGreaterThan(0); expect(peaks[0].frequency).toBeGreaterThanOrEqual(400); expect(peaks[0].frequency).toBeLessThanOrEqual(500); }); it('should limit number of peaks', () => { const customDetector = new PeakDetector({ sampleRate, maxPeaks: 3, }); const signal = generateComplexTone(220, [2, 3, 4, 5], sampleRate, 0.1); const peaks = customDetector.findPeaks(signal); expect(peaks.length).toBeLessThanOrEqual(3); }); }); describe('findFundamentalHPS', () => { it('should find fundamental using HPS', () => { const fundamental = 440; const signal = generateComplexTone(fundamental, [2, 3, 4], sampleRate, 0.1); const peak = detector.findFundamentalHPS(signal); expect(peak).not.toBeNull(); expect(peak!.frequency).toBeGreaterThan(400); expect(peak!.frequency).toBeLessThan(480); }); it('should return interpolated frequency', () => { const signal = generateSineWave(440, sampleRate, 0.1); const peak = detector.findFundamentalHPS(signal); expect(peak).not.toBeNull(); expect(peak!.interpolatedFrequency).toBeDefined(); }); it('should handle noise gracefully', () => { const signal = new Float32Array(4096); for (let i = 0; i < signal.length; i++) { signal[i] = (Math.random() * 2 - 1) * 0.1; // Low amplitude noise } const peak = detector.findFundamentalHPS(signal); // HPS might find a spurious peak in noise, which is expected behavior // We just verify it doesn't crash expect(peak !== null || peak === null).toBe(true); }); }); describe('findFundamentalACF', () => { it('should find fundamental using autocorrelation', () => { const fundamental = 440; const signal = generateSineWave(fundamental, sampleRate, 0.1); const peak = detector.findFundamentalACF(signal); expect(peak).not.toBeNull(); expect(peak!.frequency).toBeGreaterThan(400); expect(peak!.frequency).toBeLessThan(480); }); it('should work with complex tones', () => { const fundamental = 220; const signal = generateComplexTone(fundamental, [2, 3], sampleRate, 0.1); const peak = detector.findFundamentalACF(signal); expect(peak).not.toBeNull(); // ACF should find the fundamental period expect(peak!.frequency).toBeGreaterThan(200); expect(peak!.frequency).toBeLessThan(240); }); }); describe('isVoiced', () => { it('should detect periodic signal as voiced', () => { const signal = generateSineWave(440, sampleRate, 0.1); const voiced = detector.isVoiced(signal); expect(voiced).toBe(true); }); it('should detect noise as unvoiced', () => { const signal = new Float32Array(4096); for (let i = 0; i < signal.length; i++) { signal[i] = Math.random() * 2 - 1; } const voiced = detector.isVoiced(signal); expect(voiced).toBe(false); }); it('should respect threshold parameter', () => { const signal = generateSineWave(440, sampleRate, 0.1); // With very high threshold, even periodic signal might not pass const strictVoiced = detector.isVoiced(signal, 0.99); // With low threshold, it should pass const lenientVoiced = detector.isVoiced(signal, 0.1); expect(lenientVoiced).toBe(true); }); }); describe('getHarmonics', () => { it('should generate harmonic series', () => { const fundamental: Peak = { bin: 10, frequency: 220, magnitude: 1.0, }; const harmonics = detector.getHarmonics(fundamental, 3); expect(harmonics.length).toBe(3); expect(harmonics[0].frequency).toBe(220); // Fundamental expect(harmonics[1].frequency).toBe(440); // 2nd harmonic expect(harmonics[2].frequency).toBe(660); // 3rd harmonic }); it('should stop at max frequency', () => { const customDetector = new PeakDetector({ sampleRate, maxFrequency: 500, }); const fundamental: Peak = { bin: 10, frequency: 200, magnitude: 1.0, }; const harmonics = customDetector.getHarmonics(fundamental, 10); // Should stop when harmonics exceed 500 Hz expect(harmonics.length).toBeLessThan(10); expect(harmonics[harmonics.length - 1].frequency).toBeLessThanOrEqual(500); }); }); describe('updateConfig', () => { it('should update configuration', () => { detector.updateConfig({ threshold: 0.5, minFrequency: 100, }); const config = detector.getConfig(); expect(config.threshold).toBe(0.5); expect(config.minFrequency).toBe(100); }); it('should recreate FFT when size changes', () => { detector.updateConfig({ fftSize: 2048 }); const config = detector.getConfig(); expect(config.fftSize).toBe(2048); }); it('should recreate window when type changes', () => { detector.updateConfig({ windowType: 'blackman' }); const config = detector.getConfig(); expect(config.windowType).toBe('blackman'); }); }); }); describe('findFundamental utility', () => { const sampleRate = 44100; it('should find fundamental using HPS method', () => { // HPS works better with harmonic-rich signals const signal = generateComplexTone(440, [2, 3], sampleRate, 0.2); const frequency = findFundamental(signal, sampleRate, 'hps'); expect(frequency).not.toBeNull(); // Allow wider tolerance due to HPS characteristics expect(frequency!).toBeGreaterThan(400); expect(frequency!).toBeLessThan(500); }); it('should find fundamental using ACF method', () => { const signal = generateSineWave(440, sampleRate, 0.1); const frequency = findFundamental(signal, sampleRate, 'acf'); expect(frequency).not.toBeNull(); expect(frequency!).toBeGreaterThan(400); expect(frequency!).toBeLessThan(480); }); it('should return null for silent signal', () => { const signal = new Float32Array(4096); // All zeros const frequency = findFundamental(signal, sampleRate); expect(frequency).toBeNull(); }); });