import type { IVolumeMeter } from '../interfaces/IVolumeMeter'; /** * VolumeMeterVisualizer class * Follows Single Responsibility Principle (SRP) - only responsible for visualizing volume data */ export class VolumeMeterVisualizer { private container: HTMLElement; private volumeMeter: IVolumeMeter; private bars: HTMLElement[] = []; private barCount: number; private animationFrameId: number | null = null; constructor(container: HTMLElement, volumeMeter: IVolumeMeter, barCount: number = 20) { this.container = container; this.volumeMeter = volumeMeter; this.barCount = barCount; this.createBars(); } /** * Creates the visual bar elements */ private createBars(): void { this.container.innerHTML = ''; this.bars = []; for (let i = 0; i < this.barCount; i++) { const bar = document.createElement('div'); bar.className = 'volume-bar'; this.container.appendChild(bar); this.bars.push(bar); } } /** * Starts the visualization animation */ start(): void { if (this.animationFrameId !== null) { return; } const update = () => { if (!this.volumeMeter.isActive()) { // Keep running even if not active, but show minimal activity so bars are visible this.bars.forEach(bar => { bar.style.height = '10%'; bar.style.opacity = '0.4'; }); this.animationFrameId = requestAnimationFrame(update); return; } // Get volume level const volume = this.volumeMeter.getVolumeLevel(); // Update each bar based on volume and frequency // Use the correct size based on analyser's frequencyBinCount (fftSize / 2) const frequencyArray = new Uint8Array(256); this.volumeMeter.getFrequencyData(frequencyArray); // Check if we're getting any actual audio data (CORS check) const hasAudioData = frequencyArray.some(value => value > 0) || volume > 0.001; if (!hasAudioData) { // If no audio data (likely CORS issue), show a subtle pulsing animation const time = Date.now() / 1000; this.bars.forEach((bar, index) => { const pulse = Math.sin(time * 2 + index * 0.3) * 0.3 + 0.5; bar.style.height = `${10 + pulse * 15}%`; bar.style.opacity = `${0.3 + pulse * 0.2}`; }); this.animationFrameId = requestAnimationFrame(update); return; } this.bars.forEach((bar, index) => { // Map bar index to frequency bin with some overlap for smoother visualization const frequencyIndex = Math.floor((index / this.barCount) * frequencyArray.length); const frequencyValue = frequencyArray[frequencyIndex] || 0; // Calculate height with better scaling - ensure minimum visibility const normalizedFreq = frequencyValue / 255; // Scale more aggressively for better visibility const height = Math.max(normalizedFreq * 100, volume * 30, 8); // At least 8% when active bar.style.height = `${Math.min(height, 100)}%`; // Add color and opacity based on volume level - more dynamic const intensity = Math.max(normalizedFreq, volume); bar.style.opacity = `${0.5 + intensity * 0.5}`; }); this.animationFrameId = requestAnimationFrame(update); }; update(); } /** * Stops the visualization animation */ stop(): void { if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } // Reset all bars this.bars.forEach(bar => { bar.style.height = '0%'; bar.style.opacity = '0.3'; }); } }