import { describe, it, expect, beforeEach, vi } from 'vitest'; import { PitchDetector, frequencyToMidi, midiToFrequency, midiToNoteName, type PitchDetectionOptions } from './index'; // Mock Web Audio API const mockAudioContext = { state: 'running', sampleRate: 44100, resume: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), createAnalyser: vi.fn(() => ({ fftSize: 2048, smoothingTimeConstant: 0, connect: vi.fn(), disconnect: vi.fn(), getFloatTimeDomainData: vi.fn(), })), createMediaStreamSource: vi.fn(() => ({ connect: vi.fn(), disconnect: vi.fn(), })), createBuffer: vi.fn((channels, length, sampleRate) => ({ numberOfChannels: channels, length, sampleRate, duration: length / sampleRate, getChannelData: vi.fn(() => new Float32Array(length)), copyToChannel: vi.fn(), })), decodeAudioData: vi.fn().mockResolvedValue({ numberOfChannels: 1, length: 44100, sampleRate: 44100, duration: 1, getChannelData: vi.fn(() => new Float32Array(44100)), }), }; global.AudioContext = vi.fn(() => mockAudioContext) as any; global.OfflineAudioContext = vi.fn(() => ({ ...mockAudioContext, destination: {}, createBufferSource: vi.fn(() => ({ buffer: null, connect: vi.fn(), start: vi.fn(), })), startRendering: vi.fn().mockResolvedValue({ numberOfChannels: 1, length: 48000, sampleRate: 48000, duration: 1, getChannelData: vi.fn(() => new Float32Array(48000)), }), })) as any; describe('PitchDetector', () => { let detector: PitchDetector; beforeEach(() => { detector = new PitchDetector(); }); describe('constructor', () => { it('should create instance with default options', () => { const options = detector.getOptions(); expect(options.sampleRate).toBe(44100); expect(options.frameSize).toBe(2048); expect(options.hopSize).toBe(1024); expect(options.maxPolyphony).toBe(4); expect(options.confidenceThreshold).toBe(0.7); }); it('should accept custom options', () => { const customDetector = new PitchDetector({ sampleRate: 48000, frameSize: 4096, maxPolyphony: 6, }); const options = customDetector.getOptions(); expect(options.sampleRate).toBe(48000); expect(options.frameSize).toBe(4096); expect(options.maxPolyphony).toBe(6); }); }); describe('initialize', () => { it('should initialize successfully', async () => { await expect(detector.initialize()).resolves.not.toThrow(); }); it('should allow multiple initialize calls', async () => { await detector.initialize(); await expect(detector.initialize()).resolves.not.toThrow(); }); }); describe('processFrame', () => { it('should throw if not initialized', async () => { const frame = new Float32Array(2048); await expect(detector.processFrame(frame)).rejects.toThrow('must be initialized'); }); it('should throw if frame size is incorrect', async () => { await detector.initialize(); const wrongFrame = new Float32Array(1024); await expect(detector.processFrame(wrongFrame)).rejects.toThrow('Frame size must be'); }); it('should accept correct frame size', async () => { await detector.initialize(); const frame = new Float32Array(2048); const result = await detector.processFrame(frame); expect(Array.isArray(result)).toBe(true); }); it('should process silent frame', async () => { await detector.initialize(); const silentFrame = new Float32Array(2048); // All zeros const result = await detector.processFrame(silentFrame); expect(Array.isArray(result)).toBe(true); }); it('should process noisy frame', async () => { await detector.initialize(); const noisyFrame = new Float32Array(2048); for (let i = 0; i < noisyFrame.length; i++) { noisyFrame[i] = (Math.random() - 0.5) * 0.1; // Random noise } const result = await detector.processFrame(noisyFrame); expect(Array.isArray(result)).toBe(true); }); }); describe('detection methods', () => { beforeEach(async () => { await detector.initialize(); }); it('should use harmonic analysis by default', async () => { const detector = new PitchDetector({ useHarmonicAnalysis: true, usePitchTracking: false, }); await detector.initialize(); const frame = new Float32Array(2048); const result = await detector.processFrame(frame); expect(Array.isArray(result)).toBe(true); }); it('should use pitch tracking when enabled', async () => { const detector = new PitchDetector({ useHarmonicAnalysis: true, usePitchTracking: true, }); await detector.initialize(); const frame = new Float32Array(2048); const result = await detector.processFrame(frame); expect(Array.isArray(result)).toBe(true); }); it('should fall back to autocorrelation when harmonic analysis fails', async () => { const detector = new PitchDetector({ useHarmonicAnalysis: true, usePitchTracking: false, }); await detector.initialize(); const frame = new Float32Array(2048); const result = await detector.processFrame(frame); expect(Array.isArray(result)).toBe(true); }); it('should work with different confidence thresholds', async () => { const lowThresholdDetector = new PitchDetector({ confidenceThreshold: 0.1, }); await lowThresholdDetector.initialize(); const frame = new Float32Array(2048); const result = await lowThresholdDetector.processFrame(frame); expect(Array.isArray(result)).toBe(true); }); }); describe('pitch tracking integration', () => { let detector: PitchDetector; beforeEach(async () => { detector = new PitchDetector({ usePitchTracking: true, useHarmonicAnalysis: true, }); await detector.initialize(); }); it('should provide pitch tracking state', () => { const state = detector.getPitchTrackingState(); expect(state).toBeDefined(); expect(state.isInitialized).toBe(false); expect(state.history).toEqual([]); }); it('should reset pitch tracking', () => { detector.resetPitchTracking(); const state = detector.getPitchTrackingState(); expect(state.isInitialized).toBe(false); expect(state.history).toEqual([]); }); it('should track pitch over multiple frames', async () => { const frame = new Float32Array(2048); // Process multiple frames for (let i = 0; i < 5; i++) { const result = await detector.processFrame(frame); expect(Array.isArray(result)).toBe(true); } const state = detector.getPitchTrackingState(); expect(state.currentFrame).toBeGreaterThanOrEqual(0); }); }); describe('configuration options', () => { it('should work with different sample rates', async () => { const detector = new PitchDetector({ sampleRate: 48000, frameSize: 2048, }); await detector.initialize(); const frame = new Float32Array(2048); const result = await detector.processFrame(frame); expect(Array.isArray(result)).toBe(true); }); it('should work with different frame sizes', async () => { const detector = new PitchDetector({ frameSize: 4096, hopSize: 2048, }); await detector.initialize(); const frame = new Float32Array(4096); const result = await detector.processFrame(frame); expect(Array.isArray(result)).toBe(true); }); it('should work with different hop sizes', async () => { const detector = new PitchDetector({ frameSize: 2048, hopSize: 512, }); await detector.initialize(); const frame = new Float32Array(2048); const result = await detector.processFrame(frame); expect(Array.isArray(result)).toBe(true); }); it('should work with different max polyphony', async () => { const detector = new PitchDetector({ maxPolyphony: 6, }); await detector.initialize(); const frame = new Float32Array(2048); const result = await detector.processFrame(frame); expect(Array.isArray(result)).toBe(true); }); it('should work with worklet enabled', async () => { // Mock audioWorklet and AudioWorkletNode for this test const mockAudioWorklet = { addModule: vi.fn().mockResolvedValue(undefined), }; const mockAudioWorkletNode = vi.fn(() => ({ connect: vi.fn(), disconnect: vi.fn(), port: { onmessage: null, postMessage: vi.fn(), close: vi.fn(), }, })); mockAudioContext.audioWorklet = mockAudioWorklet; global.AudioWorkletNode = mockAudioWorkletNode; const detector = new PitchDetector({ useWorklet: true, workletPath: '/src/pitch-worklet.js', }); await detector.initialize(); const frame = new Float32Array(2048); const result = await detector.processFrame(frame); expect(Array.isArray(result)).toBe(true); }); }); describe('edge cases', () => { beforeEach(async () => { await detector.initialize(); }); it('should handle very quiet signals', async () => { const quietFrame = new Float32Array(2048); for (let i = 0; i < quietFrame.length; i++) { quietFrame[i] = 0.001; // Very quiet } const result = await detector.processFrame(quietFrame); expect(Array.isArray(result)).toBe(true); }); it('should handle very loud signals', async () => { const loudFrame = new Float32Array(2048); for (let i = 0; i < loudFrame.length; i++) { loudFrame[i] = 0.9; // Very loud } const result = await detector.processFrame(loudFrame); expect(Array.isArray(result)).toBe(true); }); it('should handle DC offset signals', async () => { const dcFrame = new Float32Array(2048); for (let i = 0; i < dcFrame.length; i++) { dcFrame[i] = 0.5; // DC offset } const result = await detector.processFrame(dcFrame); expect(Array.isArray(result)).toBe(true); }); it('should handle clipped signals', async () => { const clippedFrame = new Float32Array(2048); for (let i = 0; i < clippedFrame.length; i++) { clippedFrame[i] = i % 2 === 0 ? 1.0 : -1.0; // Square wave } const result = await detector.processFrame(clippedFrame); expect(Array.isArray(result)).toBe(true); }); }); describe('detectFromAudioBuffer', () => { it('should process AudioBuffer', async () => { await detector.initialize(); const mockBuffer = { numberOfChannels: 1, length: 4096, sampleRate: 44100, duration: 4096 / 44100, getChannelData: () => new Float32Array(4096), } as AudioBuffer; const result = await detector.detectFromAudioBuffer(mockBuffer); expect(Array.isArray(result)).toBe(true); }); }); describe('getAudioProcessor', () => { it('should return audio processor instance', () => { const audioProcessor = detector.getAudioProcessor(); expect(audioProcessor).toBeDefined(); }); }); describe('dispose', () => { it('should clean up resources', async () => { await detector.initialize(); detector.dispose(); const frame = new Float32Array(2048); await expect(detector.processFrame(frame)).rejects.toThrow('must be initialized'); }); it('should dispose audio processor', async () => { await detector.initialize(); const audioProcessor = detector.getAudioProcessor(); detector.dispose(); expect(audioProcessor.isInitialized()).toBe(false); }); }); }); describe('Utility functions', () => { describe('frequencyToMidi', () => { it('should convert A4 (440 Hz) to MIDI 69', () => { expect(frequencyToMidi(440)).toBe(69); }); it('should convert C4 (261.63 Hz) to MIDI 60', () => { expect(frequencyToMidi(261.63)).toBe(60); }); it('should convert A3 (220 Hz) to MIDI 57', () => { expect(frequencyToMidi(220)).toBe(57); }); it('should handle edge cases', () => { expect(frequencyToMidi(20)).toBe(15); // Very low frequency expect(frequencyToMidi(20000)).toBe(135); // Very high frequency expect(frequencyToMidi(0)).toBe(-Infinity); // Zero frequency }); it('should handle negative frequencies', () => { expect(frequencyToMidi(-440)).toBeNaN(); }); }); describe('midiToFrequency', () => { it('should convert MIDI 69 to 440 Hz', () => { expect(midiToFrequency(69)).toBeCloseTo(440, 2); }); it('should convert MIDI 60 to ~261.63 Hz', () => { expect(midiToFrequency(60)).toBeCloseTo(261.63, 2); }); it('should convert MIDI 57 to 220 Hz', () => { expect(midiToFrequency(57)).toBeCloseTo(220, 2); }); it('should handle edge cases', () => { expect(midiToFrequency(0)).toBeCloseTo(8.18, 2); // C-1 expect(midiToFrequency(127)).toBeCloseTo(12543.85, 2); // G9 expect(midiToFrequency(-1)).toBeCloseTo(7.72, 2); // Below range }); it('should handle fractional MIDI numbers', () => { expect(midiToFrequency(69.5)).toBeCloseTo(452.89, 2); // A4 + 50 cents expect(midiToFrequency(60.25)).toBeCloseTo(265.43, 2); // C4 + 25 cents }); }); describe('midiToNoteName', () => { it('should convert MIDI 69 to A4', () => { expect(midiToNoteName(69)).toBe('A4'); }); it('should convert MIDI 60 to C4', () => { expect(midiToNoteName(60)).toBe('C4'); }); it('should convert MIDI 61 to C#4', () => { expect(midiToNoteName(61)).toBe('C#4'); }); it('should convert MIDI 48 to C3', () => { expect(midiToNoteName(48)).toBe('C3'); }); it('should handle all sharp notes', () => { expect(midiToNoteName(61)).toBe('C#4'); // C# expect(midiToNoteName(63)).toBe('D#4'); // D# expect(midiToNoteName(66)).toBe('F#4'); // F# expect(midiToNoteName(68)).toBe('G#4'); // G# expect(midiToNoteName(70)).toBe('A#4'); // A# }); it('should handle all natural notes', () => { expect(midiToNoteName(60)).toBe('C4'); // C expect(midiToNoteName(62)).toBe('D4'); // D expect(midiToNoteName(64)).toBe('E4'); // E expect(midiToNoteName(65)).toBe('F4'); // F expect(midiToNoteName(67)).toBe('G4'); // G expect(midiToNoteName(69)).toBe('A4'); // A expect(midiToNoteName(71)).toBe('B4'); // B }); it('should handle different octaves', () => { expect(midiToNoteName(0)).toBe('C-1'); // C-1 expect(midiToNoteName(12)).toBe('C0'); // C0 expect(midiToNoteName(24)).toBe('C1'); // C1 expect(midiToNoteName(36)).toBe('C2'); // C2 expect(midiToNoteName(48)).toBe('C3'); // C3 expect(midiToNoteName(60)).toBe('C4'); // C4 expect(midiToNoteName(72)).toBe('C5'); // C5 expect(midiToNoteName(84)).toBe('C6'); // C6 expect(midiToNoteName(96)).toBe('C7'); // C7 expect(midiToNoteName(108)).toBe('C8'); // C8 expect(midiToNoteName(120)).toBe('C9'); // C9 }); it('should handle edge cases', () => { expect(midiToNoteName(-1)).toBe('undefined-2'); // Below range expect(midiToNoteName(128)).toBe('G#9'); // Above range }); it('should handle fractional MIDI numbers', () => { expect(midiToNoteName(69.5)).toBe('undefined4'); // Rounds down expect(midiToNoteName(60.7)).toBe('undefined4'); // Rounds down }); }); }); describe('Error handling', () => { let detector: PitchDetector; beforeEach(() => { detector = new PitchDetector(); }); it('should handle initialization errors gracefully', async () => { // This should not throw even if there are issues await expect(detector.initialize()).resolves.not.toThrow(); }); it('should handle processFrame errors gracefully', async () => { await detector.initialize(); // Test with invalid frame const invalidFrame = new Float32Array(1024); await expect(detector.processFrame(invalidFrame)).rejects.toThrow(); }); it('should handle dispose errors gracefully', async () => { await detector.initialize(); // Multiple dispose calls should not throw detector.dispose(); detector.dispose(); // Should be safe to call methods after dispose const frame = new Float32Array(2048); await expect(detector.processFrame(frame)).rejects.toThrow(); }); it('should handle audio processor errors gracefully', async () => { await detector.initialize(); // Should not throw even if audio processor has issues const audioProcessor = detector.getAudioProcessor(); expect(audioProcessor).toBeDefined(); }); }); describe('Performance tests', () => { let detector: PitchDetector; beforeEach(async () => { detector = new PitchDetector(); await detector.initialize(); }); it('should process frames quickly', async () => { const frame = new Float32Array(2048); const start = performance.now(); for (let i = 0; i < 100; i++) { await detector.processFrame(frame); } const end = performance.now(); const duration = end - start; // Should process 100 frames in less than 1 second expect(duration).toBeLessThan(1000); }); it('should handle large frames efficiently', async () => { const largeDetector = new PitchDetector({ frameSize: 8192, }); await largeDetector.initialize(); const largeFrame = new Float32Array(8192); const start = performance.now(); await largeDetector.processFrame(largeFrame); const end = performance.now(); const duration = end - start; // Should process large frame in reasonable time expect(duration).toBeLessThan(100); }); });