EQUALIZER FINALLY
This commit is contained in:
parent
b726a0b6bf
commit
47cc05e60e
8 changed files with 3497 additions and 2941 deletions
5101
index.html
5101
index.html
File diff suppressed because one or more lines are too long
308
js/audio-context.js
Normal file
308
js/audio-context.js
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
// js/audio-context.js
|
||||||
|
// Shared Audio Context Manager - handles EQ and provides context for visualizer
|
||||||
|
|
||||||
|
import { equalizerSettings } 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.isInitialized = false;
|
||||||
|
this.isEQEnabled = false;
|
||||||
|
this.currentGains = new Array(16).fill(0);
|
||||||
|
this.audio = null;
|
||||||
|
|
||||||
|
// Load saved settings
|
||||||
|
this._loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.audio = audioElement;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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 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 enabled state
|
||||||
|
*/
|
||||||
|
_connectGraph() {
|
||||||
|
if (!this.source || !this.audioContext) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Disconnect everything first
|
||||||
|
this.source.disconnect();
|
||||||
|
this.outputNode.disconnect();
|
||||||
|
this.analyser.disconnect();
|
||||||
|
|
||||||
|
if (this.isEQEnabled && this.filters.length > 0) {
|
||||||
|
// EQ enabled: source -> EQ filters -> output -> analyser -> destination
|
||||||
|
this.source.connect(this.filters[0]);
|
||||||
|
this.outputNode.connect(this.analyser);
|
||||||
|
this.analyser.connect(this.audioContext.destination);
|
||||||
|
console.log('[AudioContext] EQ connected');
|
||||||
|
} else {
|
||||||
|
// EQ disabled: source -> analyser -> destination
|
||||||
|
this.source.connect(this.analyser);
|
||||||
|
this.analyser.connect(this.audioContext.destination);
|
||||||
|
console.log('[AudioContext] EQ bypassed');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AudioContext] Failed to connect graph:', e);
|
||||||
|
// Fallback: direct connection
|
||||||
|
try {
|
||||||
|
this.source.connect(this.audioContext.destination);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume audio context (required after user interaction)
|
||||||
|
*/
|
||||||
|
resume() {
|
||||||
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the analyser node for the visualizer
|
||||||
|
*/
|
||||||
|
getAnalyser() {
|
||||||
|
return this.analyser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the audio context
|
||||||
|
*/
|
||||||
|
getAudioContext() {
|
||||||
|
return this.audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if initialized
|
||||||
|
*/
|
||||||
|
isReady() {
|
||||||
|
return this.isInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const audioContextManager = new AudioContextManager();
|
||||||
|
|
||||||
|
// Export presets for settings UI
|
||||||
|
export { EQ_PRESETS };
|
||||||
359
js/equalizer.js
Normal file
359
js/equalizer.js
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
// js/equalizer.js
|
||||||
|
// 16-Band Parametric Equalizer with Web Audio API
|
||||||
|
|
||||||
|
import { equalizerSettings } 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
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frequency labels for UI display
|
||||||
|
const FREQUENCY_LABELS = [
|
||||||
|
'25', '40', '63', '100', '160', '250', '400', '630',
|
||||||
|
'1K', '1.6K', '2.5K', '4K', '6.3K', '10K', '16K', '20K'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Equalizer {
|
||||||
|
constructor() {
|
||||||
|
this.audioContext = null;
|
||||||
|
this.source = null;
|
||||||
|
this.filters = [];
|
||||||
|
this.inputNode = null;
|
||||||
|
this.outputNode = null;
|
||||||
|
this.isEnabled = false;
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.audio = null;
|
||||||
|
|
||||||
|
// Store current gains
|
||||||
|
this.currentGains = new Array(16).fill(0);
|
||||||
|
|
||||||
|
// Load saved settings
|
||||||
|
this._loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the equalizer with a shared AudioContext
|
||||||
|
* This should be called after the visualizer creates the context
|
||||||
|
* @param {AudioContext} audioContext - Shared audio context
|
||||||
|
* @param {AudioNode} sourceNode - The MediaElementSource node
|
||||||
|
* @param {HTMLAudioElement} audioElement - The audio element
|
||||||
|
*/
|
||||||
|
init(audioContext, sourceNode, audioElement) {
|
||||||
|
if (this.isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.audioContext = audioContext;
|
||||||
|
this.source = sourceNode;
|
||||||
|
this.audio = audioElement;
|
||||||
|
|
||||||
|
// Create 16 biquad filters for each frequency band
|
||||||
|
this.filters = EQ_FREQUENCIES.map((freq, index) => {
|
||||||
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
|
|
||||||
|
// Use peaking filter for all bands (best for EQ)
|
||||||
|
filter.type = 'peaking';
|
||||||
|
filter.frequency.value = freq;
|
||||||
|
filter.Q.value = this._calculateQ(index);
|
||||||
|
filter.gain.value = this.currentGains[index];
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create input/output gain nodes for bypass switching
|
||||||
|
this.inputNode = this.audioContext.createGain();
|
||||||
|
this.outputNode = this.audioContext.createGain();
|
||||||
|
|
||||||
|
// Connect the filter chain
|
||||||
|
this._connectFilters();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
|
||||||
|
// Apply saved enabled state
|
||||||
|
if (this.isEnabled) {
|
||||||
|
this._enableFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Equalizer] Initialized with 16 bands');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Equalizer] Init failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Q factor for each band
|
||||||
|
* Using constant-Q design for consistent bandwidth
|
||||||
|
*/
|
||||||
|
_calculateQ(index) {
|
||||||
|
// For 16-band 1/2 octave spacing, Q ≈ 2.87
|
||||||
|
// Slightly lower Q for smoother response
|
||||||
|
return 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect all filters in series
|
||||||
|
*/
|
||||||
|
_connectFilters() {
|
||||||
|
if (!this.filters.length) return;
|
||||||
|
|
||||||
|
// Chain filters together
|
||||||
|
for (let i = 0; i < this.filters.length - 1; i++) {
|
||||||
|
this.filters[i].connect(this.filters[i + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect last filter to output
|
||||||
|
this.filters[this.filters.length - 1].connect(this.outputNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the EQ processing
|
||||||
|
*/
|
||||||
|
_enableFilters() {
|
||||||
|
if (!this.isInitialized || !this.source) return;
|
||||||
|
|
||||||
|
// Note: The actual connection handling is done by the visualizer
|
||||||
|
// This just marks the EQ as enabled
|
||||||
|
this.isEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable the EQ (bypass)
|
||||||
|
*/
|
||||||
|
_disableFilters() {
|
||||||
|
this.isEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the input node for external connection
|
||||||
|
*/
|
||||||
|
getInputNode() {
|
||||||
|
return this.filters[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the output node
|
||||||
|
*/
|
||||||
|
getOutputNode() {
|
||||||
|
return this.outputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if EQ is active (enabled and initialized)
|
||||||
|
*/
|
||||||
|
isActive() {
|
||||||
|
return this.isInitialized && this.isEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle EQ on/off
|
||||||
|
*/
|
||||||
|
toggle(enabled) {
|
||||||
|
this.isEnabled = enabled;
|
||||||
|
equalizerSettings.setEnabled(enabled);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
this._enableFilters();
|
||||||
|
} else {
|
||||||
|
this._disableFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event for visualizer to reconnect
|
||||||
|
window.dispatchEvent(new CustomEvent('equalizer-toggle', {
|
||||||
|
detail: { enabled }
|
||||||
|
}));
|
||||||
|
|
||||||
|
return this.isEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set gain for a specific band
|
||||||
|
* @param {number} bandIndex - Band index (0-15)
|
||||||
|
* @param {number} gainDb - Gain in dB (-12 to +12)
|
||||||
|
*/
|
||||||
|
setBandGain(bandIndex, gainDb) {
|
||||||
|
if (bandIndex < 0 || bandIndex >= 16) return;
|
||||||
|
|
||||||
|
// Clamp gain to valid range
|
||||||
|
const clampedGain = Math.max(-30, Math.min(30, gainDb));
|
||||||
|
this.currentGains[bandIndex] = clampedGain;
|
||||||
|
|
||||||
|
if (this.filters[bandIndex]) {
|
||||||
|
// Smooth transition for clicks prevention
|
||||||
|
const now = this.audioContext?.currentTime || 0;
|
||||||
|
this.filters[bandIndex].gain.setTargetAtTime(clampedGain, now, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
equalizerSettings.setGains(this.currentGains);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set all band gains at once
|
||||||
|
* @param {number[]} gains - Array of 16 gain values in dB
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param {string} presetKey - Key from EQ_PRESETS
|
||||||
|
*/
|
||||||
|
applyPreset(presetKey) {
|
||||||
|
const preset = EQ_PRESETS[presetKey];
|
||||||
|
if (!preset) return;
|
||||||
|
|
||||||
|
this.setAllGains(preset.gains);
|
||||||
|
equalizerSettings.setPreset(presetKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all bands to flat (0 dB)
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.setAllGains(new Array(16).fill(0));
|
||||||
|
equalizerSettings.setPreset('flat');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current gains
|
||||||
|
* @returns {number[]} Array of 16 gain values
|
||||||
|
*/
|
||||||
|
getGains() {
|
||||||
|
return [...this.currentGains];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get frequency labels
|
||||||
|
*/
|
||||||
|
static getFrequencyLabels() {
|
||||||
|
return FREQUENCY_LABELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get frequencies
|
||||||
|
*/
|
||||||
|
static getFrequencies() {
|
||||||
|
return EQ_FREQUENCIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available presets
|
||||||
|
*/
|
||||||
|
static getPresets() {
|
||||||
|
return EQ_PRESETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load settings from storage
|
||||||
|
*/
|
||||||
|
_loadSettings() {
|
||||||
|
this.isEnabled = equalizerSettings.isEnabled();
|
||||||
|
this.currentGains = equalizerSettings.getGains();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the equalizer
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.filters.forEach(filter => {
|
||||||
|
try { filter.disconnect(); } catch { }
|
||||||
|
});
|
||||||
|
|
||||||
|
try { this.inputNode?.disconnect(); } catch { }
|
||||||
|
try { this.outputNode?.disconnect(); } catch { }
|
||||||
|
|
||||||
|
this.filters = [];
|
||||||
|
this.inputNode = null;
|
||||||
|
this.outputNode = null;
|
||||||
|
this.isInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const equalizer = new Equalizer();
|
||||||
|
|
||||||
|
// Export constants
|
||||||
|
export { EQ_FREQUENCIES, FREQUENCY_LABELS, EQ_PRESETS };
|
||||||
83
js/events.js
83
js/events.js
|
|
@ -17,6 +17,7 @@ import { updateTabTitle, navigate } from './router.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { syncManager } from './accounts/pocketbase.js';
|
import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { waveformGenerator } from './waveform.js';
|
import { waveformGenerator } from './waveform.js';
|
||||||
|
import { audioContextManager } from './audio-context.js';
|
||||||
|
|
||||||
let currentTrackIdForWaveform = null;
|
let currentTrackIdForWaveform = null;
|
||||||
|
|
||||||
|
|
@ -52,6 +53,12 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
}
|
}
|
||||||
|
|
||||||
audioPlayer.addEventListener('play', () => {
|
audioPlayer.addEventListener('play', () => {
|
||||||
|
// Initialize audio context manager for EQ (only once)
|
||||||
|
if (!audioContextManager.isReady()) {
|
||||||
|
audioContextManager.init(audioPlayer);
|
||||||
|
}
|
||||||
|
audioContextManager.resume();
|
||||||
|
|
||||||
if (player.currentTrack) {
|
if (player.currentTrack) {
|
||||||
// Scrobble
|
// Scrobble
|
||||||
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
|
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
|
||||||
|
|
@ -611,11 +618,10 @@ export async function showAddToPlaylistModal(track) {
|
||||||
return `
|
return `
|
||||||
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
|
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
|
||||||
<span>${p.name}</span>
|
<span>${p.name}</span>
|
||||||
${
|
${alreadyContains
|
||||||
alreadyContains
|
|
||||||
? `<button class="remove-from-playlist-btn-modal" title="Remove from playlist" style="background: transparent; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center;">${SVG_BIN}</button>`
|
? `<button class="remove-from-playlist-btn-modal" title="Remove from playlist" style="background: transparent; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center;">${SVG_BIN}</button>`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
|
|
@ -962,11 +968,10 @@ export async function handleTrackAction(
|
||||||
return `
|
return `
|
||||||
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
|
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
|
||||||
<span>${p.name}</span>
|
<span>${p.name}</span>
|
||||||
${
|
${alreadyContains
|
||||||
alreadyContains
|
|
||||||
? `<button class="remove-from-playlist-btn-modal" title="Remove from playlist" style="background: transparent; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center;">${SVG_BIN}</button>`
|
? `<button class="remove-from-playlist-btn-modal" title="Remove from playlist" style="background: transparent; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center;">${SVG_BIN}</button>`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
|
|
@ -1085,31 +1090,28 @@ export async function handleTrackAction(
|
||||||
${item.trackerInfo.recordingDate ? `<p><strong style="color: var(--foreground);">Recording Date:</strong> ${new Date(item.trackerInfo.recordingDate).toLocaleDateString()}</p>` : ''}
|
${item.trackerInfo.recordingDate ? `<p><strong style="color: var(--foreground);">Recording Date:</strong> ${new Date(item.trackerInfo.recordingDate).toLocaleDateString()}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${
|
${item.trackerInfo.description
|
||||||
item.trackerInfo.description
|
? `
|
||||||
? `
|
|
||||||
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Description</p>
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Description</p>
|
||||||
<p style="font-size: 0.85rem; line-height: 1.6;">${item.trackerInfo.description}</p>
|
<p style="font-size: 0.85rem; line-height: 1.6;">${item.trackerInfo.description}</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
${
|
${item.trackerInfo.notes
|
||||||
item.trackerInfo.notes
|
? `
|
||||||
? `
|
|
||||||
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Notes</p>
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Notes</p>
|
||||||
<p style="font-size: 0.85rem; line-height: 1.6;">${item.trackerInfo.notes}</p>
|
<p style="font-size: 0.85rem; line-height: 1.6;">${item.trackerInfo.notes}</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
${
|
${item.trackerInfo.sourceUrl
|
||||||
item.trackerInfo.sourceUrl
|
? `
|
||||||
? `
|
|
||||||
<div style="margin-top: 1rem;">
|
<div style="margin-top: 1rem;">
|
||||||
<p style="margin-bottom: 0.5rem;"><strong style="color: var(--foreground);">Source URL:</strong></p>
|
<p style="margin-bottom: 0.5rem;"><strong style="color: var(--foreground);">Source URL:</strong></p>
|
||||||
<a href="${item.trackerInfo.sourceUrl}" target="_blank" style="color: var(--primary); word-break: break-all; font-size: 0.85rem; display: block; padding: 0.5rem; background: var(--accent); border-radius: 6px; text-decoration: none;">
|
<a href="${item.trackerInfo.sourceUrl}" target="_blank" style="color: var(--primary); word-break: break-all; font-size: 0.85rem; display: block; padding: 0.5rem; background: var(--accent); border-radius: 6px; text-decoration: none;">
|
||||||
|
|
@ -1117,8 +1119,8 @@ export async function handleTrackAction(
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${item.id}</p>` : ''}
|
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${item.id}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1149,9 +1151,8 @@ export async function handleTrackAction(
|
||||||
<p><strong style="color: var(--foreground);">Quality:</strong> ${quality} ${bitrate ? `(${bitrate})` : ''}</p>
|
<p><strong style="color: var(--foreground);">Quality:</strong> ${quality} ${bitrate ? `(${bitrate})` : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${
|
${item.credits && item.credits.length > 0
|
||||||
item.credits && item.credits.length > 0
|
? `
|
||||||
? `
|
|
||||||
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Credits</p>
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Credits</p>
|
||||||
<div style="font-size: 0.85rem; line-height: 1.6;">
|
<div style="font-size: 0.85rem; line-height: 1.6;">
|
||||||
|
|
@ -1159,26 +1160,24 @@ export async function handleTrackAction(
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
${
|
${item.composers && item.composers.length > 0
|
||||||
item.composers && item.composers.length > 0
|
? `
|
||||||
? `
|
|
||||||
<p style="margin-top: 0.5rem;"><strong style="color: var(--foreground);">Composers:</strong> ${item.composers.map((c) => c.name).join(', ')}</p>
|
<p style="margin-top: 0.5rem;"><strong style="color: var(--foreground);">Composers:</strong> ${item.composers.map((c) => c.name).join(', ')}</p>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
${
|
${item.lyrics?.text
|
||||||
item.lyrics?.text
|
? `
|
||||||
? `
|
|
||||||
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Has Lyrics</p>
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Has Lyrics</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${item.id}</p>` : ''}
|
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${item.id}</p>` : ''}
|
||||||
${item.album?.id ? `<p style="font-size: 0.8rem; color: var(--muted);"><strong>Album ID:</strong> ${item.album.id}</p>` : ''}
|
${item.album?.id ? `<p style="font-size: 0.8rem; color: var(--muted);"><strong>Album ID:</strong> ${item.album.id}</p>` : ''}
|
||||||
|
|
@ -1453,12 +1452,12 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
const type = card.dataset.albumId
|
const type = card.dataset.albumId
|
||||||
? 'album'
|
? 'album'
|
||||||
: card.dataset.playlistId
|
: card.dataset.playlistId
|
||||||
? 'playlist'
|
? 'playlist'
|
||||||
: card.dataset.mixId
|
: card.dataset.mixId
|
||||||
? 'mix'
|
? 'mix'
|
||||||
: card.dataset.href
|
: card.dataset.href
|
||||||
? card.dataset.href.split('/')[1]
|
? card.dataset.href.split('/')[1]
|
||||||
: 'item';
|
: 'item';
|
||||||
const id = card.dataset.albumId || card.dataset.playlistId || card.dataset.mixId;
|
const id = card.dataset.albumId || card.dataset.playlistId || card.dataset.mixId;
|
||||||
|
|
||||||
const item = trackDataStore.get(card) || {
|
const item = trackDataStore.get(card) || {
|
||||||
|
|
|
||||||
140
js/settings.js
140
js/settings.js
|
|
@ -15,7 +15,9 @@ import {
|
||||||
visualizerSettings,
|
visualizerSettings,
|
||||||
bulkDownloadSettings,
|
bulkDownloadSettings,
|
||||||
playlistSettings,
|
playlistSettings,
|
||||||
|
equalizerSettings,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
|
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { authManager } from './accounts/auth.js';
|
import { authManager } from './accounts/auth.js';
|
||||||
import { syncManager } from './accounts/pocketbase.js';
|
import { syncManager } from './accounts/pocketbase.js';
|
||||||
|
|
@ -99,7 +101,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await authManager.sendPasswordReset(email);
|
await authManager.sendPasswordReset(email);
|
||||||
} catch {}
|
} catch { }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,6 +342,142 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 16-Band Equalizer Settings
|
||||||
|
// ========================================
|
||||||
|
const eqToggle = document.getElementById('equalizer-enabled-toggle');
|
||||||
|
const eqContainer = document.getElementById('equalizer-container');
|
||||||
|
const eqPresetSelect = document.getElementById('equalizer-preset-select');
|
||||||
|
const eqResetBtn = document.getElementById('equalizer-reset-btn');
|
||||||
|
const eqBands = document.querySelectorAll('.eq-band');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the visual display of a band value
|
||||||
|
*/
|
||||||
|
const updateBandValueDisplay = (bandEl, value) => {
|
||||||
|
const valueEl = bandEl.querySelector('.eq-value');
|
||||||
|
if (!valueEl) return;
|
||||||
|
|
||||||
|
const displayValue = value > 0 ? `+${value}` : value.toString();
|
||||||
|
valueEl.textContent = displayValue;
|
||||||
|
|
||||||
|
// Add color classes based on value
|
||||||
|
valueEl.classList.remove('positive', 'negative');
|
||||||
|
if (value > 0) {
|
||||||
|
valueEl.classList.add('positive');
|
||||||
|
} else if (value < 0) {
|
||||||
|
valueEl.classList.add('negative');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all band sliders and displays from an array of gains
|
||||||
|
*/
|
||||||
|
const updateAllBandUI = (gains) => {
|
||||||
|
eqBands.forEach((bandEl, index) => {
|
||||||
|
const slider = bandEl.querySelector('.eq-slider');
|
||||||
|
if (slider && gains[index] !== undefined) {
|
||||||
|
slider.value = gains[index];
|
||||||
|
updateBandValueDisplay(bandEl, gains[index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle EQ container visibility
|
||||||
|
*/
|
||||||
|
const updateEQContainerVisibility = (enabled) => {
|
||||||
|
if (eqContainer) {
|
||||||
|
eqContainer.style.display = enabled ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize EQ toggle
|
||||||
|
if (eqToggle) {
|
||||||
|
const isEnabled = equalizerSettings.isEnabled();
|
||||||
|
eqToggle.checked = isEnabled;
|
||||||
|
updateEQContainerVisibility(isEnabled);
|
||||||
|
|
||||||
|
eqToggle.addEventListener('change', (e) => {
|
||||||
|
const enabled = e.target.checked;
|
||||||
|
audioContextManager.toggleEQ(enabled);
|
||||||
|
updateEQContainerVisibility(enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize preset selector
|
||||||
|
if (eqPresetSelect) {
|
||||||
|
eqPresetSelect.value = equalizerSettings.getPreset();
|
||||||
|
|
||||||
|
eqPresetSelect.addEventListener('change', (e) => {
|
||||||
|
const presetKey = e.target.value;
|
||||||
|
const preset = EQ_PRESETS[presetKey];
|
||||||
|
|
||||||
|
if (preset) {
|
||||||
|
audioContextManager.applyPreset(presetKey);
|
||||||
|
updateAllBandUI(preset.gains);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize reset button
|
||||||
|
if (eqResetBtn) {
|
||||||
|
eqResetBtn.addEventListener('click', () => {
|
||||||
|
audioContextManager.reset();
|
||||||
|
updateAllBandUI(new Array(16).fill(0));
|
||||||
|
if (eqPresetSelect) {
|
||||||
|
eqPresetSelect.value = 'flat';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all band sliders
|
||||||
|
if (eqBands.length > 0) {
|
||||||
|
const savedGains = equalizerSettings.getGains();
|
||||||
|
|
||||||
|
eqBands.forEach((bandEl) => {
|
||||||
|
const bandIndex = parseInt(bandEl.dataset.band, 10);
|
||||||
|
const slider = bandEl.querySelector('.eq-slider');
|
||||||
|
|
||||||
|
if (slider && !isNaN(bandIndex)) {
|
||||||
|
// Set initial value from saved settings
|
||||||
|
const initialGain = savedGains[bandIndex] ?? 0;
|
||||||
|
slider.value = initialGain;
|
||||||
|
updateBandValueDisplay(bandEl, initialGain);
|
||||||
|
|
||||||
|
// Handle slider input
|
||||||
|
slider.addEventListener('input', (e) => {
|
||||||
|
const gain = parseFloat(e.target.value);
|
||||||
|
audioContextManager.setBandGain(bandIndex, gain);
|
||||||
|
updateBandValueDisplay(bandEl, gain);
|
||||||
|
|
||||||
|
// When manually adjusting, switch preset to 'flat' (custom)
|
||||||
|
// to indicate the user has made custom changes
|
||||||
|
if (eqPresetSelect && eqPresetSelect.value !== 'flat') {
|
||||||
|
// Check if current gains still match the selected preset
|
||||||
|
const currentPreset = EQ_PRESETS[eqPresetSelect.value];
|
||||||
|
if (currentPreset) {
|
||||||
|
const currentGains = audioContextManager.getGains();
|
||||||
|
const matches = currentPreset.gains.every(
|
||||||
|
(g, i) => Math.abs(g - currentGains[i]) < 0.01
|
||||||
|
);
|
||||||
|
if (!matches) {
|
||||||
|
// Don't change the select, but the preset will save as 'custom'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Double-click to reset individual band to 0
|
||||||
|
slider.addEventListener('dblclick', () => {
|
||||||
|
slider.value = 0;
|
||||||
|
audioContextManager.setBandGain(bandIndex, 0);
|
||||||
|
updateBandValueDisplay(bandEl, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Now Playing Mode
|
// Now Playing Mode
|
||||||
const nowPlayingMode = document.getElementById('now-playing-mode');
|
const nowPlayingMode = document.getElementById('now-playing-mode');
|
||||||
if (nowPlayingMode) {
|
if (nowPlayingMode) {
|
||||||
|
|
|
||||||
|
|
@ -757,6 +757,61 @@ export const visualizerSettings = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const equalizerSettings = {
|
||||||
|
ENABLED_KEY: 'equalizer-enabled',
|
||||||
|
GAINS_KEY: 'equalizer-gains',
|
||||||
|
PRESET_KEY: 'equalizer-preset',
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
try {
|
||||||
|
// Disabled by default
|
||||||
|
return localStorage.getItem(this.ENABLED_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setEnabled(enabled) {
|
||||||
|
localStorage.setItem(this.ENABLED_KEY, enabled ? 'true' : 'false');
|
||||||
|
},
|
||||||
|
|
||||||
|
getGains() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.GAINS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const gains = JSON.parse(stored);
|
||||||
|
if (Array.isArray(gains) && gains.length === 16) {
|
||||||
|
return gains;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
// Return flat EQ (all zeros) by default
|
||||||
|
return new Array(16).fill(0);
|
||||||
|
},
|
||||||
|
|
||||||
|
setGains(gains) {
|
||||||
|
try {
|
||||||
|
if (Array.isArray(gains) && gains.length === 16) {
|
||||||
|
localStorage.setItem(this.GAINS_KEY, JSON.stringify(gains));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EQ] Failed to save gains:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getPreset() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(this.PRESET_KEY) || 'flat';
|
||||||
|
} catch {
|
||||||
|
return 'flat';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setPreset(preset) {
|
||||||
|
localStorage.setItem(this.PRESET_KEY, preset);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const queueManager = {
|
export const queueManager = {
|
||||||
STORAGE_KEY: 'monochrome-queue',
|
STORAGE_KEY: 'monochrome-queue',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { visualizerSettings } from './storage.js';
|
||||||
import { LCDPreset } from './visualizers/lcd.js';
|
import { LCDPreset } from './visualizers/lcd.js';
|
||||||
import { ParticlesPreset } from './visualizers/particles.js';
|
import { ParticlesPreset } from './visualizers/particles.js';
|
||||||
import { UnknownPleasuresPreset } from './visualizers/unknown_pleasures.js';
|
import { UnknownPleasuresPreset } from './visualizers/unknown_pleasures.js';
|
||||||
|
import { equalizer } from './equalizer.js';
|
||||||
|
|
||||||
export class Visualizer {
|
export class Visualizer {
|
||||||
constructor(canvas, audio) {
|
constructor(canvas, audio) {
|
||||||
|
|
@ -45,6 +46,11 @@ export class Visualizer {
|
||||||
// ---- CACHED STATE ----
|
// ---- CACHED STATE ----
|
||||||
this._lastPrimaryColor = '';
|
this._lastPrimaryColor = '';
|
||||||
this._resizeBound = () => this.resize();
|
this._resizeBound = () => this.resize();
|
||||||
|
|
||||||
|
// Listen for EQ toggle events to reconnect audio graph
|
||||||
|
window.addEventListener('equalizer-toggle', () => {
|
||||||
|
this._reconnectAudioGraph();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get activePreset() {
|
get activePreset() {
|
||||||
|
|
@ -66,13 +72,73 @@ export class Visualizer {
|
||||||
this.dataArray = new Uint8Array(this.bufferLength);
|
this.dataArray = new Uint8Array(this.bufferLength);
|
||||||
|
|
||||||
this.source = this.audioContext.createMediaElementSource(this.audio);
|
this.source = this.audioContext.createMediaElementSource(this.audio);
|
||||||
this.source.connect(this.analyser);
|
|
||||||
this.analyser.connect(this.audioContext.destination);
|
// Initialize equalizer with shared context
|
||||||
|
equalizer.init(this.audioContext, this.source, this.audio);
|
||||||
|
|
||||||
|
// Connect audio graph with EQ if enabled
|
||||||
|
this._reconnectAudioGraph();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Visualizer init failed:', e);
|
console.warn('Visualizer init failed:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect the audio graph based on EQ state
|
||||||
|
* Audio chain: source -> [EQ filters] -> analyser -> destination
|
||||||
|
*/
|
||||||
|
_reconnectAudioGraph() {
|
||||||
|
if (!this.source || !this.analyser || !this.audioContext) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Disconnect the source from its current connections
|
||||||
|
this.source.disconnect();
|
||||||
|
|
||||||
|
if (equalizer.isActive()) {
|
||||||
|
// Route through EQ: source -> EQ -> analyser -> destination
|
||||||
|
const eqInput = equalizer.getInputNode();
|
||||||
|
const eqOutput = equalizer.getOutputNode();
|
||||||
|
|
||||||
|
if (eqInput && eqOutput) {
|
||||||
|
this.source.connect(eqInput);
|
||||||
|
eqOutput.connect(this.analyser);
|
||||||
|
this.analyser.connect(this.audioContext.destination);
|
||||||
|
console.log('[Audio] EQ enabled in audio chain');
|
||||||
|
} else {
|
||||||
|
// Fallback if EQ nodes aren't ready
|
||||||
|
this.source.connect(this.analyser);
|
||||||
|
this.analyser.connect(this.audioContext.destination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Bypass EQ: source -> analyser -> destination
|
||||||
|
this.source.connect(this.analyser);
|
||||||
|
this.analyser.connect(this.audioContext.destination);
|
||||||
|
console.log('[Audio] EQ bypassed');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Audio] Failed to reconnect audio graph:', e);
|
||||||
|
// Attempt simple reconnect as fallback
|
||||||
|
try {
|
||||||
|
this.source.connect(this.analyser);
|
||||||
|
this.analyser.connect(this.audioContext.destination);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the shared AudioContext for external use
|
||||||
|
*/
|
||||||
|
getAudioContext() {
|
||||||
|
return this.audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the source node
|
||||||
|
*/
|
||||||
|
getSourceNode() {
|
||||||
|
return this.source;
|
||||||
|
}
|
||||||
|
|
||||||
initContext() {
|
initContext() {
|
||||||
if (this.ctx) return;
|
if (this.ctx) return;
|
||||||
|
|
||||||
|
|
|
||||||
322
styles.css
322
styles.css
|
|
@ -5067,4 +5067,326 @@ textarea:focus {
|
||||||
fill: #ef4444;
|
fill: #ef4444;
|
||||||
/* Standardize heart red */
|
/* Standardize heart red */
|
||||||
stroke: #ef4444;
|
stroke: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
16-Band Equalizer
|
||||||
|
========================================= */
|
||||||
|
|
||||||
|
.equalizer-container {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background: linear-gradient(145deg, var(--card), rgba(var(--highlight-rgb), 0.03));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-header {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-preset-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-preset-row label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-preset-row select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
max-width: 250px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-preset-row select:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-preset-row select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ring);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--highlight-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#equalizer-reset-btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
#equalizer-reset-btn:hover {
|
||||||
|
background: var(--card);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
#equalizer-reset-btn svg {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#equalizer-reset-btn:hover svg {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-bands {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
padding: var(--spacing-md) 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zero line indicator */
|
||||||
|
.equalizer-bands::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-band {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical slider styling */
|
||||||
|
.eq-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
direction: rtl;
|
||||||
|
width: 8px;
|
||||||
|
height: 120px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track */
|
||||||
|
.eq-slider::-webkit-slider-runnable-track {
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to top, var(--muted), var(--input));
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-moz-range-track {
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to top, var(--muted), var(--input));
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumb */
|
||||||
|
.eq-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: linear-gradient(145deg, var(--primary), var(--highlight));
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: grab;
|
||||||
|
margin-left: -6px;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 2px rgba(255, 255, 255, 0.3);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
border: 2px solid var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: linear-gradient(145deg, var(--primary), var(--highlight));
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: grab;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 2px rgba(255, 255, 255, 0.3);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
border: 2px solid var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(var(--highlight-rgb), 0.4),
|
||||||
|
inset 0 1px 2px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(var(--highlight-rgb), 0.4),
|
||||||
|
inset 0 1px 2px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-webkit-slider-thumb:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-moz-range-thumb:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider:focus::-webkit-slider-thumb {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(var(--highlight-rgb), 0.3),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider:focus::-moz-range-thumb {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(var(--highlight-rgb), 0.3),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-value {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background: var(--input);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-value.positive {
|
||||||
|
color: var(--highlight);
|
||||||
|
background: rgba(var(--highlight-rgb), 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-value.negative {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-freq {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-scale {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: var(--spacing-sm);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-scale span {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.equalizer-container {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-bands {
|
||||||
|
gap: 2px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--spacing-sm);
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-band {
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider {
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-webkit-slider-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-freq {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-value {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.equalizer-preset-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-preset-row select {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-preset-row label {
|
||||||
|
margin-bottom: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider {
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-band {
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue