kv-music/js/audio-context.js
tryptz 5bbc36cdb1 feat: add binaural/spatial DSP with multichannel HRTF rendering
Implement a complete binaural audio processing pipeline that sits before
EQ Studio in the signal chain. Supports multichannel (5.1) HRTF
binauralization for Dolby Atmos and Apple 3D Audio content, with
crossfeed and stereo widening for regular stereo content.

New modules:
- hrtf-generator.js: Procedural HRTF impulse response synthesis using
  Woodworth head model (ITD, ILD, head shadow, pinna coloring)
- binaural-dsp.js: BinauralDSP engine with multichannel splitter,
  per-channel ConvolverNode HRTF rendering, Bauer-style crossfeed
  (low/medium/high), and M/S stereo widener

Integration:
- Audio graph: binaural block inserted before preamp/EQ, multichannel
  passthrough via MediaElementSource channelCount=6
- Storage: binauralDspSettings with full persistence (JSON in localStorage)
- UI: toggle + sub-controls (crossfeed level, HRTF preset, width slider)
  placed before EQ Studio in settings
- Player: auto-enables binaural DSP when Atmos content detected,
  shows binaural badge on Atmos tracks
2026-04-10 16:06:04 +03:00

1609 lines
58 KiB
JavaScript

// js/audio-context.js
// Shared Audio Context Manager - handles EQ and provides context for visualizer
// Supports 3-32 parametric EQ bands
import { isIos } from './platform-detection.js';
import { equalizerSettings, monoAudioSettings, binauralDspSettings } from './storage.js';
import { BinauralDSP } from './binaural-dsp.js';
/**
* Compute RBJ cookbook IIR coefficients for shelf filters with Q support.
* Web Audio API's BiquadFilterNode ignores Q for lowshelf/highshelf,
* so we use IIRFilterNode with these coefficients instead.
*/
// Generate frequency array for given number of bands using logarithmic spacing
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
const frequencies = [];
const safeMin = Math.max(10, minFreq);
const safeMax = Math.min(96000, maxFreq);
for (let i = 0; i < bandCount; i++) {
// Logarithmic interpolation
const t = i / (bandCount - 1);
const freq = safeMin * Math.pow(safeMax / safeMin, t);
frequencies.push(Math.round(freq));
}
return frequencies;
}
// Generate frequency labels for display
function generateFrequencyLabels(frequencies) {
return frequencies.map((freq) => {
if (freq < 1000) {
return freq.toString();
} else if (freq < 10000) {
return (freq / 1000).toFixed(freq % 1000 === 0 ? 0 : 1) + 'K';
} else {
return (freq / 1000).toFixed(0) + 'K';
}
});
}
// EQ Presets (16-band default)
const EQ_PRESETS_16 = {
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] },
};
// Interpolate 16-band preset to target band count
function interpolatePreset(preset16, targetBands) {
if (targetBands === 16) return [...preset16];
const result = [];
for (let i = 0; i < targetBands; i++) {
const sourceIndex = (i / (targetBands - 1)) * (preset16.length - 1);
const indexLow = Math.floor(sourceIndex);
const indexHigh = Math.min(Math.ceil(sourceIndex), preset16.length - 1);
const fraction = sourceIndex - indexLow;
const lowValue = preset16[indexLow] || 0;
const highValue = preset16[indexHigh] || 0;
const interpolated = lowValue + (highValue - lowValue) * fraction;
result.push(Math.round(interpolated * 10) / 10);
}
return result;
}
// Get presets for given band count
function getPresetsForBandCount(bandCount) {
const presets = {};
for (const [key, preset] of Object.entries(EQ_PRESETS_16)) {
presets[key] = {
name: preset.name,
gains: interpolatePreset(preset.gains, bandCount),
};
}
return presets;
}
// Default export for backwards compatibility (16 bands)
const EQ_PRESETS = EQ_PRESETS_16;
class AudioContextManager {
constructor() {
this.audioContext = null;
this.source = null;
this.sources = new Map();
this.analyser = null;
this.filters = [];
this.outputNode = null;
this.volumeNode = null;
this.isInitialized = false;
this.isEQEnabled = false;
this.isMonoAudioEnabled = false;
this.monoMergerNode = null;
this.audio = null;
// M/S (Mid/Side) processing state
this.msEnabled = false;
this.msSplitter = null;
this.msEncoderMidL = null;
this.msEncoderMidR = null;
this.msEncoderSideL = null;
this.msEncoderSideR = null;
this.msMidInput = null;
this.msSideInput = null;
this.midFilters = [];
this.sideFilters = [];
this.midOutputNode = null;
this.sideOutputNode = null;
this.msDecoderMidToL = null;
this.msDecoderSideToL = null;
this.msDecoderMidToR = null;
this.msDecoderSideToR = null;
this.msLMix = null;
this.msRMix = null;
this.msMerger = null;
this.msOutputNode = null;
this.currentVolume = 1.0;
// Band configuration
this.bandCount = equalizerSettings.getBandCount();
this.freqRange = equalizerSettings.getFreqRange();
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
this.currentGains = new Array(this.bandCount).fill(0);
this.currentChannels = new Array(this.bandCount).fill('stereo');
// Binaural DSP state
this.binauralDsp = null;
this.isBinauralEnabled = binauralDspSettings.isEnabled();
// Callbacks for audio graph changes (for visualizers like Butterchurn)
this._graphChangeCallbacks = [];
// --- Graphic EQ (configurable bands, separate chain) ---
this.geqFilters = [];
this.geqPreampNode = null;
this.geqOutputNode = null;
this.isGraphicEQEnabled = equalizerSettings.isGraphicEqEnabled();
this.geqBandCount = equalizerSettings.getGraphicEqBandCount();
this.geqFreqRange = equalizerSettings.getGraphicEqFreqRange();
this.geqFrequencies = generateFrequencies(this.geqBandCount, this.geqFreqRange.min, this.geqFreqRange.max);
this.geqGains = equalizerSettings.getGraphicEqGains(this.geqBandCount);
this.geqPreamp = equalizerSettings.getGraphicEqPreamp();
// Load saved settings
this._loadSettings();
}
/**
* Update band count and reinitialize EQ
*/
setBandCount(count) {
const newCount = Math.max(
equalizerSettings.MIN_BANDS,
Math.min(equalizerSettings.MAX_BANDS, parseInt(count, 10) || 16)
);
if (newCount === this.bandCount) return;
// Save new band count
equalizerSettings.setBandCount(newCount);
// Update configuration
this.bandCount = newCount;
this.frequencies = generateFrequencies(newCount, this.freqRange.min, this.freqRange.max);
// Interpolate current gains to new band count
const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount);
this.currentGains = newGains;
equalizerSettings.setGains(newGains);
// Reinitialize EQ if already initialized
if (this.isInitialized && this.audioContext) {
this._destroyMSFilters();
this._destroyEQ();
this._createEQ();
if (this.msEnabled) this._createMSFilters();
this._connectGraph();
}
// Dispatch event for UI update
window.dispatchEvent(
new CustomEvent('equalizer-band-count-changed', {
detail: { bandCount: newCount, frequencies: this.frequencies },
})
);
}
/**
* Update frequency range and reinitialize EQ
*/
setFreqRange(minFreq, maxFreq) {
const newMin = Math.max(10, Math.min(96000, parseInt(minFreq, 10) || 20));
const newMax = Math.max(10, Math.min(96000, parseInt(maxFreq, 10) || 20000));
if (newMin >= newMax) {
console.warn('[AudioContext] Invalid frequency range: min must be less than max');
return false;
}
if (newMin === this.freqRange.min && newMax === this.freqRange.max) return true;
// Save new frequency range
equalizerSettings.setFreqRange(newMin, newMax);
// Update configuration
this.freqRange = { min: newMin, max: newMax };
this.frequencies = generateFrequencies(this.bandCount, newMin, newMax);
// Reinitialize EQ if already initialized
if (this.isInitialized && this.audioContext) {
this._destroyMSFilters();
this._destroyEQ();
this._createEQ();
if (this.msEnabled) this._createMSFilters();
this._connectGraph();
}
// Dispatch event for UI update
window.dispatchEvent(
new CustomEvent('equalizer-freq-range-changed', {
detail: { min: newMin, max: newMax, frequencies: this.frequencies },
})
);
return true;
}
/**
* Destroy EQ filters
*/
_destroyEQ() {
if (this.filters) {
this.filters.forEach((filter) => {
try {
filter.disconnect();
} catch {
/* ignore */
}
});
}
this.filters = [];
// Destroy preamp node
if (this.preampNode) {
try {
this.preampNode.disconnect();
} catch {
/* ignore */
}
this.preampNode = null;
}
}
/**
* Create M/S matrix nodes (encoder, decoder, merger).
* These are cheap static nodes created once in init().
*/
_createMSNodes() {
if (!this.audioContext) return;
this.msSplitter = this.audioContext.createChannelSplitter(2);
// Encoder: L/R → M/S
this.msEncoderMidL = this.audioContext.createGain();
this.msEncoderMidL.gain.value = 0.5;
this.msEncoderMidR = this.audioContext.createGain();
this.msEncoderMidR.gain.value = 0.5;
this.msEncoderSideL = this.audioContext.createGain();
this.msEncoderSideL.gain.value = 0.5;
this.msEncoderSideR = this.audioContext.createGain();
this.msEncoderSideR.gain.value = -0.5;
// Mono mixing points for M and S signals
this.msMidInput = this.audioContext.createGain();
this.msMidInput.channelCount = 1;
this.msMidInput.channelCountMode = 'explicit';
this.msSideInput = this.audioContext.createGain();
this.msSideInput.channelCount = 1;
this.msSideInput.channelCountMode = 'explicit';
// Chain output nodes
this.midOutputNode = this.audioContext.createGain();
this.sideOutputNode = this.audioContext.createGain();
// Decoder: M/S → L/R
this.msDecoderMidToL = this.audioContext.createGain();
this.msDecoderMidToL.gain.value = 1.0;
this.msDecoderSideToL = this.audioContext.createGain();
this.msDecoderSideToL.gain.value = 1.0;
this.msDecoderMidToR = this.audioContext.createGain();
this.msDecoderMidToR.gain.value = 1.0;
this.msDecoderSideToR = this.audioContext.createGain();
this.msDecoderSideToR.gain.value = -1.0;
// L/R recombination points (mono)
this.msLMix = this.audioContext.createGain();
this.msLMix.channelCount = 1;
this.msLMix.channelCountMode = 'explicit';
this.msRMix = this.audioContext.createGain();
this.msRMix.channelCount = 1;
this.msRMix.channelCountMode = 'explicit';
this.msMerger = this.audioContext.createChannelMerger(2);
this.msOutputNode = this.audioContext.createGain();
}
/**
* Create parallel M/S filter chains based on current band settings.
* Mid filters process the center image, Side filters process stereo width.
*/
_createMSFilters() {
if (!this.audioContext) return;
this.midFilters = this.frequencies.map((freq, i) => {
const type = (this.currentTypes && this.currentTypes[i]) || 'peaking';
const q = this.currentQs && this.currentQs[i] > 0 ? this.currentQs[i] : this._calculateQ(i);
const ch = (this.currentChannels && this.currentChannels[i]) || 'stereo';
const gain = ch === 'side' ? 0 : this.currentGains[i] || 0;
const filter = this.audioContext.createBiquadFilter();
filter.type = type;
filter.frequency.value = freq;
filter.Q.value = q;
filter.gain.value = gain;
return filter;
});
this.sideFilters = this.frequencies.map((freq, i) => {
const type = (this.currentTypes && this.currentTypes[i]) || 'peaking';
const q = this.currentQs && this.currentQs[i] > 0 ? this.currentQs[i] : this._calculateQ(i);
const ch = (this.currentChannels && this.currentChannels[i]) || 'stereo';
const gain = ch === 'mid' ? 0 : this.currentGains[i] || 0;
const filter = this.audioContext.createBiquadFilter();
filter.type = type;
filter.frequency.value = freq;
filter.Q.value = q;
filter.gain.value = gain;
return filter;
});
}
/**
* Destroy M/S parallel filter chains
*/
_destroyMSFilters() {
const sd = (node) => {
try {
node?.disconnect();
} catch {
/* */
}
};
this.midFilters.forEach(sd);
this.sideFilters.forEach(sd);
this.midFilters = [];
this.sideFilters = [];
}
/**
* Update a filter chain in-place. Returns true if reconnect is needed.
* @param {Array} chain - Filter array to update (this.filters, this.midFilters, or this.sideFilters)
* @param {Array} freqs - New frequencies
* @param {Array} types - New filter types
* @param {Array} qs - New Q values
* @param {Array} gains - New gain values
* @param {number} now - Current audio context time
* @param {string} [prop] - Property name on this to update replaced filters (e.g. 'midFilters')
* @returns {boolean} Whether graph reconnection is needed
*/
_updateFilterChain(chain, freqs, types, qs, gains, now) {
chain.forEach((filter, i) => {
const type = types[i] || 'peaking';
const q = qs[i] > 0 ? qs[i] : this._calculateQ(i);
const gain = gains[i];
filter.type = type;
filter.frequency.setTargetAtTime(freqs[i], now, 0.005);
filter.gain.setTargetAtTime(gain, now, 0.005);
filter.Q.setTargetAtTime(q, now, 0.005);
});
}
/**
* Create EQ filters
*/
_createEQ() {
if (!this.audioContext) return;
// Create preamp node
if (!this.preampNode) {
this.preampNode = this.audioContext.createGain();
}
// Set preamp gain
const preampValue = this.preamp || 0;
const gainValue = Math.pow(10, preampValue / 20);
this.preampNode.gain.value = gainValue;
// Create filters for each frequency band
this.filters = this.frequencies.map((freq, index) => {
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
const gain = this.currentGains[index] || 0;
const filter = this.audioContext.createBiquadFilter();
filter.type = type;
filter.frequency.value = freq;
filter.Q.value = q;
filter.gain.value = gain;
return filter;
});
// Create volume node if not exists
if (!this.volumeNode) {
this.volumeNode = this.audioContext.createGain();
}
}
/**
* Calculate Q factor for each band
*/
_calculateQ(_index) {
// Scale Q based on band count for consistent sound
const baseQ = 2.5;
const scalingFactor = Math.sqrt(16 / this.bandCount);
return baseQ * scalingFactor;
}
/**
* 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;
if (isIos) {
console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility');
return;
}
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
try {
this.audioContext = new AudioContext({ latencyHint: 'playback' });
console.log(`[AudioContext] Created: ${this.audioContext.sampleRate}Hz`);
} catch {
this.audioContext = new AudioContext();
}
if (!this.sources.has(audioElement)) {
const src = this.audioContext.createMediaElementSource(audioElement);
// Allow multichannel passthrough for Atmos/spatial audio
try {
src.channelCount = 6;
src.channelCountMode = 'max';
src.channelInterpretation = 'discrete';
} catch {
// Some browsers may not support this
}
this.sources.set(audioElement, src);
}
this.source = this.sources.get(audioElement);
// Enable multichannel passthrough for Atmos/spatial content
try {
this.audioContext.destination.channelCount = Math.min(
this.audioContext.destination.maxChannelCount,
8
);
this.audioContext.destination.channelCountMode = 'explicit';
this.audioContext.destination.channelInterpretation = 'discrete';
} catch {
// Some browsers may not support changing destination channel count
}
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.7;
// Create binaural DSP processor
this.binauralDsp = new BinauralDSP(this.audioContext);
this._loadBinauralSettings();
this._createEQ();
this._createGraphicEQ();
this._createMSNodes();
if (this.msEnabled) {
this._createMSFilters();
}
this.outputNode = this.audioContext.createGain();
this.outputNode.gain.value = 1;
this.volumeNode = this.audioContext.createGain();
this.volumeNode.gain.value = this.currentVolume;
this.monoMergerNode = this.audioContext.createChannelMerger(2);
this._connectGraph();
// Auto-recover from unexpected suspensions (e.g. background throttling)
this.audioContext.addEventListener('statechange', () => {
if (this.audioContext.state === 'interrupted' || this.audioContext.state === 'suspended') {
console.log(`[AudioContext] State changed to ${this.audioContext.state}, attempting resume`);
// Use a short delay to let the system settle before resuming
setTimeout(() => {
if (this.audioContext && this.audioContext.state !== 'running' && this.source) {
this.audioContext.resume().catch((e) => {
console.warn('[AudioContext] Auto-resume failed:', e);
});
}
}, 100);
}
});
this.isInitialized = true;
} catch (e) {
console.warn('[AudioContext] Init failed:', e);
}
}
changeSource(audioElement) {
if (!this.audioContext) {
this.init(audioElement);
return;
}
if (this.audio === audioElement) return;
try {
if (this.source) {
try {
this.source.disconnect();
} catch {
// node may already be disconnected
}
}
this.audio = audioElement;
if (!this.sources.has(audioElement)) {
this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement));
}
this.source = this.sources.get(audioElement);
if (this.isInitialized) {
this._connectGraph();
}
} catch (e) {
console.warn('changeSource failed:', e);
}
}
/**
* Connect the audio graph based on EQ and mono audio state.
* Uses connect-before-disconnect ordering to avoid audio dropouts:
* the new chain is wired up first, then the old connections are torn down.
*/
_connectGraph() {
if (!this.isInitialized || !this.source || !this.audioContext) return;
// Ensure graphic EQ nodes exist
if (this.geqFilters.length === 0 && this.isGraphicEQEnabled) {
this._createGraphicEQ();
}
// Helper: connect a chain segment from lastNode through graphic EQ (if enabled) to analyser -> volume -> dest
const connectTail = (lastNode) => {
if (this.isGraphicEQEnabled && this.geqFilters.length > 0) {
lastNode.connect(this.geqPreampNode);
this.geqPreampNode.connect(this.geqFilters[0]);
for (let i = 0; i < this.geqFilters.length - 1; i++) {
this.geqFilters[i].connect(this.geqFilters[i + 1]);
}
this.geqFilters[this.geqFilters.length - 1].connect(this.geqOutputNode);
this.geqOutputNode.connect(this.analyser);
} else {
lastNode.connect(this.analyser);
}
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
};
try {
// Ensure mono gain node exists if needed
if (this.isMonoAudioEnabled && this.monoMergerNode && !this.monoGainNode) {
this.monoGainNode = this.audioContext.createGain();
this.monoGainNode.gain.value = 0.5;
}
// --- 1. Disconnect all existing connections ---
const safeDisconnect = (node) => {
try {
node?.disconnect();
} catch {
/* */
}
};
safeDisconnect(this.source);
safeDisconnect(this.monoGainNode);
safeDisconnect(this.monoMergerNode);
// Binaural DSP disconnects internally
if (this.binauralDsp) {
const { input, output } = this.binauralDsp.getNodes();
safeDisconnect(input);
safeDisconnect(output);
}
safeDisconnect(this.preampNode);
this.filters.forEach(safeDisconnect);
safeDisconnect(this.outputNode);
// M/S nodes
safeDisconnect(this.msSplitter);
safeDisconnect(this.msEncoderMidL);
safeDisconnect(this.msEncoderMidR);
safeDisconnect(this.msEncoderSideL);
safeDisconnect(this.msEncoderSideR);
safeDisconnect(this.msMidInput);
safeDisconnect(this.msSideInput);
this.midFilters.forEach(safeDisconnect);
this.sideFilters.forEach(safeDisconnect);
safeDisconnect(this.midOutputNode);
safeDisconnect(this.sideOutputNode);
safeDisconnect(this.msDecoderMidToL);
safeDisconnect(this.msDecoderSideToL);
safeDisconnect(this.msDecoderMidToR);
safeDisconnect(this.msDecoderSideToR);
safeDisconnect(this.msLMix);
safeDisconnect(this.msRMix);
safeDisconnect(this.msMerger);
safeDisconnect(this.msOutputNode);
// Graphic EQ + tail
safeDisconnect(this.geqPreampNode);
this.geqFilters.forEach(safeDisconnect);
safeDisconnect(this.geqOutputNode);
safeDisconnect(this.analyser);
safeDisconnect(this.volumeNode);
// --- 2. Reconnect the graph ---
let lastNode = this.source;
if (this.isMonoAudioEnabled && this.monoMergerNode) {
this.source.connect(this.monoGainNode);
this.monoGainNode.connect(this.monoMergerNode, 0, 0);
this.monoGainNode.connect(this.monoMergerNode, 0, 1);
lastNode = this.monoMergerNode;
}
// Insert binaural DSP before EQ
if (this.isBinauralEnabled && this.binauralDsp) {
const { input, output } = this.binauralDsp.getNodes();
lastNode.connect(input);
this.binauralDsp._connectInternal();
lastNode = output;
}
if (this.isEQEnabled && this.filters.length > 0) {
const useMS = this.msEnabled && this.midFilters.length > 0 && this.sideFilters.length > 0;
// Connect preamp
if (this.preampNode) {
lastNode.connect(this.preampNode);
lastNode = this.preampNode;
}
if (useMS) {
// === M/S processing path ===
// Encode L/R → M/S
lastNode.connect(this.msSplitter);
this.msSplitter.connect(this.msEncoderMidL, 0); // L → Mid
this.msSplitter.connect(this.msEncoderMidR, 1); // R → Mid
this.msEncoderMidL.connect(this.msMidInput);
this.msEncoderMidR.connect(this.msMidInput); // Mid = (L+R)*0.5
this.msSplitter.connect(this.msEncoderSideL, 0); // L → Side
this.msSplitter.connect(this.msEncoderSideR, 1); // R → Side (-0.5)
this.msEncoderSideL.connect(this.msSideInput);
this.msEncoderSideR.connect(this.msSideInput); // Side = (L-R)*0.5
// Mid filter chain
this.msMidInput.connect(this.midFilters[0]);
for (let i = 0; i < this.midFilters.length - 1; i++) {
this.midFilters[i].connect(this.midFilters[i + 1]);
}
this.midFilters[this.midFilters.length - 1].connect(this.midOutputNode);
// Side filter chain
this.msSideInput.connect(this.sideFilters[0]);
for (let i = 0; i < this.sideFilters.length - 1; i++) {
this.sideFilters[i].connect(this.sideFilters[i + 1]);
}
this.sideFilters[this.sideFilters.length - 1].connect(this.sideOutputNode);
// Decode M/S → L/R
this.midOutputNode.connect(this.msDecoderMidToL);
this.sideOutputNode.connect(this.msDecoderSideToL);
this.msDecoderMidToL.connect(this.msLMix);
this.msDecoderSideToL.connect(this.msLMix); // L = Mid + Side
this.midOutputNode.connect(this.msDecoderMidToR);
this.sideOutputNode.connect(this.msDecoderSideToR);
this.msDecoderMidToR.connect(this.msRMix);
this.msDecoderSideToR.connect(this.msRMix); // R = Mid - Side
this.msLMix.connect(this.msMerger, 0, 0);
this.msRMix.connect(this.msMerger, 0, 1);
this.msMerger.connect(this.msOutputNode);
connectTail(this.msOutputNode);
} else {
// === Normal stereo path ===
lastNode.connect(this.filters[0]);
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);
connectTail(this.outputNode);
}
} else {
connectTail(lastNode);
}
// Notify visualizers that graph has been reconnected
this._notifyGraphChange();
} catch (e) {
console.warn('[AudioContext] Failed to connect graph:', e);
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;
}
// ==========================================
// Binaural DSP controls
// ==========================================
/**
* Toggle binaural DSP on/off
*/
async toggleBinaural(enabled) {
this.isBinauralEnabled = enabled;
binauralDspSettings.setEnabled(enabled);
if (this.binauralDsp) {
await this.binauralDsp.setEnabled(enabled);
}
if (this.isInitialized) {
this._connectGraph();
}
return this.isBinauralEnabled;
}
/**
* Check if binaural DSP is active
*/
isBinauralActive() {
return this.isInitialized && this.isBinauralEnabled;
}
/**
* Set crossfeed enabled state
*/
async setBinauralCrossfeedEnabled(enabled) {
binauralDspSettings.setCrossfeedEnabled(enabled);
if (this.binauralDsp) {
await this.binauralDsp.setCrossfeedEnabled(enabled);
if (this.isInitialized) this._connectGraph();
}
}
/**
* Set crossfeed level
* @param {'low'|'medium'|'high'} level
*/
setBinauralCrossfeedLevel(level) {
binauralDspSettings.setCrossfeedLevel(level);
if (this.binauralDsp) {
this.binauralDsp.setCrossfeedLevel(level);
}
}
/**
* Set HRTF preset
* @param {'intimate'|'studio'|'wide'} preset
*/
async setBinauralHrtfPreset(preset) {
binauralDspSettings.setHrtfPreset(preset);
if (this.binauralDsp) {
await this.binauralDsp.setHrtfPreset(preset);
}
}
/**
* Set stereo widening enabled state
*/
async setBinauralWideningEnabled(enabled) {
binauralDspSettings.setWideningEnabled(enabled);
if (this.binauralDsp) {
await this.binauralDsp.setWideningEnabled(enabled);
if (this.isInitialized) this._connectGraph();
}
}
/**
* Set stereo widening amount
* @param {number} amount - 0.0 to 2.0 (1.0 = neutral)
*/
setBinauralWidening(amount) {
binauralDspSettings.setWideningAmount(amount);
if (this.binauralDsp) {
this.binauralDsp.setWideningAmount(amount);
}
}
/**
* Notify binaural DSP of channel count change (for multichannel detection)
* @param {number} channelCount
*/
async notifyBinauralChannelCount(channelCount) {
if (this.binauralDsp && this.isBinauralEnabled) {
await this.binauralDsp.detectAndConfigure(channelCount);
if (this.isInitialized) this._connectGraph();
}
}
/**
* Get binaural DSP status
*/
getBinauralStatus() {
return this.binauralDsp ? this.binauralDsp.getStatus() : null;
}
/**
* Load binaural settings from storage and apply to DSP
*/
async _loadBinauralSettings() {
if (!this.binauralDsp) return;
this.isBinauralEnabled = binauralDspSettings.isEnabled();
this.binauralDsp.crossfeedEnabled = binauralDspSettings.getCrossfeedEnabled();
this.binauralDsp.crossfeedLevel = binauralDspSettings.getCrossfeedLevel();
this.binauralDsp.hrtfPreset = binauralDspSettings.getHrtfPreset();
this.binauralDsp.wideningEnabled = binauralDspSettings.getWideningEnabled();
this.binauralDsp.wideningAmount = binauralDspSettings.getWideningAmount();
if (this.isBinauralEnabled) {
await this.binauralDsp.setEnabled(true);
}
}
/**
* Get current gain range
*/
getRange() {
return equalizerSettings.getRange();
}
/**
* Calculate biquad filter magnitude response in dB at a given frequency
*/
_biquadResponseDb(f, band, sr) {
if (!band.enabled || !band.type) return 0;
const w = (2 * Math.PI * band.freq) / sr;
const p = (2 * Math.PI * f) / sr;
const s = Math.sin(w) / (2 * band.q);
const A = Math.pow(10, band.gain / 40);
const c = Math.cos(w);
let b0, b1, b2, a0, a1, a2;
const t = band.type[0];
if (t === 'p') {
b0 = 1 + s * A;
b1 = -2 * c;
b2 = 1 - s * A;
a0 = 1 + s / A;
a1 = -2 * c;
a2 = 1 - s / A;
} else if (t === 'l') {
const sq = 2 * Math.sqrt(A) * s;
b0 = A * (A + 1 - (A - 1) * c + sq);
b1 = 2 * A * (A - 1 - (A + 1) * c);
b2 = A * (A + 1 - (A - 1) * c - sq);
a0 = A + 1 + (A - 1) * c + sq;
a1 = -2 * (A - 1 + (A + 1) * c);
a2 = A + 1 + (A - 1) * c - sq;
} else if (t === 'h') {
const sq = 2 * Math.sqrt(A) * s;
b0 = A * (A + 1 + (A - 1) * c + sq);
b1 = -2 * A * (A - 1 + (A + 1) * c);
b2 = A * (A + 1 + (A - 1) * c - sq);
a0 = A + 1 - (A - 1) * c + sq;
a1 = 2 * (A - 1 - (A + 1) * c);
a2 = A + 1 - (A - 1) * c - sq;
} else {
return 0;
}
const _a0 = 1 / a0;
const b0n = b0 * _a0,
b1n = b1 * _a0,
b2n = b2 * _a0;
const a1n = a1 * _a0,
a2n = a2 * _a0;
const cp = Math.cos(p),
c2p = Math.cos(2 * p);
const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p;
const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p;
return 10 * Math.log10(n / d);
}
/**
* Clamp gain to valid range
*/
_clampGain(gainDb) {
const range = this.getRange();
return Math.max(range.min, Math.min(range.max, gainDb));
}
/**
* Set gain for a specific band
*/
setBandGain(bandIndex, gainDb) {
if (bandIndex < 0 || bandIndex >= this.bandCount) return;
const clampedGain = this._clampGain(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)) return;
// Ensure gains array matches current band count
let adjustedGains = gains;
if (gains.length !== this.bandCount) {
adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount);
}
const now = this.audioContext?.currentTime || 0;
adjustedGains.forEach((gain, index) => {
const clampedGain = this._clampGain(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 presets = getPresetsForBandCount(this.bandCount);
const preset = presets[presetKey];
if (!preset) return;
this.setAllGains(preset.gains);
equalizerSettings.setPreset(presetKey);
}
/**
* Reset all bands to flat
*/
reset() {
this.setAllGains(new Array(this.bandCount).fill(0));
equalizerSettings.setPreset('flat');
}
/**
* Get current gains
*/
getGains() {
return [...this.currentGains];
}
/**
* Get current band count
*/
getBandCount() {
return this.bandCount;
}
/**
* Load settings from storage
*/
_loadSettings() {
this.isEQEnabled = equalizerSettings.isEnabled();
this.bandCount = equalizerSettings.getBandCount();
this.freqRange = equalizerSettings.getFreqRange();
const customFreqs = equalizerSettings.getCustomFrequencies(this.bandCount);
this.frequencies = customFreqs || generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
this.currentGains = equalizerSettings.getGains(this.bandCount);
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
this.currentChannels = equalizerSettings.getBandChannels(this.bandCount);
this.msEnabled = this.currentChannels.some((ch) => ch === 'mid' || ch === 'side');
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
this.preamp = equalizerSettings.getPreamp();
this.isBinauralEnabled = binauralDspSettings.isEnabled();
}
/**
* Set preamp value in dB
* @param {number} db - Preamp value in dB (-20 to +20)
*/
setPreamp(db) {
const clampedDb = Math.max(-20, Math.min(20, parseFloat(db) || 0));
this.preamp = clampedDb;
equalizerSettings.setPreamp(clampedDb);
// Update preamp node if it exists
if (this.preampNode && this.audioContext) {
const gainValue = Math.pow(10, clampedDb / 20);
const now = this.audioContext.currentTime;
this.preampNode.gain.setTargetAtTime(gainValue, now, 0.01);
}
}
/**
* Get current preamp value
* @returns {number} Current preamp value in dB
*/
getPreamp() {
return this.preamp || 0;
}
/**
* Apply AutoEQ-generated bands to the equalizer
* Unlike regular presets, AutoEQ bands have specific frequencies, gains, and Q values
* @param {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>} bands
* @returns {string} Exported text representation of the applied EQ
*/
applyAutoEQBands(bands, skipPreamp = false) {
if (!bands || bands.length === 0) return '';
const enabledBands = bands.filter((b) => b.enabled);
if (enabledBands.length === 0) return '';
const count = Math.max(equalizerSettings.MIN_BANDS, Math.min(equalizerSettings.MAX_BANDS, enabledBands.length));
// Calculate preamp: negative of cumulative peak gain across all bands to prevent clipping
let cumulativePeak = 0;
if (!skipPreamp) {
const sr = this.audioContext?.sampleRate ?? 48000;
// Sweep log-spaced frequencies (24 points/octave from 20-20kHz) to catch narrow peaks
for (let f = 20; f <= 20000; f *= Math.pow(2, 1 / 24)) {
let sum = 0;
for (const b of enabledBands) {
sum += this._biquadResponseDb(f, b, sr);
}
if (sum > cumulativePeak) cumulativePeak = sum;
}
}
const preamp = skipPreamp
? equalizerSettings.getPreamp()
: cumulativePeak > 0
? -Math.round(cumulativePeak * 10) / 10
: 0;
// Sort bands by frequency so index order is deterministic
const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq);
// Build normalized band descriptor arrays, pad if fewer enabled bands than minimum
const maxFreq = (this.audioContext?.sampleRate ?? 48000) / 2 - 1;
const slicedBands = sortedBands.slice(0, count);
const newFrequencies = slicedBands.map((b) => Math.round(Math.min(b.freq, maxFreq)));
const newTypes = slicedBands.map((b) => b.type || 'peaking');
const newQs = slicedBands.map((b) => b.q);
const newGains = slicedBands.map((b) => this._clampGain(b.gain));
const newChannels = slicedBands.map((b) => b.channel || 'stereo');
while (newFrequencies.length < count) {
const lastFreq = newFrequencies[newFrequencies.length - 1] || 1000;
newFrequencies.push(Math.round(Math.min(lastFreq * 2, maxFreq)));
newTypes.push('peaking');
newQs.push(1.0);
newGains.push(0);
newChannels.push('stereo');
}
// Update band count via class setter to trigger equalizer-band-count-changed event
if (count !== this.bandCount) {
this.setBandCount(count);
}
// Override frequencies, types, Qs, and channels with band-specific values
this.frequencies = newFrequencies;
this.currentTypes = newTypes;
this.currentQs = newQs;
this.currentGains = newGains;
this.currentChannels = newChannels;
// Determine if M/S processing is needed
const needsMS = newChannels.some((ch) => ch === 'mid' || ch === 'side');
const msChanged = needsMS !== this.msEnabled;
this.msEnabled = needsMS;
if (this.isInitialized && this.audioContext) {
const needsRebuild =
msChanged || this.filters.length !== count || (needsMS && this.midFilters.length !== count);
if (needsRebuild) {
// M/S state changed or band count changed — full rebuild
this._destroyMSFilters();
this._destroyEQ();
this._createEQ();
if (needsMS) {
this._createMSFilters();
}
this._connectGraph();
} else if (needsMS) {
// M/S active — update both parallel chains in-place
const now = this.audioContext.currentTime;
// Update main filters (not connected in M/S mode, kept in sync for stereo fallback)
this._updateFilterChain(this.filters, newFrequencies, newTypes, newQs, newGains, now);
// Update mid filters (gain = 0 for side-only bands)
const midGains = newGains.map((g, i) => (newChannels[i] === 'side' ? 0 : g));
this._updateFilterChain(this.midFilters, newFrequencies, newTypes, newQs, midGains, now);
// Update side filters (gain = 0 for mid-only bands)
const sideGains = newGains.map((g, i) => (newChannels[i] === 'mid' ? 0 : g));
this._updateFilterChain(this.sideFilters, newFrequencies, newTypes, newQs, sideGains, now);
} else if (this.filters.length === count) {
// Normal stereo — update in-place
const now = this.audioContext.currentTime;
this._updateFilterChain(this.filters, newFrequencies, newTypes, newQs, newGains, now);
} else {
// Band count changed — must rebuild
this._destroyMSFilters();
this._destroyEQ();
this._createEQ();
if (this.msEnabled) this._createMSFilters();
this._connectGraph();
}
}
// Apply preamp (skip if caller manages preamp externally)
if (!skipPreamp) {
this.setPreamp(preamp);
}
// Persist normalized band descriptors to settings store
equalizerSettings.setCustomFrequencies(this.frequencies);
equalizerSettings.setGains(this.currentGains);
equalizerSettings.setBandTypes(this.currentTypes);
equalizerSettings.setBandQs(this.currentQs);
equalizerSettings.setBandChannels(this.currentChannels);
// Generate export text using the actual applied preamp value
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
sortedBands.forEach((band, index) => {
if (index >= count) return;
const filterType = band.type === 'lowshelf' ? 'LSC' : band.type === 'highshelf' ? 'HSC' : 'PK';
lines.push(
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
);
});
return lines.join('\n');
}
/**
* Export equalizer settings to text format
* @returns {string} Exported settings in text format
*/
exportEQToText() {
const lines = [];
const preampValue = this.getPreamp();
lines.push(`Preamp: ${preampValue.toFixed(1)} dB`);
this.frequencies.forEach((freq, index) => {
const gain = this.currentGains[index] || 0;
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
const filterType = type === 'lowshelf' ? 'LSC' : type === 'highshelf' ? 'HSC' : 'PK';
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
const filterNum = index + 1;
lines.push(
`Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`
);
});
return lines.join('\n');
}
/**
* Import equalizer settings from text format
* @param {string} text - Text format settings
* @returns {boolean} True if import was successful
*/
importEQFromText(text) {
try {
const lines = text
.split('\n')
.map((line) => line.trim())
.filter((line) => line);
const filters = [];
let preamp = 0;
for (const line of lines) {
// Parse preamp
const preampMatch = line.match(/^Preamp:\s*([+-]?\d+\.?\d*)\s*dB$/i);
if (preampMatch) {
preamp = parseFloat(preampMatch[1]);
continue;
}
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
const filterMatch = line.match(
/^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB(?:\s+Q\s+(\d+\.?\d*))?/i
);
if (filterMatch) {
const type = filterMatch[1].toUpperCase();
const freq = parseInt(filterMatch[2], 10);
const gain = parseFloat(filterMatch[3]);
const q = filterMatch[4] ? parseFloat(filterMatch[4]) : Math.SQRT1_2;
filters.push({ type, freq, gain, q });
}
}
if (filters.length === 0) {
console.warn('[AudioContext] No valid filters found in import text');
return false;
}
// Apply preamp
this.setPreamp(preamp);
// If different number of bands, adjust
const newCount = Math.max(
equalizerSettings.MIN_BANDS,
Math.min(equalizerSettings.MAX_BANDS, filters.length)
);
if (newCount !== this.bandCount) {
this.setBandCount(newCount);
}
// Apply per-band frequencies, types, Qs, and gains from import
const sliced = filters.slice(0, this.bandCount);
const typeMap = {
PK: 'peaking',
LS: 'lowshelf',
LSC: 'lowshelf',
LSF: 'lowshelf',
HS: 'highshelf',
HSC: 'highshelf',
HSF: 'highshelf',
};
// Pad arrays to bandCount if import has fewer filters than minimum
const padCount = this.bandCount - sliced.length;
const freqs = sliced.map((f) => f.freq);
const types = sliced.map((f) => typeMap[f.type] || 'peaking');
const qs = sliced.map((f) => f.q);
const gains = sliced.map((f) => this._clampGain(f.gain));
if (padCount > 0) {
const lastFreq = freqs[freqs.length - 1] || 1000;
const maxFreq = (this.audioContext?.sampleRate ?? 48000) / 2 - 1;
for (let p = 0; p < padCount; p++) {
const padFreq = Math.min(lastFreq * Math.pow(2, p + 1), maxFreq);
freqs.push(Math.round(padFreq));
types.push('peaking');
qs.push(this._calculateQ(freqs.length - 1));
gains.push(0);
}
}
this.frequencies = freqs;
this.currentTypes = types;
this.currentQs = qs;
this.currentGains = gains;
// Rebuild EQ chain to apply new frequencies, types, and Qs
if (this.isInitialized && this.audioContext) {
this._destroyMSFilters();
this._destroyEQ();
this._createEQ();
if (this.msEnabled) this._createMSFilters();
this._connectGraph();
}
// Persist all band settings including custom frequencies
equalizerSettings.setCustomFrequencies(this.frequencies);
equalizerSettings.setGains(this.currentGains);
equalizerSettings.setBandTypes(this.currentTypes);
equalizerSettings.setBandQs(this.currentQs);
return true;
} catch (e) {
console.warn('[AudioContext] Failed to import EQ settings:', e);
return false;
}
}
// ========================================
// Graphic EQ (16-band, independent chain)
// ========================================
_createGraphicEQ() {
if (!this.audioContext) return;
this.geqPreampNode = this.audioContext.createGain();
const gainValue = Math.pow(10, (this.geqPreamp || 0) / 20);
this.geqPreampNode.gain.value = gainValue;
this.geqOutputNode = this.audioContext.createGain();
this.geqOutputNode.gain.value = 1;
const geqQ = 2.5 * Math.sqrt(16 / this.geqBandCount);
this.geqFilters = this.geqFrequencies.map((freq, i) => {
const filter = this.audioContext.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = geqQ;
filter.gain.value = this.geqGains[i] || 0;
return filter;
});
}
_destroyGraphicEQ() {
this.geqFilters.forEach((f) => {
try {
f.disconnect();
} catch {
/* */
}
});
this.geqFilters = [];
if (this.geqPreampNode) {
try {
this.geqPreampNode.disconnect();
} catch {
/* */
}
this.geqPreampNode = null;
}
if (this.geqOutputNode) {
try {
this.geqOutputNode.disconnect();
} catch {
/* */
}
this.geqOutputNode = null;
}
}
toggleGraphicEQ(enabled) {
this.isGraphicEQEnabled = enabled;
equalizerSettings.setGraphicEqEnabled(enabled);
if (this.isInitialized) {
this._connectGraph();
}
}
setGraphicEqBandGain(bandIndex, gainDb) {
if (bandIndex < 0 || bandIndex >= this.geqBandCount) return;
this.geqGains[bandIndex] = Math.max(-30, Math.min(30, gainDb));
if (this.geqFilters[bandIndex] && this.audioContext) {
const now = this.audioContext.currentTime;
this.geqFilters[bandIndex].gain.setTargetAtTime(this.geqGains[bandIndex], now, 0.01);
}
equalizerSettings.setGraphicEqGains([...this.geqGains]);
}
setGraphicEqAllGains(gains) {
if (!Array.isArray(gains)) return;
const now = this.audioContext?.currentTime || 0;
gains.forEach((g, i) => {
if (i >= this.geqBandCount) return;
this.geqGains[i] = Math.max(-30, Math.min(30, g));
if (this.geqFilters[i]) {
this.geqFilters[i].gain.setTargetAtTime(this.geqGains[i], now, 0.01);
}
});
equalizerSettings.setGraphicEqGains([...this.geqGains]);
}
setGraphicEqBandCount(count) {
const newCount = Math.max(3, Math.min(32, parseInt(count, 10) || 16));
if (newCount === this.geqBandCount) return;
const oldGains = this.geqGains;
this.geqBandCount = newCount;
this.geqFrequencies = generateFrequencies(newCount, this.geqFreqRange.min, this.geqFreqRange.max);
this.geqGains = equalizerSettings._interpolateGains(oldGains, newCount);
equalizerSettings.setGraphicEqBandCount(newCount);
equalizerSettings.setGraphicEqGains(this.geqGains);
if (this.isInitialized && this.audioContext) {
this._destroyGraphicEQ();
this._createGraphicEQ();
this._connectGraph();
}
}
setGraphicEqFreqRange(minFreq, maxFreq) {
const newMin = Math.max(10, Math.min(96000, parseInt(minFreq, 10) || 25));
const newMax = Math.max(10, Math.min(96000, parseInt(maxFreq, 10) || 20000));
if (newMin >= newMax) return;
if (newMin === this.geqFreqRange.min && newMax === this.geqFreqRange.max) return;
this.geqFreqRange = { min: newMin, max: newMax };
this.geqFrequencies = generateFrequencies(this.geqBandCount, newMin, newMax);
equalizerSettings.setGraphicEqFreqRange(newMin, newMax);
if (this.isInitialized && this.audioContext) {
this._destroyGraphicEQ();
this._createGraphicEQ();
this._connectGraph();
}
}
getGraphicEqFrequencies() {
return this.geqFrequencies;
}
getGraphicEqBandCount() {
return this.geqBandCount;
}
setGraphicEqPreamp(db) {
this.geqPreamp = Math.max(-20, Math.min(20, parseFloat(db) || 0));
if (this.geqPreampNode && this.audioContext) {
const gainValue = Math.pow(10, this.geqPreamp / 20);
const now = this.audioContext.currentTime;
this.geqPreampNode.gain.setTargetAtTime(gainValue, now, 0.01);
}
equalizerSettings.setGraphicEqPreamp(this.geqPreamp);
}
}
// Export singleton instance
export const audioContextManager = new AudioContextManager();
// Export presets and helper functions for settings UI
export {
EQ_PRESETS,
generateFrequencies,
generateFrequencyLabels,
getPresetsForBandCount,
interpolatePreset,
EQ_PRESETS_16,
};