/** * Pitch tracking and smoothing utilities * * This module provides temporal smoothing and tracking of pitch detection results * to improve stability and accuracy over time. */ /** * Configuration for pitch tracking */ export interface PitchTrackingConfig { sampleRate?: number; // Sample rate in Hz frameSize?: number; // Frame size in samples hopSize?: number; // Hop size in samples smoothingWindow?: number; // Number of frames for smoothing (default: 5) medianFilterSize?: number; // Size of median filter (default: 3) outlierThreshold?: number; // Threshold for outlier detection (default: 0.3) minConfidence?: number; // Minimum confidence for tracking (default: 0.5) maxPitchJump?: number; // Maximum pitch jump ratio (default: 0.5) useViterbi?: boolean; // Use Viterbi algorithm for tracking (default: true) viterbiStates?: number; // Number of Viterbi states (default: 50) viterbiTransitions?: number; // Number of Viterbi transitions (default: 10) } /** * Tracked pitch result */ export interface TrackedPitch { frequency: number; // Smoothed frequency in Hz confidence: number; // Smoothed confidence (0-1) clarity: number; // Smoothed clarity (0-1) timestamp: number; // Time in ms isStable: boolean; // Whether pitch is stable velocity: number; // Pitch velocity (Hz/frame) acceleration: number; // Pitch acceleration (Hz/frame²) } /** * Pitch tracking state */ export interface PitchTrackingState { history: TrackedPitch[]; // Pitch history currentFrame: number; // Current frame number lastStablePitch: TrackedPitch | null; // Last stable pitch viterbiStates: number[]; // Viterbi state history isInitialized: boolean; // Whether tracker is initialized } /** * Main pitch tracking class */ export class PitchTracker { private config: Required; private state: PitchTrackingState; private medianFilter: number[]; private smoothingBuffer: TrackedPitch[]; constructor(config: PitchTrackingConfig = {}) { this.config = { sampleRate: config.sampleRate ?? 44100, frameSize: config.frameSize ?? 2048, hopSize: config.hopSize ?? 1024, smoothingWindow: config.smoothingWindow ?? 5, medianFilterSize: config.medianFilterSize ?? 3, outlierThreshold: config.outlierThreshold ?? 0.3, minConfidence: config.minConfidence ?? 0.5, maxPitchJump: config.maxPitchJump ?? 0.5, useViterbi: config.useViterbi ?? true, viterbiStates: config.viterbiStates ?? 50, viterbiTransitions: config.viterbiTransitions ?? 10, }; this.state = { history: [], currentFrame: 0, lastStablePitch: null, viterbiStates: [], isInitialized: false, }; this.medianFilter = []; this.smoothingBuffer = []; } /** * Process a new pitch detection result */ processPitch(pitch: { frequency: number; confidence: number; clarity: number; timestamp: number }): TrackedPitch | null { if (!this.state.isInitialized) { this.initializeTracker(pitch); return null; } // Apply outlier detection const filteredPitch = this.detectOutliers(pitch); if (!filteredPitch) { return null; } // Apply median filtering const medianFilteredPitch = this.applyMedianFilter(filteredPitch); // Apply temporal smoothing const smoothedPitch = this.applyTemporalSmoothing(medianFilteredPitch); // Apply Viterbi tracking if enabled const trackedPitch = this.config.useViterbi ? this.applyViterbiTracking(smoothedPitch) : smoothedPitch; // Update state this.updateState(trackedPitch); return trackedPitch; } /** * Initialize the tracker with first pitch */ private initializeTracker(pitch: { frequency: number; confidence: number; clarity: number; timestamp: number }): void { const trackedPitch: TrackedPitch = { frequency: pitch.frequency, confidence: pitch.confidence, clarity: pitch.clarity, timestamp: pitch.timestamp, isStable: true, velocity: 0, acceleration: 0, }; this.state.history.push(trackedPitch); this.state.lastStablePitch = trackedPitch; this.state.isInitialized = true; this.state.currentFrame = 1; } /** * Detect and filter outliers */ private detectOutliers(pitch: { frequency: number; confidence: number; clarity: number; timestamp: number }): { frequency: number; confidence: number; clarity: number; timestamp: number } | null { if (this.state.history.length === 0) { return pitch; } const lastPitch = this.state.history[this.state.history.length - 1]; const pitchJump = Math.abs(pitch.frequency - lastPitch.frequency) / lastPitch.frequency; // Check for excessive pitch jump if (pitchJump > this.config.maxPitchJump) { return null; } // Check confidence threshold if (pitch.confidence < this.config.minConfidence) { return null; } return pitch; } /** * Apply median filtering */ private applyMedianFilter(pitch: { frequency: number; confidence: number; clarity: number; timestamp: number }): TrackedPitch { // Add to median filter buffer this.medianFilter.push(pitch.frequency); if (this.medianFilter.length > this.config.medianFilterSize) { this.medianFilter.shift(); } // Calculate median frequency const sortedFrequencies = [...this.medianFilter].sort((a, b) => a - b); const medianIndex = Math.floor(sortedFrequencies.length / 2); const medianFrequency = sortedFrequencies[medianIndex]; return { frequency: medianFrequency, confidence: pitch.confidence, clarity: pitch.clarity, timestamp: pitch.timestamp, isStable: this.calculateStability(), velocity: this.calculateVelocity(), acceleration: this.calculateAcceleration(), }; } /** * Apply temporal smoothing */ private applyTemporalSmoothing(pitch: TrackedPitch): TrackedPitch { // Add to smoothing buffer this.smoothingBuffer.push(pitch); if (this.smoothingBuffer.length > this.config.smoothingWindow) { this.smoothingBuffer.shift(); } // Calculate smoothed values const smoothedFrequency = this.calculateSmoothedFrequency(); const smoothedConfidence = this.calculateSmoothedConfidence(); const smoothedClarity = this.calculateSmoothedClarity(); return { frequency: smoothedFrequency, confidence: smoothedConfidence, clarity: smoothedClarity, timestamp: pitch.timestamp, isStable: this.calculateStability(), velocity: this.calculateVelocity(), acceleration: this.calculateAcceleration(), }; } /** * Apply Viterbi tracking */ private applyViterbiTracking(pitch: TrackedPitch): TrackedPitch { // Simple Viterbi implementation for pitch tracking const viterbiResult = this.runViterbiAlgorithm(pitch); return { frequency: viterbiResult.frequency, confidence: pitch.confidence, clarity: pitch.clarity, timestamp: pitch.timestamp, isStable: this.calculateStability(), velocity: this.calculateVelocity(), acceleration: this.calculateAcceleration(), }; } /** * Run Viterbi algorithm for pitch tracking */ private runViterbiAlgorithm(pitch: TrackedPitch): { frequency: number } { // Simplified Viterbi implementation // In a full implementation, this would use transition probabilities // and state likelihoods for more sophisticated tracking if (this.state.history.length === 0) { return { frequency: pitch.frequency }; } const lastPitch = this.state.history[this.state.history.length - 1]; const frequencyChange = pitch.frequency - lastPitch.frequency; // Apply smoothing based on confidence const smoothingFactor = Math.min(pitch.confidence, 0.8); const smoothedFrequency = lastPitch.frequency + (frequencyChange * smoothingFactor); return { frequency: smoothedFrequency }; } /** * Calculate smoothed frequency */ private calculateSmoothedFrequency(): number { if (this.smoothingBuffer.length === 0) { return 0; } const weights = this.smoothingBuffer.map((p, i) => p.confidence * (i + 1)); const totalWeight = weights.reduce((sum, w) => sum + w, 0); if (totalWeight === 0) { return this.smoothingBuffer[this.smoothingBuffer.length - 1].frequency; } const weightedSum = this.smoothingBuffer.reduce((sum, p, i) => { return sum + p.frequency * weights[i]; }, 0); return weightedSum / totalWeight; } /** * Calculate smoothed confidence */ private calculateSmoothedConfidence(): number { if (this.smoothingBuffer.length === 0) { return 0; } return this.smoothingBuffer.reduce((sum, p) => sum + p.confidence, 0) / this.smoothingBuffer.length; } /** * Calculate smoothed clarity */ private calculateSmoothedClarity(): number { if (this.smoothingBuffer.length === 0) { return 0; } return this.smoothingBuffer.reduce((sum, p) => sum + p.clarity, 0) / this.smoothingBuffer.length; } /** * Calculate pitch stability */ private calculateStability(): boolean { if (this.smoothingBuffer.length < 3) { return false; } const frequencies = this.smoothingBuffer.map(p => p.frequency); const mean = frequencies.reduce((sum, f) => sum + f, 0) / frequencies.length; const variance = frequencies.reduce((sum, f) => sum + Math.pow(f - mean, 2), 0) / frequencies.length; const coefficientOfVariation = Math.sqrt(variance) / mean; return coefficientOfVariation < this.config.outlierThreshold; } /** * Calculate pitch velocity */ private calculateVelocity(): number { if (this.smoothingBuffer.length < 2) { return 0; } const recent = this.smoothingBuffer.slice(-2); const timeDiff = (recent[1].timestamp - recent[0].timestamp) / 1000; // Convert to seconds if (timeDiff === 0) { return 0; } return (recent[1].frequency - recent[0].frequency) / timeDiff; } /** * Calculate pitch acceleration */ private calculateAcceleration(): number { if (this.smoothingBuffer.length < 3) { return 0; } const recent = this.smoothingBuffer.slice(-3); const timeDiff = (recent[2].timestamp - recent[0].timestamp) / 1000; // Convert to seconds if (timeDiff === 0) { return 0; } const velocity1 = (recent[1].frequency - recent[0].frequency) / ((recent[1].timestamp - recent[0].timestamp) / 1000); const velocity2 = (recent[2].frequency - recent[1].frequency) / ((recent[2].timestamp - recent[1].timestamp) / 1000); const timeDiff2 = (recent[2].timestamp - recent[1].timestamp) / 1000; return timeDiff2 > 0 ? (velocity2 - velocity1) / timeDiff2 : 0; } /** * Update tracking state */ private updateState(pitch: TrackedPitch): void { this.state.history.push(pitch); this.state.currentFrame++; // Keep only recent history const maxHistory = this.config.smoothingWindow * 2; if (this.state.history.length > maxHistory) { this.state.history.shift(); } // Update last stable pitch if (pitch.isStable) { this.state.lastStablePitch = pitch; } } /** * Get current tracking state */ getState(): Readonly { return { ...this.state }; } /** * Reset tracking state */ reset(): void { this.state = { history: [], currentFrame: 0, lastStablePitch: null, viterbiStates: [], isInitialized: false, }; this.medianFilter = []; this.smoothingBuffer = []; } /** * Update configuration */ updateConfig(newConfig: Partial): void { this.config = { ...this.config, ...newConfig }; // Reset buffers if sizes changed if (newConfig.medianFilterSize && newConfig.medianFilterSize !== this.config.medianFilterSize) { this.medianFilter = []; } if (newConfig.smoothingWindow && newConfig.smoothingWindow !== this.config.smoothingWindow) { this.smoothingBuffer = []; } } /** * Get current configuration */ getConfig(): Readonly> { return { ...this.config }; } } /** * Utility functions for pitch tracking */ export const PitchTrackingUtils = { /** * Calculate pitch stability over a window */ calculateStability(pitches: TrackedPitch[], windowSize: number = 5): number { if (pitches.length < windowSize) { return 0; } const recent = pitches.slice(-windowSize); const frequencies = recent.map(p => p.frequency); const mean = frequencies.reduce((sum, f) => sum + f, 0) / frequencies.length; const variance = frequencies.reduce((sum, f) => sum + Math.pow(f - mean, 2), 0) / frequencies.length; const coefficientOfVariation = Math.sqrt(variance) / mean; return Math.max(0, 1 - coefficientOfVariation); }, /** * Detect pitch jumps */ detectPitchJumps(pitches: TrackedPitch[], threshold: number = 0.5): number[] { const jumps: number[] = []; for (let i = 1; i < pitches.length; i++) { const ratio = Math.abs(pitches[i].frequency - pitches[i - 1].frequency) / pitches[i - 1].frequency; if (ratio > threshold) { jumps.push(i); } } return jumps; }, /** * Smooth pitch trajectory */ smoothTrajectory(pitches: TrackedPitch[], windowSize: number = 3): TrackedPitch[] { if (pitches.length < windowSize) { return pitches; } const smoothed: TrackedPitch[] = []; for (let i = 0; i < pitches.length; i++) { const start = Math.max(0, i - Math.floor(windowSize / 2)); const end = Math.min(pitches.length, start + windowSize); const window = pitches.slice(start, end); const avgFrequency = window.reduce((sum, p) => sum + p.frequency, 0) / window.length; const avgConfidence = window.reduce((sum, p) => sum + p.confidence, 0) / window.length; const avgClarity = window.reduce((sum, p) => sum + p.clarity, 0) / window.length; smoothed.push({ ...pitches[i], frequency: avgFrequency, confidence: avgConfidence, clarity: avgClarity, }); } return smoothed; }, /** * Calculate pitch statistics */ calculateStatistics(pitches: TrackedPitch[]): { mean: number; median: number; stdDev: number; min: number; max: number; range: number; } { if (pitches.length === 0) { return { mean: 0, median: 0, stdDev: 0, min: 0, max: 0, range: 0 }; } const frequencies = pitches.map(p => p.frequency); const sorted = [...frequencies].sort((a, b) => a - b); const mean = frequencies.reduce((sum, f) => sum + f, 0) / frequencies.length; const median = sorted[Math.floor(sorted.length / 2)]; const variance = frequencies.reduce((sum, f) => sum + Math.pow(f - mean, 2), 0) / frequencies.length; const stdDev = Math.sqrt(variance); const min = Math.min(...frequencies); const max = Math.max(...frequencies); const range = max - min; return { mean, median, stdDev, min, max, range }; }, }; /** * Quick utility function for pitch tracking */ export function trackPitch( pitch: { frequency: number; confidence: number; clarity: number; timestamp: number }, config: PitchTrackingConfig = {} ): TrackedPitch | null { const tracker = new PitchTracker(config); return tracker.processPitch(pitch); }