Fix potential memory leaks in audio handling - Can crash server after extended use
This comprehensive fix addresses memory leaks in both backend and frontend that could cause server crashes after extended use. Backend fixes: - MemoryManager class monitors process and GPU memory usage - Automatic cleanup when thresholds exceeded (4GB process, 2GB GPU) - Whisper model reloading to clear GPU memory fragmentation - Aggressive temporary file cleanup based on age - Context manager for audio processing with guaranteed cleanup - Integration with session manager for resource tracking - Background monitoring thread runs every 30 seconds Frontend fixes: - MemoryManager singleton tracks all browser resources - SafeMediaRecorder wrapper ensures stream cleanup - AudioBlobHandler manages blob lifecycle and object URLs - Automatic cleanup of closed AudioContexts - Proper MediaStream track stopping - Periodic cleanup of orphaned resources - Cleanup on page unload Admin features: - GET /admin/memory - View memory statistics - POST /admin/memory/cleanup - Trigger manual cleanup - Real-time metrics including GPU usage and temp files - Model reload tracking Key improvements: - AudioContext properly closed after use - Object URLs revoked after use - MediaRecorder streams properly stopped - Audio chunks cleared after processing - GPU cache cleared after each transcription - Temp files tracked and cleaned aggressively This prevents the gradual memory increase that could lead to out-of-memory errors or performance degradation after hours of use. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
		| @@ -23,6 +23,7 @@ import { PerformanceMonitor } from './performanceMonitor'; | ||||
