/** * Memory management utilities for preventing leaks in audio handling */ export class MemoryManager { private static instance: MemoryManager; private audioContexts: Set = new Set(); private objectURLs: Set = new Set(); private mediaStreams: Set = new Set(); private intervals: Set = new Set(); private timeouts: Set = new Set(); private constructor() { // Set up periodic cleanup this.startPeriodicCleanup(); // Clean up on page unload window.addEventListener('beforeunload', () => this.cleanup()); } static getInstance(): MemoryManager { if (!MemoryManager.instance) { MemoryManager.instance = new MemoryManager(); } return MemoryManager.instance; } /** * Register an AudioContext for cleanup */ registerAudioContext(context: AudioContext): void { this.audioContexts.add(context); } /** * Register an object URL for cleanup */ registerObjectURL(url: string): void { this.objectURLs.add(url); } /** * Register a MediaStream for cleanup */ registerMediaStream(stream: MediaStream): void { this.mediaStreams.add(stream); } /** * Register an interval for cleanup */ registerInterval(id: number): void { this.intervals.add(id); } /** * Register a timeout for cleanup */ registerTimeout(id: number): void { this.timeouts.add(id); } /** * Clean up a specific AudioContext */ cleanupAudioContext(context: AudioContext): void { if (context.state !== 'closed') { context.close().catch(console.error); } this.audioContexts.delete(context); } /** * Clean up a specific object URL */ cleanupObjectURL(url: string): void { URL.revokeObjectURL(url); this.objectURLs.delete(url); } /** * Clean up a specific MediaStream */ cleanupMediaStream(stream: MediaStream): void { stream.getTracks().forEach(track => { track.stop(); }); this.mediaStreams.delete(stream); } /** * Clean up all resources */ cleanup(): void { // Clean up audio contexts this.audioContexts.forEach(context => { if (context.state !== 'closed') { context.close().catch(console.error); } }); this.audioContexts.clear(); // Clean up object URLs this.objectURLs.forEach(url => { URL.revokeObjectURL(url); }); this.objectURLs.clear(); // Clean up media streams this.mediaStreams.forEach(stream => { stream.getTracks().forEach(track => { track.stop(); }); }); this.mediaStreams.clear(); // Clear intervals and timeouts this.intervals.forEach(id => clearInterval(id)); this.intervals.clear(); this.timeouts.forEach(id => clearTimeout(id)); this.timeouts.clear(); console.log('Memory cleanup completed'); } /** * Get memory usage statistics */ getStats(): MemoryStats { return { audioContexts: this.audioContexts.size, objectURLs: this.objectURLs.size, mediaStreams: this.mediaStreams.size, intervals: this.intervals.size, timeouts: this.timeouts.size }; } /** * Start periodic cleanup of orphaned resources */ private startPeriodicCleanup(): void { setInterval(() => { // Clean up closed audio contexts this.audioContexts.forEach(context => { if (context.state === 'closed') { this.audioContexts.delete(context); } }); // Clean up stopped media streams this.mediaStreams.forEach(stream => { const activeTracks = stream.getTracks().filter(track => track.readyState === 'live'); if (activeTracks.length === 0) { this.mediaStreams.delete(stream); } }); // Log stats in development if (process.env.NODE_ENV === 'development') { const stats = this.getStats(); if (Object.values(stats).some(v => v > 0)) { console.log('Memory manager stats:', stats); } } }, 30000); // Every 30 seconds // Don't track this interval to avoid self-reference // It will be cleared on page unload } } interface MemoryStats { audioContexts: number; objectURLs: number; mediaStreams: number; intervals: number; timeouts: number; } /** * Wrapper for safe audio blob handling */ export class AudioBlobHandler { private blob: Blob; private objectURL?: string; private memoryManager: MemoryManager; constructor(blob: Blob) { this.blob = blob; this.memoryManager = MemoryManager.getInstance(); } /** * Get object URL (creates one if needed) */ getObjectURL(): string { if (!this.objectURL) { this.objectURL = URL.createObjectURL(this.blob); this.memoryManager.registerObjectURL(this.objectURL); } return this.objectURL; } /** * Get the blob */ getBlob(): Blob { return this.blob; } /** * Clean up resources */ cleanup(): void { if (this.objectURL) { this.memoryManager.cleanupObjectURL(this.objectURL); this.objectURL = undefined; } // Help garbage collection (this.blob as any) = null; } } /** * Safe MediaRecorder wrapper */ export class SafeMediaRecorder { private mediaRecorder?: MediaRecorder; private stream?: MediaStream; private chunks: Blob[] = []; private memoryManager: MemoryManager; constructor() { this.memoryManager = MemoryManager.getInstance(); } async start(constraints: MediaStreamConstraints = { audio: true }): Promise { // Clean up any existing recorder this.cleanup(); this.stream = await navigator.mediaDevices.getUserMedia(constraints); this.memoryManager.registerMediaStream(this.stream); const options = { mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm' }; this.mediaRecorder = new MediaRecorder(this.stream, options); this.chunks = []; this.mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { this.chunks.push(event.data); } }; this.mediaRecorder.start(); } stop(): Promise { return new Promise((resolve, reject) => { if (!this.mediaRecorder) { reject(new Error('MediaRecorder not initialized')); return; } this.mediaRecorder.onstop = () => { const blob = new Blob(this.chunks, { type: this.mediaRecorder?.mimeType || 'audio/webm' }); resolve(blob); // Clean up after delivering the blob setTimeout(() => this.cleanup(), 100); }; this.mediaRecorder.stop(); }); } cleanup(): void { if (this.stream) { this.memoryManager.cleanupMediaStream(this.stream); this.stream = undefined; } if (this.mediaRecorder) { if (this.mediaRecorder.state !== 'inactive') { try { this.mediaRecorder.stop(); } catch (e) { // Ignore errors } } this.mediaRecorder = undefined; } // Clear chunks this.chunks = []; } isRecording(): boolean { return this.mediaRecorder?.state === 'recording'; } }