Previously, the code was setting isInitialized = true on iOS even though no AudioContext was created. This caused isReady() to return true, which led other code to try to use the non-existent audio context. Now isInitialized remains false on iOS, so isReady() returns false and the code properly falls back to using the standard HTMLAudioElement APIs.
454 lines
14 KiB
JavaScript
454 lines
14 KiB
JavaScript
// 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<boolean>} - 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 };
|