| import { SpeakerManager } from './speakerManager'; | ||||
| import { ConnectionManager } from './connectionManager'; | ||||
| import { ConnectionUI } from './connectionUI'; | ||||
| import { MemoryManager, AudioBlobHandler, SafeMediaRecorder } from './memoryManager'; | ||||
| // import { apiClient } from './apiClient'; // Available for cross-origin requests | ||||
|  | ||||
| // Initialize error boundary | ||||
| @@ -32,6 +33,9 @@ const errorBoundary = ErrorBoundary.getInstance(); | ||||
| ConnectionManager.getInstance(); // Initialize connection manager | ||||
| const connectionUI = ConnectionUI.getInstance(); | ||||
|  | ||||
| // Initialize memory management | ||||
| const memoryManager = MemoryManager.getInstance(); | ||||
|  | ||||
| // Configure API client if needed for cross-origin requests | ||||
| // import { apiClient } from './apiClient'; | ||||
| // apiClient.configure({ baseUrl: 'https://api.talk2me.com', credentials: 'include' }); | ||||
| @@ -149,8 +153,8 @@ function initApp(): void { | ||||
|  | ||||
|     // Set initial values | ||||
|     let isRecording: boolean = false; | ||||
|     let mediaRecorder: MediaRecorder | null = null; | ||||
|     let audioChunks: Blob[] = []; | ||||
|     let safeMediaRecorder: SafeMediaRecorder | null = null; | ||||
|     let currentAudioHandler: AudioBlobHandler | null = null; | ||||
|     let currentSourceText: string = ''; | ||||
|     let currentTranslationText: string = ''; | ||||
|     let currentTtsServerUrl: string = ''; | ||||
| @@ -409,7 +413,7 @@ function initApp(): void { | ||||
|     }); | ||||
|  | ||||
|     // Function to start recording | ||||
|     function startRecording(): void { | ||||
|     async function startRecording(): Promise<void> { | ||||
|         // Request audio with specific constraints for better compression | ||||
|         const audioConstraints = { | ||||
|             audio: { | ||||
| @@ -421,86 +425,79 @@ function initApp(): void { | ||||
|             } | ||||
|         }; | ||||
|          | ||||
|         navigator.mediaDevices.getUserMedia(audioConstraints) | ||||
|             .then(stream => { | ||||
|                 // Use webm/opus for better compression (if supported) | ||||
|                 const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')  | ||||
|                     ? 'audio/webm;codecs=opus'  | ||||
|                     : 'audio/webm'; | ||||
|                  | ||||
|                 const options = { | ||||
|                     mimeType: mimeType, | ||||
|                     audioBitsPerSecond: 32000 // Low bitrate for speech (32 kbps) | ||||
|                 }; | ||||
|                  | ||||
|                 try { | ||||
|                     mediaRecorder = new MediaRecorder(stream, options); | ||||
|                 } catch (e) { | ||||
|                     // Fallback to default if options not supported | ||||
|                     console.warn('Compression options not supported, using defaults'); | ||||
|                     mediaRecorder = new MediaRecorder(stream); | ||||
|                 } | ||||
|                  | ||||
|                 audioChunks = []; | ||||
|  | ||||
|                 mediaRecorder.addEventListener('dataavailable', event => { | ||||
|                     audioChunks.push(event.data); | ||||
|                 }); | ||||
|  | ||||
|                 mediaRecorder.addEventListener('stop', async () => { | ||||
|                     // Create blob with appropriate MIME type | ||||
|                     const mimeType = mediaRecorder?.mimeType || 'audio/webm'; | ||||
|                     const audioBlob = new Blob(audioChunks, { type: mimeType }); | ||||
|                      | ||||
|                     // Log compression results | ||||
|                     const sizeInKB = (audioBlob.size / 1024).toFixed(2); | ||||
|                     console.log(`Audio compressed to ${sizeInKB} KB (${mimeType})`); | ||||
|                      | ||||
|                     // If the audio is still too large, we can compress it further | ||||
|                     if (audioBlob.size > 500 * 1024) { // If larger than 500KB | ||||
|                         statusIndicator.textContent = 'Compressing audio...'; | ||||
|                         const compressedBlob = await compressAudioBlob(audioBlob); | ||||
|                         transcribeAudio(compressedBlob); | ||||
|                     } else { | ||||
|                         transcribeAudio(audioBlob); | ||||
|                     } | ||||
|                 }); | ||||
|  | ||||
|                 mediaRecorder.start(); | ||||
|                 isRecording = true; | ||||
|                 recordBtn.classList.add('recording'); | ||||
|                 recordBtn.classList.replace('btn-primary', 'btn-danger'); | ||||
|                 recordBtn.innerHTML = '<div class="recording-wave"><span></span><span></span><span></span><span></span><span></span></div>'; | ||||
|                 statusIndicator.textContent = 'Recording... Click to stop'; | ||||
|                 statusIndicator.classList.add('processing'); | ||||
|             }) | ||||
|             .catch(error => { | ||||
|                 console.error('Error accessing microphone:', error); | ||||
|                 alert('Error accessing microphone. Please make sure you have given permission for microphone access.'); | ||||
|             }); | ||||
|         try { | ||||
|             // Clean up any previous recorder | ||||
|             if (safeMediaRecorder) { | ||||
|                 safeMediaRecorder.cleanup(); | ||||
|             } | ||||
|              | ||||
|             safeMediaRecorder = new SafeMediaRecorder(); | ||||
|             await safeMediaRecorder.start(audioConstraints); | ||||
|             isRecording = true; | ||||
|             recordBtn.classList.add('recording'); | ||||
|             recordBtn.classList.replace('btn-primary', 'btn-danger'); | ||||
|             recordBtn.innerHTML = '<div class="recording-wave"><span></span><span></span><span></span><span></span><span></span></div>'; | ||||
|             statusIndicator.textContent = 'Recording... Click to stop'; | ||||
|             statusIndicator.classList.add('processing'); | ||||
|         } catch (error) { | ||||
|             console.error('Error accessing microphone:', error); | ||||
|             alert('Error accessing microphone. Please make sure you have given permission for microphone access.'); | ||||
|             isRecording = false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Function to stop recording | ||||
|     function stopRecording(): void { | ||||
|         if (!mediaRecorder) return; | ||||
|     async function stopRecording(): Promise<void> { | ||||
|         if (!safeMediaRecorder || !safeMediaRecorder.isRecording()) return; | ||||
|          | ||||
|         mediaRecorder.stop(); | ||||
|         isRecording = false; | ||||
|         recordBtn.classList.remove('recording'); | ||||
|         recordBtn.classList.replace('btn-danger', 'btn-primary'); | ||||
|         recordBtn.innerHTML = '<i class="fas fa-microphone"></i>'; | ||||
|         statusIndicator.textContent = 'Processing audio...'; | ||||
|         statusIndicator.classList.add('processing'); | ||||
|         showLoadingOverlay('Transcribing your speech...'); | ||||
|          | ||||
|         // Stop all audio tracks | ||||
|         mediaRecorder.stream.getTracks().forEach(track => track.stop()); | ||||
|         try { | ||||
|             isRecording = false; | ||||
|             recordBtn.classList.remove('recording'); | ||||
|             recordBtn.classList.replace('btn-danger', 'btn-primary'); | ||||
|             recordBtn.innerHTML = '<i class="fas fa-microphone"></i>'; | ||||
|             statusIndicator.textContent = 'Processing audio...'; | ||||
|             statusIndicator.classList.add('processing'); | ||||
|             showLoadingOverlay('Transcribing your speech...'); | ||||
|              | ||||
|             const audioBlob = await safeMediaRecorder.stop(); | ||||
|              | ||||
|             // Log compression results | ||||
|             const sizeInKB = (audioBlob.size / 1024).toFixed(2); | ||||
|             console.log(`Audio compressed to ${sizeInKB} KB`); | ||||
|              | ||||
|             // Clean up old audio handler | ||||
|             if (currentAudioHandler) { | ||||
|                 currentAudioHandler.cleanup(); | ||||
|             } | ||||
|              | ||||
|             // Create new audio handler | ||||
|             currentAudioHandler = new AudioBlobHandler(audioBlob); | ||||
|              | ||||
|             // If the audio is still too large, compress it further | ||||
|             if (audioBlob.size > 500 * 1024) { // If larger than 500KB | ||||
|                 statusIndicator.textContent = 'Compressing audio...'; | ||||
|                 const compressedBlob = await compressAudioBlob(audioBlob); | ||||
|                  | ||||
|                 // Update handler with compressed blob | ||||
|                 currentAudioHandler.cleanup(); | ||||
|                 currentAudioHandler = new AudioBlobHandler(compressedBlob); | ||||
|                  | ||||
|                 transcribeAudio(compressedBlob); | ||||
|             } else { | ||||
|                 transcribeAudio(audioBlob); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error('Error stopping recording:', error); | ||||
|             statusIndicator.textContent = 'Error processing audio'; | ||||
|             hideLoadingOverlay(); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Function to compress audio blob if needed | ||||
|     async function compressAudioBlob(blob: Blob): Promise<Blob> { | ||||
|         return new Promise((resolve) => { | ||||
|             const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); | ||||
|             memoryManager.registerAudioContext(audioContext); | ||||
|              | ||||
|             const reader = new FileReader(); | ||||
|             reader.onload = async (e) => { | ||||
| @@ -510,6 +507,8 @@ function initApp(): void { | ||||
|                      | ||||
|                     // Downsample to 16kHz mono | ||||
|                     const offlineContext = new OfflineAudioContext(1, audioBuffer.duration * 16000, 16000); | ||||
|                     memoryManager.registerAudioContext(offlineContext as any); | ||||
|                      | ||||
|                     const source = offlineContext.createBufferSource(); | ||||
|                     source.buffer = audioBuffer; | ||||
|                     source.connect(offlineContext.destination); | ||||
| @@ -522,9 +521,15 @@ function initApp(): void { | ||||
|                     const compressedSizeKB = (wavBlob.size / 1024).toFixed(2); | ||||
|                     console.log(`Further compressed to ${compressedSizeKB} KB`); | ||||
|                      | ||||
|                     // Clean up contexts | ||||
|                     memoryManager.cleanupAudioContext(audioContext); | ||||
|                     memoryManager.cleanupAudioContext(offlineContext as any); | ||||
|                      | ||||
|                     resolve(wavBlob); | ||||
|                 } catch (error) { | ||||
|                     console.error('Compression failed, using original:', error); | ||||
|                     // Clean up on error | ||||
|                     memoryManager.cleanupAudioContext(audioContext); | ||||
|                     resolve(blob); // Return original if compression fails | ||||
|                 } | ||||
|             }; | ||||
|   | ||||
							
								
								
									
										309
									
								
								static/js/src/memoryManager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										309
									
								
								static/js/src/memoryManager.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,309 @@ | ||||
| /** | ||||
|  * Memory management utilities for preventing leaks in audio handling | ||||
|  */ | ||||
|  | ||||
| export class MemoryManager { | ||||
|     private static instance: MemoryManager; | ||||
|     private audioContexts: Set<AudioContext> = new Set(); | ||||
|     private objectURLs: Set<string> = new Set(); | ||||
|     private mediaStreams: Set<MediaStream> = new Set(); | ||||
|     private intervals: Set<number> = new Set(); | ||||
|     private timeouts: Set<number> = 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<void> { | ||||
|         // 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<Blob> { | ||||
|         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'; | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user