// js/audio-context.js // Shared Audio Context Manager - handles EQ and provides context for visualizer import { equalizerSettings, monoAudioSettings } from './storage.js'; // Standard 16-band ISO center frequencies (Hz) const EQ_FREQUENCIES = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000]; // EQ Presets (gain values in dB for each of the 16 bands) const EQ_PRESETS = { flat: { name: 'Flat', gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, bass_boost: { name: 'Bass Boost', gains: [6, 5, 4.5, 4, 3, 2, 1, 0.5, 0, 0, 0, 0, 0, 0, 0, 0], }, bass_reducer: { name: 'Bass Reducer', gains: [-6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, treble_boost: { name: 'Treble Boost', gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 5.5, 6], }, treble_reducer: { name: 'Treble Reducer', gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -2, -3, -4, -5, -5.5, -6], }, vocal_boost: { name: 'Vocal Boost', gains: [-2, -1, 0, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, 0, -1, -2], }, loudness: { name: 'Loudness', gains: [5, 4, 3, 1, 0, -1, -1, 0, 0, 1, 2, 3, 4, 4.5, 4, 3], }, rock: { name: 'Rock', gains: [4, 3.5, 3, 2, -1, -2, -1, 1, 2, 3, 3.5, 4, 4, 3, 2, 1], }, pop: { name: 'Pop', gains: [-1, 0, 1, 2, 3, 3, 2, 1, 0, 1, 2, 2, 2, 2, 1, 0], }, classical: { name: 'Classical', gains: [3, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 3, 2], }, jazz: { name: 'Jazz', gains: [3, 2, 1, 1, -1, -1, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2], }, electronic: { name: 'Electronic', gains: [4, 3.5, 3, 1, 0, -1, 0, 1, 2, 3, 3, 2, 2, 3, 4, 3.5], }, hip_hop: { name: 'Hip-Hop', gains: [5, 4.5, 4, 3, 1, 0, 0, 1, 1, 1, 1, 1, 1, 2, 2, 2], }, r_and_b: { name: 'R&B', gains: [3, 5, 4, 2, 1, 0, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1], }, acoustic: { name: 'Acoustic', gains: [3, 2, 1, 1, 2, 2, 1, 0, 0, 1, 1, 2, 3, 3, 2, 1], }, podcast: { name: 'Podcast / Speech', gains: [-3, -2, -1, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, -1, -2, -3], }, }; class AudioContextManager { constructor() { this.audioContext = null; this.source = null; this.analyser = null; this.filters = []; this.outputNode = null; this.volumeNode = null; this.isInitialized = false; this.isEQEnabled = false; this.isMonoAudioEnabled = false; this.monoMergerNode = null; this.currentGains = new Array(16).fill(0); this.audio = null; this.currentVolume = 1.0; // Callbacks for audio graph changes (for visualizers like Butterchurn) this._graphChangeCallbacks = []; // Load saved settings this._loadSettings(); } /** * Register a callback to be called when audio graph is reconnected * @param {Function} callback - Function to call when graph changes * @returns {Function} - Unregister function */ onGraphChange(callback) { this._graphChangeCallbacks.push(callback); return () => { const index = this._graphChangeCallbacks.indexOf(callback); if (index > -1) { this._graphChangeCallbacks.splice(index, 1); } }; } /** * Notify all registered callbacks that graph has changed */ _notifyGraphChange() { this._graphChangeCallbacks.forEach((callback) => { try { callback(this.source); } catch (e) { console.warn('[AudioContext] Graph change callback failed:', e); } }); } /** * Initialize the audio context and connect to the audio element * This should be called when audio starts playing */ init(audioElement) { if (this.isInitialized) return; if (!audioElement) return; this.audio = audioElement; // Detect iOS - skip Web Audio initialization on iOS to avoid lock screen audio issues // iOS suspends AudioContext when screen locks, and MediaSession controls don't count // as user gestures to resume it, causing audio to play silently const ua = navigator.userAgent.toLowerCase(); const isIOS = /iphone|ipad|ipod/.test(ua) || (ua.includes('mac') && navigator.maxTouchPoints > 1); if (isIOS) { console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility'); // Don't set isInitialized - let it remain false so isReady() returns false // This prevents other code from trying to use the non-existent audio context return; } try { const AudioContext = window.AudioContext || window.webkitAudioContext; this.audioContext = new AudioContext(); // Create the media element source this.source = this.audioContext.createMediaElementSource(audioElement); // Create analyser for visualizer this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 512; this.analyser.smoothingTimeConstant = 0.7; // Create 16 biquad filters for EQ this.filters = EQ_FREQUENCIES.map((freq, index) => { const filter = this.audioContext.createBiquadFilter(); filter.type = 'peaking'; filter.frequency.value = freq; filter.Q.value = 2.5; // Constant-Q design filter.gain.value = this.currentGains[index]; return filter; }); // Create output gain node this.outputNode = this.audioContext.createGain(); this.outputNode.gain.value = 1; // Create volume node this.volumeNode = this.audioContext.createGain(); this.volumeNode.gain.value = this.currentVolume; // Create mono audio merger node this.monoMergerNode = this.audioContext.createChannelMerger(2); // Connect filter chain: filter[0] -> filter[1] -> ... -> filter[15] -> outputNode for (let i = 0; i < this.filters.length - 1; i++) { this.filters[i].connect(this.filters[i + 1]); } this.filters[this.filters.length - 1].connect(this.outputNode); // Connect the audio graph based on EQ and mono state this._connectGraph(); this.isInitialized = true; console.log('[AudioContext] Initialized with 16-band EQ'); } catch (e) { console.warn('[AudioContext] Init failed:', e); } } /** * Connect the audio graph based on EQ and mono audio state */ _connectGraph() { if (!this.source || !this.audioContext) return; try { // Disconnect everything first this.source.disconnect(); this.outputNode.disconnect(); if (this.volumeNode) { this.volumeNode.disconnect(); } this.analyser.disconnect(); if (this.monoMergerNode) { try { this.monoMergerNode.disconnect(); } catch { // Ignore if not connected } } let lastNode = this.source; // Apply mono audio if enabled if (this.isMonoAudioEnabled && this.monoMergerNode) { // Create a gain node to mix channels before the merger const monoGain = this.audioContext.createGain(); monoGain.gain.value = 0.5; // Reduce volume to prevent clipping when mixing // Connect source to mono gain this.source.connect(monoGain); // Connect mono gain to both inputs of the merger monoGain.connect(this.monoMergerNode, 0, 0); monoGain.connect(this.monoMergerNode, 0, 1); lastNode = this.monoMergerNode; console.log('[AudioContext] Mono audio enabled'); } if (this.isEQEnabled && this.filters.length > 0) { // EQ enabled: lastNode -> EQ filters -> output -> analyser -> volume -> destination lastNode.connect(this.filters[0]); this.outputNode.connect(this.analyser); this.analyser.connect(this.volumeNode); this.volumeNode.connect(this.audioContext.destination); console.log('[AudioContext] EQ connected'); } else { // EQ disabled: lastNode -> analyser -> volume -> destination lastNode.connect(this.analyser); this.analyser.connect(this.volumeNode); this.volumeNode.connect(this.audioContext.destination); console.log('[AudioContext] EQ bypassed'); } // Notify visualizers that graph has been reconnected this._notifyGraphChange(); } catch (e) { console.warn('[AudioContext] Failed to connect graph:', e); // Fallback: direct connection try { this.source.connect(this.audioContext.destination); } catch { /* ignore */ } } } /** * Resume audio context (required after user interaction) * @returns {Promise} - Returns true if context is running */ async resume() { if (!this.audioContext) return false; console.log('[AudioContext] Current state:', this.audioContext.state); if (this.audioContext.state === 'suspended') { try { await this.audioContext.resume(); console.log('[AudioContext] Resumed successfully, state:', this.audioContext.state); } catch (e) { console.warn('[AudioContext] Failed to resume:', e); } } // Ensure graph is connected after resuming (iOS may disconnect when suspended) if (this.isInitialized && this.audioContext.state === 'running') { this._connectGraph(); } return this.audioContext.state === 'running'; } /** * Get the analyser node for the visualizer */ getAnalyser() { return this.analyser; } /** * Get the audio context */ getAudioContext() { return this.audioContext; } /** * Get the source node for visualizers */ getSourceNode() { return this.source; } /** * Check if initialized and active */ isReady() { return this.isInitialized && this.audioContext !== null; } /** * Set the volume level (0.0 to 1.0) * @param {number} value - Volume level */ setVolume(value) { this.currentVolume = Math.max(0, Math.min(1, value)); if (this.volumeNode && this.audioContext) { const now = this.audioContext.currentTime; this.volumeNode.gain.setTargetAtTime(this.currentVolume, now, 0.01); } } /** * Toggle EQ on/off */ toggleEQ(enabled) { this.isEQEnabled = enabled; equalizerSettings.setEnabled(enabled); if (this.isInitialized) { this._connectGraph(); } return this.isEQEnabled; } /** * Check if EQ is active */ isEQActive() { return this.isInitialized && this.isEQEnabled; } /** * Toggle mono audio on/off */ toggleMonoAudio(enabled) { this.isMonoAudioEnabled = enabled; monoAudioSettings.setEnabled(enabled); if (this.isInitialized) { this._connectGraph(); } return this.isMonoAudioEnabled; } /** * Check if mono audio is active */ isMonoAudioActive() { return this.isInitialized && this.isMonoAudioEnabled; } /** * Set gain for a specific band */ setBandGain(bandIndex, gainDb) { if (bandIndex < 0 || bandIndex >= 16) return; const clampedGain = Math.max(-30, Math.min(30, gainDb)); this.currentGains[bandIndex] = clampedGain; if (this.filters[bandIndex] && this.audioContext) { const now = this.audioContext.currentTime; this.filters[bandIndex].gain.setTargetAtTime(clampedGain, now, 0.01); } equalizerSettings.setGains(this.currentGains); } /** * Set all band gains at once */ setAllGains(gains) { if (!Array.isArray(gains) || gains.length !== 16) return; const now = this.audioContext?.currentTime || 0; gains.forEach((gain, index) => { const clampedGain = Math.max(-30, Math.min(30, gain)); this.currentGains[index] = clampedGain; if (this.filters[index]) { this.filters[index].gain.setTargetAtTime(clampedGain, now, 0.01); } }); equalizerSettings.setGains(this.currentGains); } /** * Apply a preset */ applyPreset(presetKey) { const preset = EQ_PRESETS[presetKey]; if (!preset) return; this.setAllGains(preset.gains); equalizerSettings.setPreset(presetKey); } /** * Reset all bands to flat */ reset() { this.setAllGains(new Array(16).fill(0)); equalizerSettings.setPreset('flat'); } /** * Get current gains */ getGains() { return [...this.currentGains]; } /** * Load settings from storage */ _loadSettings() { this.isEQEnabled = equalizerSettings.isEnabled(); this.currentGains = equalizerSettings.getGains(); this.isMonoAudioEnabled = monoAudioSettings.isEnabled(); } } // Export singleton instance export const audioContextManager = new AudioContextManager(); // Export presets for settings UI export { EQ_PRESETS };