import type { IVolumeMeter } from '../interfaces/IVolumeMeter'; /** * VolumeMeter class * Follows Single Responsibility Principle (SRP) - only responsible for audio analysis */ export class VolumeMeter implements IVolumeMeter { private audioContext: AudioContext | null = null; private analyser: AnalyserNode | null = null; private source: MediaElementAudioSourceNode | null = null; private videoElement: HTMLVideoElement; private isRunning: boolean = false; private animationFrameId: number | null = null; private volumeCallback?: (volume: number) => void; constructor(videoElement: HTMLVideoElement) { this.videoElement = videoElement; } /** * Sets a callback function to be called with volume updates */ setVolumeCallback(callback: (volume: number) => void): void { this.volumeCallback = callback; } async start(): Promise { if (this.isRunning) { return; } try { // Create audio context this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); // Resume audio context if suspended (required by browser autoplay policies) if (this.audioContext.state === 'suspended') { await this.audioContext.resume(); console.log('[VolumeMeter] Audio context resumed'); } this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 512; // Increased for better frequency resolution this.analyser.smoothingTimeConstant = 0.3; // Lower for more reactive visualization // Create source from video element this.source = this.audioContext.createMediaElementSource(this.videoElement); this.source.connect(this.analyser); this.analyser.connect(this.audioContext.destination); this.isRunning = true; console.log('[VolumeMeter] Started successfully, frequencyBinCount:', this.analyser.frequencyBinCount); // Check for CORS issues by testing if we get any audio data if (this.analyser) { setTimeout(() => { if (this.analyser) { const testArray = new Uint8Array(this.analyser.frequencyBinCount); this.analyser.getByteFrequencyData(testArray); const hasData = testArray.some(value => value > 0); if (!hasData) { console.warn('[VolumeMeter] No audio data detected - likely CORS restriction. Volume meters may not react.'); console.warn('[VolumeMeter] Solution: Add CORS headers to the HLS stream server or use same-origin content.'); } } }, 1000); } this.startVolumeMonitoring(); } catch (error) { console.error('[VolumeMeter] Error starting audio analysis:', error); this.isRunning = false; } } stop(): void { if (!this.isRunning) { return; } if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } if (this.source) { this.source.disconnect(); this.source = null; } if (this.analyser) { this.analyser.disconnect(); this.analyser = null; } if (this.audioContext && this.audioContext.state !== 'closed') { this.audioContext.close(); this.audioContext = null; } this.isRunning = false; } getVolumeLevel(): number { if (!this.analyser) { return 0; } const dataArray = new Uint8Array(this.analyser.frequencyBinCount); this.analyser.getByteFrequencyData(dataArray); // Calculate average volume let sum = 0; for (let i = 0; i < dataArray.length; i++) { sum += dataArray[i]; } const average = sum / dataArray.length; // Normalize to 0-1 range return average / 255; } getFrequencyData(array: Uint8Array): void { if (this.analyser) { this.analyser.getByteFrequencyData(array as Uint8Array); } } getTimeDomainData(array: Uint8Array): void { if (this.analyser) { this.analyser.getByteTimeDomainData(array as Uint8Array); } } isActive(): boolean { return this.isRunning; } /** * Continuously monitors volume and calls the callback */ private startVolumeMonitoring(): void { const update = () => { if (!this.isRunning) { return; } const volume = this.getVolumeLevel(); if (this.volumeCallback) { this.volumeCallback(volume); } this.animationFrameId = requestAnimationFrame(update); }; update(); } }