import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { AudioProcessor, mixToMono, calculateRMS, normalizeAudio, applyPreEmphasis } from './audio'; // 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((buffer) => { // Fill with a simple sine wave pattern for (let i = 0; i < buffer.length; i++) { buffer[i] = Math.sin((i / buffer.length) * Math.PI * 2); } }), })), 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('AudioProcessor', () => { let processor: AudioProcessor; beforeEach(() => { processor = new AudioProcessor(); }); afterEach(() => { processor.dispose(); }); describe('initialization', () => { it('should initialize AudioContext', async () => { await processor.initialize(); expect(processor.isInitialized()).toBe(true); }); it('should use custom sample rate', async () => { const customProcessor = new AudioProcessor({ sampleRate: 48000 }); await customProcessor.initialize(); expect(AudioContext).toHaveBeenCalledWith({ sampleRate: 48000 }); customProcessor.dispose(); }); it('should not reinitialize if already initialized', async () => { await processor.initialize(); const firstContext = processor.getContext(); await processor.initialize(); const secondContext = processor.getContext(); expect(firstContext).toBe(secondContext); }); }); describe('getContext', () => { it('should throw if not initialized', () => { expect(() => processor.getContext()).toThrow('not initialized'); }); it('should return AudioContext when initialized', async () => { await processor.initialize(); const context = processor.getContext(); expect(context).toBeDefined(); expect(context.sampleRate).toBe(44100); }); }); describe('extractFrames', () => { it('should extract overlapping frames', () => { const buffer = { numberOfChannels: 1, length: 10000, sampleRate: 44100, duration: 10000 / 44100, getChannelData: () => new Float32Array(10000), } as AudioBuffer; const frames = processor.extractFrames(buffer); // With frameSize=2048, hopSize=1024, we should get multiple frames expect(frames.length).toBeGreaterThan(0); expect(frames[0].length).toBe(2048); }); it('should use custom frame and hop sizes', () => { const customProcessor = new AudioProcessor({ frameSize: 1024, hopSize: 512, }); const buffer = { numberOfChannels: 1, length: 5000, sampleRate: 44100, duration: 5000 / 44100, getChannelData: () => new Float32Array(5000), } as AudioBuffer; const frames = customProcessor.extractFrames(buffer); expect(frames[0].length).toBe(1024); customProcessor.dispose(); }); }); describe('createAnalyser', () => { it('should create AnalyserNode', async () => { await processor.initialize(); const analyser = processor.createAnalyser(); expect(analyser).toBeDefined(); expect(analyser.fftSize).toBe(2048); expect(analyser.smoothingTimeConstant).toBe(0); }); it('should reuse existing analyser', async () => { await processor.initialize(); const analyser1 = processor.createAnalyser(); const analyser2 = processor.createAnalyser(); expect(analyser1).toBe(analyser2); }); }); describe('resampleBuffer', () => { it('should return same buffer if sample rates match', async () => { await processor.initialize(); const buffer = { numberOfChannels: 1, length: 44100, sampleRate: 44100, duration: 1, } as AudioBuffer; const resampled = await processor.resampleBuffer(buffer, 44100); expect(resampled).toBe(buffer); }); it('should resample to different sample rate', async () => { await processor.initialize(); const buffer = { numberOfChannels: 1, length: 44100, sampleRate: 44100, duration: 1, } as AudioBuffer; const resampled = await processor.resampleBuffer(buffer, 48000); expect(resampled.sampleRate).toBe(48000); }); }); describe('createBufferFromChannelData', () => { it('should create AudioBuffer from Float32Array', async () => { await processor.initialize(); const channelData = new Float32Array(1024); const buffer = processor.createBufferFromChannelData(channelData); expect(buffer.numberOfChannels).toBe(1); expect(buffer.length).toBe(1024); }); }); describe('getSampleRate', () => { it('should return sample rate when initialized', async () => { await processor.initialize(); expect(processor.getSampleRate()).toBe(44100); }); it('should return configured sample rate when not initialized', () => { expect(processor.getSampleRate()).toBe(44100); }); }); describe('dispose', () => { it('should clean up resources', async () => { await processor.initialize(); processor.dispose(); expect(processor.isInitialized()).toBe(false); expect(() => processor.getContext()).toThrow(); }); }); }); describe('Utility functions', () => { describe('mixToMono', () => { it('should mix stereo channels to mono', () => { const left = new Float32Array([1, 0, -1, 0]); const right = new Float32Array([0, 1, 0, -1]); const mono = mixToMono(left, right); expect(mono[0]).toBeCloseTo(0.5); expect(mono[1]).toBeCloseTo(0.5); expect(mono[2]).toBeCloseTo(-0.5); expect(mono[3]).toBeCloseTo(-0.5); }); }); describe('calculateRMS', () => { it('should calculate RMS of signal', () => { const samples = new Float32Array([1, -1, 1, -1]); const rms = calculateRMS(samples); expect(rms).toBeCloseTo(1); }); it('should return 0 for silent signal', () => { const samples = new Float32Array(100); const rms = calculateRMS(samples); expect(rms).toBe(0); }); }); describe('normalizeAudio', () => { it('should normalize audio to [-1, 1]', () => { const samples = new Float32Array([2, -4, 1, -2]); const normalized = normalizeAudio(samples); // Peak value (4) becomes 1, all values scaled proportionally expect(normalized[0]).toBeCloseTo(0.5); // 2/4 expect(normalized[1]).toBeCloseTo(-1); // -4/4 expect(normalized[2]).toBeCloseTo(0.25); // 1/4 expect(normalized[3]).toBeCloseTo(-0.5); // -2/4 // Maximum absolute value should be 1 const maxAbs = Math.max(...Array.from(normalized).map(Math.abs)); expect(maxAbs).toBeCloseTo(1); }); it('should handle all-zero signal', () => { const samples = new Float32Array(100); const normalized = normalizeAudio(samples); expect(normalized.every(v => v === 0)).toBe(true); }); }); describe('applyPreEmphasis', () => { it('should apply pre-emphasis filter', () => { const samples = new Float32Array([1, 2, 3, 4]); const filtered = applyPreEmphasis(samples); expect(filtered[0]).toBe(1); expect(filtered[1]).toBeCloseTo(2 - 0.97 * 1); expect(filtered[2]).toBeCloseTo(3 - 0.97 * 2); expect(filtered[3]).toBeCloseTo(4 - 0.97 * 3); }); it('should use custom coefficient', () => { const samples = new Float32Array([1, 2, 3]); const filtered = applyPreEmphasis(samples, 0.5); expect(filtered[1]).toBeCloseTo(2 - 0.5 * 1); }); }); });