import { describe, it, expect } from 'vitest'; import { FFT, WindowFunction, SpectralAnalysis, nextPowerOfTwo, isPowerOfTwo, type WindowType, } from './dsp'; describe('FFT', () => { describe('constructor', () => { it('should create FFT with power-of-2 size', () => { expect(() => new FFT(256)).not.toThrow(); expect(() => new FFT(512)).not.toThrow(); expect(() => new FFT(1024)).not.toThrow(); }); it('should throw for non-power-of-2 size', () => { expect(() => new FFT(100)).toThrow('power of 2'); expect(() => new FFT(500)).toThrow('power of 2'); }); }); describe('forward FFT', () => { it('should transform DC signal', () => { const fft = new FFT(8); const input = new Float32Array([1, 1, 1, 1, 1, 1, 1, 1]); const output = fft.forward(input); // DC component should be sum of all samples expect(output[0].real).toBeCloseTo(8); expect(output[0].imag).toBeCloseTo(0); // All other bins should be near zero for (let i = 1; i < output.length; i++) { expect(Math.abs(output[i].real)).toBeLessThan(1e-10); expect(Math.abs(output[i].imag)).toBeLessThan(1e-10); } }); it('should transform sine wave to correct frequency bin', () => { const fft = new FFT(8); // Create a sine wave at bin 1 (frequency = sampleRate / 8) const input = new Float32Array(8); for (let i = 0; i < 8; i++) { input[i] = Math.sin((2 * Math.PI * i) / 8); } const output = fft.forward(input); // Energy should be concentrated in bin 1 (and 7 for negative frequency) expect(Math.abs(output[1].imag)).toBeGreaterThan(3); expect(Math.abs(output[7].imag)).toBeGreaterThan(3); }); it('should throw for wrong input size', () => { const fft = new FFT(256); const input = new Float32Array(128); expect(() => fft.forward(input)).toThrow('Input size must be'); }); }); describe('getMagnitude', () => { it('should compute magnitude correctly', () => { const fft = new FFT(4); const complex = [ { real: 3, imag: 4 }, { real: 0, imag: 0 }, { real: 5, imag: 12 }, { real: 1, imag: 0 }, ]; const magnitude = fft.getMagnitude(complex); expect(magnitude[0]).toBeCloseTo(5); // sqrt(3^2 + 4^2) expect(magnitude[1]).toBeCloseTo(0); expect(magnitude[2]).toBeCloseTo(13); // sqrt(5^2 + 12^2) expect(magnitude[3]).toBeCloseTo(1); }); }); describe('getPhase', () => { it('should compute phase correctly', () => { const fft = new FFT(4); const complex = [ { real: 1, imag: 1 }, { real: 1, imag: 0 }, { real: 0, imag: 1 }, { real: -1, imag: 0 }, ]; const phase = fft.getPhase(complex); expect(phase[0]).toBeCloseTo(Math.PI / 4); expect(phase[1]).toBeCloseTo(0); expect(phase[2]).toBeCloseTo(Math.PI / 2); expect(phase[3]).toBeCloseTo(Math.PI); }); }); describe('compute', () => { it('should compute full FFT result', () => { const fft = new FFT(256); const sampleRate = 44100; const input = new Float32Array(256); // Generate a 440 Hz sine wave for (let i = 0; i < 256; i++) { input[i] = Math.sin((2 * Math.PI * 440 * i) / sampleRate); } const result = fft.compute(input, sampleRate); expect(result.magnitude).toBeInstanceOf(Float32Array); expect(result.phase).toBeInstanceOf(Float32Array); expect(result.frequencies).toBeInstanceOf(Float32Array); expect(result.magnitude.length).toBe(256); expect(result.frequencies[1]).toBeCloseTo(sampleRate / 256); }); }); }); describe('WindowFunction', () => { const size = 256; describe('create', () => { it('should create window of specified type', () => { const types: WindowType[] = ['hann', 'hamming', 'blackman', 'bartlett', 'rectangular']; types.forEach(type => { const window = WindowFunction.create(size, type); expect(window).toBeInstanceOf(Float32Array); expect(window.length).toBe(size); }); }); it('should throw for unknown window type', () => { expect(() => WindowFunction.create(size, 'unknown' as WindowType)).toThrow('Unknown window type'); }); }); describe('hann', () => { it('should create Hann window', () => { const window = WindowFunction.hann(size); // Hann window should start and end near 0 expect(window[0]).toBeCloseTo(0); expect(window[size - 1]).toBeCloseTo(0); // Peak should be at center expect(window[size / 2]).toBeCloseTo(1); }); }); describe('hamming', () => { it('should create Hamming window', () => { const window = WindowFunction.hamming(size); // Hamming window should start and end at 0.08 expect(window[0]).toBeCloseTo(0.08, 2); expect(window[size - 1]).toBeCloseTo(0.08, 2); // Peak should be at center and close to 1 expect(window[size / 2]).toBeCloseTo(1, 1); }); }); describe('blackman', () => { it('should create Blackman window', () => { const window = WindowFunction.blackman(size); // Blackman window should start and end near 0 expect(window[0]).toBeLessThan(0.01); expect(window[size - 1]).toBeLessThan(0.01); // Peak should be at center expect(window[size / 2]).toBeCloseTo(1, 1); }); }); describe('bartlett', () => { it('should create Bartlett window', () => { const window = WindowFunction.bartlett(size); // Bartlett (triangular) window expect(window[0]).toBeCloseTo(0); expect(window[size - 1]).toBeCloseTo(0); expect(window[size / 2]).toBeCloseTo(1); }); }); describe('rectangular', () => { it('should create rectangular window', () => { const window = WindowFunction.rectangular(size); // All values should be 1 expect(window.every(v => v === 1)).toBe(true); }); }); describe('apply', () => { it('should apply window to signal', () => { const signal = new Float32Array(10).fill(1); const window = new Float32Array(10).fill(0.5); const windowed = WindowFunction.apply(signal, window); expect(windowed.every(v => v === 0.5)).toBe(true); }); it('should throw if sizes dont match', () => { const signal = new Float32Array(10); const window = new Float32Array(5); expect(() => WindowFunction.apply(signal, window)).toThrow('same length'); }); }); }); describe('SpectralAnalysis', () => { describe('findPeaks', () => { it('should find spectral peaks', () => { const spectrum = new Float32Array([ 0.1, 0.2, 0.8, 0.3, 0.1, // Peak at index 2 0.2, 0.4, 0.9, 0.5, 0.1, // Peak at index 7 ]); const peaks = SpectralAnalysis.findPeaks(spectrum, 0.5, 1); expect(peaks).toContain(2); expect(peaks).toContain(7); }); it('should respect threshold', () => { const spectrum = new Float32Array([0.1, 0.5, 0.2, 1.0, 0.3]); const peaks = SpectralAnalysis.findPeaks(spectrum, 0.8, 1); // Only peak at 3 (value 1.0) should be above 80% threshold expect(peaks).toEqual([3]); }); }); describe('parabolicInterpolation', () => { it('should interpolate peak position', () => { const spectrum = new Float32Array([0.5, 0.8, 1.0, 0.7, 0.3]); const refinedPeak = SpectralAnalysis.parabolicInterpolation(spectrum, 2); // Should be close to 2 but slightly adjusted expect(refinedPeak).toBeGreaterThan(1.5); expect(refinedPeak).toBeLessThan(2.5); }); it('should handle edge cases', () => { const spectrum = new Float32Array([1.0, 0.5, 0.3]); // Can't interpolate at edges expect(SpectralAnalysis.parabolicInterpolation(spectrum, 0)).toBe(0); expect(SpectralAnalysis.parabolicInterpolation(spectrum, 2)).toBe(2); }); }); describe('frequency conversion', () => { it('should convert bin to frequency', () => { const freq = SpectralAnalysis.binToFrequency(10, 44100, 1024); expect(freq).toBeCloseTo(430.66, 2); }); it('should convert frequency to bin', () => { const bin = SpectralAnalysis.frequencyToBin(440, 44100, 1024); expect(bin).toBeCloseTo(10, 0); }); }); describe('spectralCentroid', () => { it('should compute spectral centroid', () => { const magnitude = new Float32Array([0, 0, 1, 0, 0]); const frequencies = new Float32Array([0, 100, 200, 300, 400]); const centroid = SpectralAnalysis.spectralCentroid(magnitude, frequencies); expect(centroid).toBe(200); }); it('should handle zero magnitude', () => { const magnitude = new Float32Array([0, 0, 0]); const frequencies = new Float32Array([100, 200, 300]); const centroid = SpectralAnalysis.spectralCentroid(magnitude, frequencies); expect(centroid).toBe(0); }); }); describe('spectralFlux', () => { it('should compute spectral flux', () => { const current = new Float32Array([1, 2, 3]); const previous = new Float32Array([1, 1, 1]); const flux = SpectralAnalysis.spectralFlux(current, previous); // sqrt(0^2 + 1^2 + 2^2) = sqrt(5) expect(flux).toBeCloseTo(Math.sqrt(5)); }); it('should throw if spectra have different lengths', () => { const current = new Float32Array(10); const previous = new Float32Array(5); expect(() => SpectralAnalysis.spectralFlux(current, previous)).toThrow('same length'); }); }); describe('zeroPad', () => { it('should zero-pad to next power of 2', () => { const signal = new Float32Array([1, 2, 3, 4, 5]); const padded = SpectralAnalysis.zeroPad(signal); expect(padded.length).toBe(8); // Next power of 2 expect(padded[0]).toBe(1); expect(padded[4]).toBe(5); expect(padded[5]).toBe(0); }); it('should pad to specified size', () => { const signal = new Float32Array([1, 2, 3]); const padded = SpectralAnalysis.zeroPad(signal, 16); expect(padded.length).toBe(16); }); }); describe('harmonicProductSpectrum', () => { it('should compute HPS', () => { const spectrum = new Float32Array(10).fill(1); const hps = SpectralAnalysis.harmonicProductSpectrum(spectrum, 3); expect(hps).toBeInstanceOf(Float32Array); expect(hps.length).toBe(10); }); }); describe('autocorrelation', () => { it('should compute autocorrelation', () => { const signal = new Float32Array([1, 2, 1, 2, 1, 2]); const acf = SpectralAnalysis.autocorrelation(signal); expect(acf).toBeInstanceOf(Float32Array); expect(acf.length).toBe(signal.length); expect(acf[0]).toBeGreaterThan(0); // Zero-lag should be highest }); }); }); describe('Helper functions', () => { describe('nextPowerOfTwo', () => { it('should return next power of 2', () => { expect(nextPowerOfTwo(1)).toBe(1); expect(nextPowerOfTwo(2)).toBe(2); expect(nextPowerOfTwo(3)).toBe(4); expect(nextPowerOfTwo(5)).toBe(8); expect(nextPowerOfTwo(100)).toBe(128); expect(nextPowerOfTwo(1000)).toBe(1024); }); }); describe('isPowerOfTwo', () => { it('should check if number is power of 2', () => { expect(isPowerOfTwo(1)).toBe(true); expect(isPowerOfTwo(2)).toBe(true); expect(isPowerOfTwo(4)).toBe(true); expect(isPowerOfTwo(256)).toBe(true); expect(isPowerOfTwo(3)).toBe(false); expect(isPowerOfTwo(100)).toBe(false); expect(isPowerOfTwo(0)).toBe(false); }); }); });