/** * Tests for pitch tracking and smoothing utilities */ import { describe, it, expect, beforeEach } from 'vitest'; import { PitchTracker, PitchTrackingUtils, trackPitch, type PitchTrackingConfig, type TrackedPitch } from './pitch-tracking'; describe('PitchTracker', () => { let tracker: PitchTracker; let config: PitchTrackingConfig; beforeEach(() => { config = { sampleRate: 44100, frameSize: 2048, hopSize: 1024, smoothingWindow: 3, medianFilterSize: 3, outlierThreshold: 0.3, minConfidence: 0.5, maxPitchJump: 0.5, useViterbi: true, }; tracker = new PitchTracker(config); }); describe('constructor', () => { it('should create tracker with default config', () => { const defaultTracker = new PitchTracker(); const trackerConfig = defaultTracker.getConfig(); expect(trackerConfig.sampleRate).toBe(44100); expect(trackerConfig.frameSize).toBe(2048); expect(trackerConfig.hopSize).toBe(1024); expect(trackerConfig.smoothingWindow).toBe(5); expect(trackerConfig.medianFilterSize).toBe(3); expect(trackerConfig.outlierThreshold).toBe(0.3); expect(trackerConfig.minConfidence).toBe(0.5); expect(trackerConfig.maxPitchJump).toBe(0.5); expect(trackerConfig.useViterbi).toBe(true); }); it('should accept custom config', () => { const customConfig: PitchTrackingConfig = { sampleRate: 48000, smoothingWindow: 7, medianFilterSize: 5, outlierThreshold: 0.2, minConfidence: 0.7, maxPitchJump: 0.3, useViterbi: false, }; const customTracker = new PitchTracker(customConfig); const trackerConfig = customTracker.getConfig(); expect(trackerConfig.sampleRate).toBe(48000); expect(trackerConfig.smoothingWindow).toBe(7); expect(trackerConfig.medianFilterSize).toBe(5); expect(trackerConfig.outlierThreshold).toBe(0.2); expect(trackerConfig.minConfidence).toBe(0.7); expect(trackerConfig.maxPitchJump).toBe(0.3); expect(trackerConfig.useViterbi).toBe(false); }); }); describe('processPitch', () => { it('should initialize tracker with first pitch', () => { const pitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; const result = tracker.processPitch(pitch); expect(result).toBeNull(); // First pitch initializes tracker expect(tracker.getState().isInitialized).toBe(true); expect(tracker.getState().currentFrame).toBe(1); }); it('should process subsequent pitches', () => { // Initialize tracker const firstPitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; tracker.processPitch(firstPitch); // Process second pitch const secondPitch = { frequency: 441, confidence: 0.85, clarity: 0.88, timestamp: 100, }; const result = tracker.processPitch(secondPitch); expect(result).not.toBeNull(); expect(result!.frequency).toBeGreaterThan(0); expect(result!.confidence).toBeGreaterThan(0); expect(result!.timestamp).toBe(100); }); it('should filter outliers based on pitch jump', () => { // Initialize tracker const firstPitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; tracker.processPitch(firstPitch); // Try to process pitch with large jump const outlierPitch = { frequency: 880, // Double frequency - should be filtered confidence: 0.8, clarity: 0.9, timestamp: 100, }; const result = tracker.processPitch(outlierPitch); expect(result).toBeNull(); // Should be filtered out }); it('should filter pitches below confidence threshold', () => { // Initialize tracker const firstPitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; tracker.processPitch(firstPitch); // Try to process pitch with low confidence const lowConfidencePitch = { frequency: 441, confidence: 0.3, // Below threshold clarity: 0.9, timestamp: 100, }; const result = tracker.processPitch(lowConfidencePitch); expect(result).toBeNull(); // Should be filtered out }); it('should apply median filtering', () => { // Initialize tracker const firstPitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; tracker.processPitch(firstPitch); // Process multiple pitches to fill median filter const pitches = [ { frequency: 441, confidence: 0.8, clarity: 0.9, timestamp: 100 }, { frequency: 439, confidence: 0.8, clarity: 0.9, timestamp: 200 }, { frequency: 442, confidence: 0.8, clarity: 0.9, timestamp: 300 }, ]; for (const pitch of pitches) { const result = tracker.processPitch(pitch); expect(result).not.toBeNull(); } }); it('should apply temporal smoothing', () => { // Initialize tracker const firstPitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; tracker.processPitch(firstPitch); // Process multiple pitches to fill smoothing buffer const pitches = [ { frequency: 441, confidence: 0.8, clarity: 0.9, timestamp: 100 }, { frequency: 439, confidence: 0.8, clarity: 0.9, timestamp: 200 }, { frequency: 442, confidence: 0.8, clarity: 0.9, timestamp: 300 }, { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 400 }, ]; for (const pitch of pitches) { const result = tracker.processPitch(pitch); expect(result).not.toBeNull(); } }); }); describe('state management', () => { it('should track state correctly', () => { const pitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; tracker.processPitch(pitch); const state = tracker.getState(); expect(state.isInitialized).toBe(true); expect(state.currentFrame).toBe(1); expect(state.history.length).toBe(1); }); it('should reset state', () => { // Initialize tracker const pitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; tracker.processPitch(pitch); // Reset tracker.reset(); const state = tracker.getState(); expect(state.isInitialized).toBe(false); expect(state.currentFrame).toBe(0); expect(state.history.length).toBe(0); }); it('should update configuration', () => { const newConfig = { smoothingWindow: 7, medianFilterSize: 5, outlierThreshold: 0.2, }; tracker.updateConfig(newConfig); const trackerConfig = tracker.getConfig(); expect(trackerConfig.smoothingWindow).toBe(7); expect(trackerConfig.medianFilterSize).toBe(5); expect(trackerConfig.outlierThreshold).toBe(0.2); }); }); describe('velocity and acceleration', () => { it('should calculate velocity', () => { // Initialize tracker const firstPitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; tracker.processPitch(firstPitch); // Process second pitch const secondPitch = { frequency: 441, confidence: 0.8, clarity: 0.9, timestamp: 100, }; const result = tracker.processPitch(secondPitch); expect(result).not.toBeNull(); expect(result!.velocity).toBeGreaterThanOrEqual(0); }); it('should calculate acceleration', () => { // Initialize tracker const firstPitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; tracker.processPitch(firstPitch); // Process multiple pitches const pitches = [ { frequency: 441, confidence: 0.8, clarity: 0.9, timestamp: 100 }, { frequency: 442, confidence: 0.8, clarity: 0.9, timestamp: 200 }, ]; for (const pitch of pitches) { const result = tracker.processPitch(pitch); expect(result).not.toBeNull(); } }); }); }); describe('PitchTrackingUtils', () => { describe('calculateStability', () => { it('should calculate stability for stable pitches', () => { const pitches: TrackedPitch[] = [ { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 441, confidence: 0.8, clarity: 0.9, timestamp: 100, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 439, confidence: 0.8, clarity: 0.9, timestamp: 200, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 300, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 441, confidence: 0.8, clarity: 0.9, timestamp: 400, isStable: true, velocity: 0, acceleration: 0 }, ]; const stability = PitchTrackingUtils.calculateStability(pitches, 5); expect(stability).toBeGreaterThan(0.5); }); it('should calculate low stability for unstable pitches', () => { const pitches: TrackedPitch[] = [ { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, isStable: false, velocity: 0, acceleration: 0 }, { frequency: 500, confidence: 0.8, clarity: 0.9, timestamp: 100, isStable: false, velocity: 0, acceleration: 0 }, { frequency: 300, confidence: 0.8, clarity: 0.9, timestamp: 200, isStable: false, velocity: 0, acceleration: 0 }, { frequency: 600, confidence: 0.8, clarity: 0.9, timestamp: 300, isStable: false, velocity: 0, acceleration: 0 }, { frequency: 200, confidence: 0.8, clarity: 0.9, timestamp: 400, isStable: false, velocity: 0, acceleration: 0 }, ]; const stability = PitchTrackingUtils.calculateStability(pitches, 5); expect(stability).toBeLessThan(0.8); }); it('should return 0 for insufficient data', () => { const pitches: TrackedPitch[] = [ { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, isStable: true, velocity: 0, acceleration: 0 }, ]; const stability = PitchTrackingUtils.calculateStability(pitches, 5); expect(stability).toBe(0); }); }); describe('detectPitchJumps', () => { it('should detect pitch jumps', () => { const pitches: TrackedPitch[] = [ { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 441, confidence: 0.8, clarity: 0.9, timestamp: 100, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 880, confidence: 0.8, clarity: 0.9, timestamp: 200, isStable: true, velocity: 0, acceleration: 0 }, // Jump { frequency: 882, confidence: 0.8, clarity: 0.9, timestamp: 300, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 400, isStable: true, velocity: 0, acceleration: 0 }, // Jump ]; const jumps = PitchTrackingUtils.detectPitchJumps(pitches, 0.5); expect(jumps).toContain(2); // Index of first jump expect(jumps).toContain(4); // Index of second jump }); it('should not detect small changes as jumps', () => { const pitches: TrackedPitch[] = [ { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 441, confidence: 0.8, clarity: 0.9, timestamp: 100, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 442, confidence: 0.8, clarity: 0.9, timestamp: 200, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 441, confidence: 0.8, clarity: 0.9, timestamp: 300, isStable: true, velocity: 0, acceleration: 0 }, ]; const jumps = PitchTrackingUtils.detectPitchJumps(pitches, 0.5); expect(jumps).toHaveLength(0); }); }); describe('smoothTrajectory', () => { it('should smooth pitch trajectory', () => { const pitches: TrackedPitch[] = [ { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 445, confidence: 0.8, clarity: 0.9, timestamp: 100, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 450, confidence: 0.8, clarity: 0.9, timestamp: 200, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 455, confidence: 0.8, clarity: 0.9, timestamp: 300, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 460, confidence: 0.8, clarity: 0.9, timestamp: 400, isStable: true, velocity: 0, acceleration: 0 }, ]; const smoothed = PitchTrackingUtils.smoothTrajectory(pitches, 3); expect(smoothed).toHaveLength(pitches.length); expect(smoothed[0].frequency).toBeGreaterThan(430); expect(smoothed[0].frequency).toBeLessThan(450); expect(smoothed[smoothed.length - 1].frequency).toBeGreaterThan(450); expect(smoothed[smoothed.length - 1].frequency).toBeLessThan(470); }); it('should handle insufficient data', () => { const pitches: TrackedPitch[] = [ { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, isStable: true, velocity: 0, acceleration: 0 }, ]; const smoothed = PitchTrackingUtils.smoothTrajectory(pitches, 3); expect(smoothed).toHaveLength(1); expect(smoothed[0].frequency).toBe(440); }); }); describe('calculateStatistics', () => { it('should calculate pitch statistics', () => { const pitches: TrackedPitch[] = [ { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 441, confidence: 0.8, clarity: 0.9, timestamp: 100, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 439, confidence: 0.8, clarity: 0.9, timestamp: 200, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 442, confidence: 0.8, clarity: 0.9, timestamp: 300, isStable: true, velocity: 0, acceleration: 0 }, { frequency: 438, confidence: 0.8, clarity: 0.9, timestamp: 400, isStable: true, velocity: 0, acceleration: 0 }, ]; const stats = PitchTrackingUtils.calculateStatistics(pitches); expect(stats.mean).toBeCloseTo(440, 0); expect(stats.median).toBe(440); expect(stats.min).toBe(438); expect(stats.max).toBe(442); expect(stats.range).toBe(4); expect(stats.stdDev).toBeGreaterThan(0); }); it('should handle empty array', () => { const stats = PitchTrackingUtils.calculateStatistics([]); expect(stats.mean).toBe(0); expect(stats.median).toBe(0); expect(stats.min).toBe(0); expect(stats.max).toBe(0); expect(stats.range).toBe(0); expect(stats.stdDev).toBe(0); }); }); }); describe('trackPitch utility', () => { it('should track pitch with default config', () => { const pitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; const result = trackPitch(pitch); expect(result).toBeNull(); // First pitch initializes tracker }); it('should track pitch with custom config', () => { const pitch = { frequency: 440, confidence: 0.8, clarity: 0.9, timestamp: 0, }; const config: PitchTrackingConfig = { smoothingWindow: 3, medianFilterSize: 3, outlierThreshold: 0.2, }; const result = trackPitch(pitch, config); expect(result).toBeNull(); // First pitch initializes tracker }); });