/** * Tests for CREPE-NMF integration module */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { CREPENMFDetector, detectPolyphonicPitches, type CREPENMFConfig, type CREPENMFResult, type CREPENMFPitch } from './crepe-nmf'; // Mock CREPE model vi.mock('./crepe', () => ({ CREPEModel: vi.fn().mockImplementation(() => ({ initialize: vi.fn().mockResolvedValue(undefined), predict: vi.fn().mockResolvedValue([ { frequency: 440, confidence: 0.8, activations: new Float32Array(360), time: 0, }, { frequency: 880, confidence: 0.7, activations: new Float32Array(360), time: 0, }, ]), dispose: vi.fn(), })), })); // Mock NMF algorithm vi.mock('./nmf', () => ({ NMFAlgorithm: vi.fn().mockImplementation(() => ({ decompose: vi.fn().mockReturnValue({ W: [new Float32Array(1025), new Float32Array(1025)], H: [new Float32Array(1), new Float32Array(1)], reconstruction: [new Float32Array(1025)], error: 0.1, iterations: 10, converged: true, components: [ { index: 0, basis: new Float32Array(1025), coefficients: new Float32Array(1), energy: 10, spectralCentroid: 100, spectralRolloff: 200, fundamental: 440, confidence: 0.8, isPitch: true, }, { index: 1, basis: new Float32Array(1025), coefficients: new Float32Array(1), energy: 8, spectralCentroid: 200, spectralRolloff: 400, fundamental: 880, confidence: 0.7, isPitch: true, }, ], }), updateConfig: vi.fn(), })), NMFUtils: { extractPitchComponents: vi.fn().mockReturnValue([ { index: 0, basis: new Float32Array(1025), coefficients: new Float32Array(1), energy: 10, spectralCentroid: 100, spectralRolloff: 200, fundamental: 440, confidence: 0.8, isPitch: true, }, { index: 1, basis: new Float32Array(1025), coefficients: new Float32Array(1), energy: 8, spectralCentroid: 200, spectralRolloff: 400, fundamental: 880, confidence: 0.7, isPitch: true, }, ]), }, })); // Mock DSP utilities vi.mock('./dsp', () => ({ WindowFunction: { create: vi.fn().mockReturnValue(new Float32Array(2048)), apply: vi.fn().mockImplementation((frame, window) => { const result = new Float32Array(frame.length); for (let i = 0; i < frame.length; i++) { result[i] = frame[i] * window[i]; } return result; }), }, })); describe('CREPENMFDetector', () => { let detector: CREPENMFDetector; let config: CREPENMFConfig; beforeEach(() => { config = { validationThreshold: 0.7, maxPitches: 4, useValidation: true, useNMF: true, sampleRate: 44100, frameSize: 2048, }; detector = new CREPENMFDetector(config); }); describe('constructor', () => { it('should create detector with default config', () => { const defaultDetector = new CREPENMFDetector(); const detectorConfig = defaultDetector.getConfig(); expect(detectorConfig.validationThreshold).toBe(0.7); expect(detectorConfig.maxPitches).toBe(4); expect(detectorConfig.useValidation).toBe(true); expect(detectorConfig.useNMF).toBe(true); expect(detectorConfig.sampleRate).toBe(44100); expect(detectorConfig.frameSize).toBe(2048); }); it('should accept custom config', () => { const customConfig: CREPENMFConfig = { validationThreshold: 0.8, maxPitches: 6, useValidation: false, useNMF: true, sampleRate: 48000, frameSize: 4096, crepeConfig: { modelSize: 'large', viterbi: true, }, nmfConfig: { rank: 6, maxIterations: 200, tolerance: 1e-8, sparsity: 0.2, smoothness: 0.2, }, }; const customDetector = new CREPENMFDetector(customConfig); const detectorConfig = customDetector.getConfig(); expect(detectorConfig.validationThreshold).toBe(0.8); expect(detectorConfig.maxPitches).toBe(6); expect(detectorConfig.useValidation).toBe(false); expect(detectorConfig.useNMF).toBe(true); expect(detectorConfig.sampleRate).toBe(48000); expect(detectorConfig.frameSize).toBe(4096); }); }); describe('initialize', () => { it('should initialize successfully', async () => { await expect(detector.initialize()).resolves.not.toThrow(); }); it('should handle initialization errors gracefully', async () => { // Mock initialization failure const mockCrepeModel = { initialize: vi.fn().mockRejectedValue(new Error('Initialization failed')), dispose: vi.fn(), }; const failingDetector = new CREPENMFDetector(); // @ts-ignore - accessing private property for testing failingDetector.crepeModel = mockCrepeModel; await expect(failingDetector.initialize()).rejects.toThrow('Initialization failed'); }); }); describe('detectPolyphonicPitches', () => { beforeEach(async () => { await detector.initialize(); }); it('should detect pitches using CREPE-NMF hybrid', async () => { const samples = new Float32Array(2048); for (let i = 0; i < samples.length; i++) { samples[i] = Math.sin(2 * Math.PI * 440 * i / 44100) * 0.5; } const result = await detector.detectPolyphonicPitches(samples); expect(result.pitches).toBeDefined(); expect(Array.isArray(result.pitches)).toBe(true); expect(result.validationScores).toBeDefined(); expect(Array.isArray(result.validationScores)).toBe(true); expect(result.processingTime).toBeGreaterThan(0); expect(result.method).toBe('crepe-nmf-hybrid'); }); it('should handle NMF-only detection', async () => { const nmfOnlyDetector = new CREPENMFDetector({ useValidation: false, useNMF: true, }); await nmfOnlyDetector.initialize(); const samples = new Float32Array(2048); const result = await nmfOnlyDetector.detectPolyphonicPitches(samples); expect(result.method).toBe('nmf-only'); expect(result.pitches).toBeDefined(); }); it('should handle CREPE-only detection', async () => { const crepeOnlyDetector = new CREPENMFDetector({ useValidation: true, useNMF: false, }); await crepeOnlyDetector.initialize(); const samples = new Float32Array(2048); const result = await crepeOnlyDetector.detectPolyphonicPitches(samples); expect(result.method).toBe('crepe-only'); expect(result.pitches).toBeDefined(); }); it('should limit results to maxPitches', async () => { const limitedDetector = new CREPENMFDetector({ maxPitches: 2, }); await limitedDetector.initialize(); const samples = new Float32Array(2048); const result = await limitedDetector.detectPolyphonicPitches(samples); expect(result.pitches.length).toBeLessThanOrEqual(2); }); it('should handle detection errors gracefully', async () => { // Mock detection failure const mockCrepeModel = { initialize: vi.fn().mockResolvedValue(undefined), predict: vi.fn().mockRejectedValue(new Error('Detection failed')), dispose: vi.fn(), }; const failingDetector = new CREPENMFDetector(); // @ts-ignore - accessing private property for testing failingDetector.crepeModel = mockCrepeModel; await failingDetector.initialize(); const samples = new Float32Array(2048); const result = await failingDetector.detectPolyphonicPitches(samples); expect(result.pitches).toBeDefined(); expect(Array.isArray(result.pitches)).toBe(true); }); it('should process different signal lengths', async () => { const shortSamples = new Float32Array(1024); const longSamples = new Float32Array(4096); const shortResult = await detector.detectPolyphonicPitches(shortSamples); const longResult = await detector.detectPolyphonicPitches(longSamples); expect(shortResult.pitches).toBeDefined(); expect(longResult.pitches).toBeDefined(); }); }); describe('pitch validation', () => { beforeEach(async () => { await detector.initialize(); }); it('should validate pitches with high confidence', async () => { const samples = new Float32Array(2048); const result = await detector.detectPolyphonicPitches(samples); for (const pitch of result.pitches) { expect(pitch.frequency).toBeGreaterThan(0); expect(pitch.midi).toBeGreaterThanOrEqual(0); expect(pitch.midi).toBeLessThanOrEqual(127); expect(pitch.note).toBeDefined(); expect(pitch.confidence).toBeGreaterThanOrEqual(0); expect(pitch.confidence).toBeLessThanOrEqual(1); expect(pitch.clarity).toBeGreaterThanOrEqual(0); expect(pitch.clarity).toBeLessThanOrEqual(1); expect(pitch.timestamp).toBeGreaterThan(0); expect(pitch.source).toMatch(/^(crepe|nmf|validated)$/); expect(pitch.validationScore).toBeGreaterThanOrEqual(0); expect(pitch.validationScore).toBeLessThanOrEqual(1); } }); it('should filter out low-confidence pitches', async () => { const lowThresholdDetector = new CREPENMFDetector({ validationThreshold: 0.9, }); await lowThresholdDetector.initialize(); const samples = new Float32Array(2048); const result = await lowThresholdDetector.detectPolyphonicPitches(samples); for (const pitch of result.pitches) { expect(pitch.confidence).toBeGreaterThanOrEqual(0.9); } }); }); describe('configuration', () => { it('should update configuration', () => { const newConfig = { validationThreshold: 0.8, maxPitches: 6, useValidation: false, useNMF: true, }; detector.updateConfig(newConfig); const detectorConfig = detector.getConfig(); expect(detectorConfig.validationThreshold).toBe(0.8); expect(detectorConfig.maxPitches).toBe(6); expect(detectorConfig.useValidation).toBe(false); expect(detectorConfig.useNMF).toBe(true); }); it('should handle partial configuration updates', () => { const partialConfig = { validationThreshold: 0.9, }; detector.updateConfig(partialConfig); const detectorConfig = detector.getConfig(); expect(detectorConfig.validationThreshold).toBe(0.9); expect(detectorConfig.maxPitches).toBe(4); // Should remain unchanged }); }); describe('dispose', () => { it('should dispose resources', async () => { await detector.initialize(); expect(() => detector.dispose()).not.toThrow(); }); it('should handle multiple dispose calls', () => { expect(() => detector.dispose()).not.toThrow(); expect(() => detector.dispose()).not.toThrow(); }); }); }); describe('detectPolyphonicPitches utility', () => { it('should detect pitches with default config', async () => { const samples = new Float32Array(2048); const result = await detectPolyphonicPitches(samples); expect(result.pitches).toBeDefined(); expect(Array.isArray(result.pitches)).toBe(true); expect(result.processingTime).toBeGreaterThan(0); }); it('should use custom config', async () => { const samples = new Float32Array(2048); const customConfig: CREPENMFConfig = { validationThreshold: 0.8, maxPitches: 2, useValidation: false, useNMF: true, }; const result = await detectPolyphonicPitches(samples, customConfig); expect(result.pitches).toBeDefined(); expect(result.pitches.length).toBeLessThanOrEqual(2); }); it('should handle errors gracefully', async () => { const samples = new Float32Array(2048); const result = await detectPolyphonicPitches(samples); expect(result.pitches).toBeDefined(); expect(Array.isArray(result.pitches)).toBe(true); }); }); describe('CREPENMFPitch interface', () => { it('should have correct structure', () => { const pitch: CREPENMFPitch = { frequency: 440, midi: 69, note: 'A4', confidence: 0.8, clarity: 0.9, timestamp: Date.now(), source: 'validated', validationScore: 0.85, }; expect(pitch.frequency).toBe(440); expect(pitch.midi).toBe(69); expect(pitch.note).toBe('A4'); expect(pitch.confidence).toBe(0.8); expect(pitch.clarity).toBe(0.9); expect(pitch.timestamp).toBeGreaterThan(0); expect(pitch.source).toBe('validated'); expect(pitch.validationScore).toBe(0.85); }); }); describe('CREPENMFResult interface', () => { it('should have correct structure', () => { const result: CREPENMFResult = { pitches: [], validationScores: [], processingTime: 100, method: 'crepe-nmf-hybrid', }; expect(result.pitches).toBeDefined(); expect(Array.isArray(result.pitches)).toBe(true); expect(result.validationScores).toBeDefined(); expect(Array.isArray(result.validationScores)).toBe(true); expect(result.processingTime).toBe(100); expect(result.method).toBe('crepe-nmf-hybrid'); }); });