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 { syncManager } from './accounts/pocketbase.js';
|
||||
import { waveformGenerator } from './waveform.js';
|
||||
import { audioContextManager } from './audio-context.js';
|
||||
|
||||
let currentTrackIdForWaveform = null;
|
||||
|
||||
|
|
@ -52,6 +53,12 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
}
|
||||
|
||||
audioPlayer.addEventListener('play', () => {
|
||||
// Initialize audio context manager for EQ (only once)
|
||||
if (!audioContextManager.isReady()) {
|
||||
audioContextManager.init(audioPlayer);
|
||||
}
|
||||
audioContextManager.resume();
|
||||
|
||||
if (player.currentTrack) {
|
||||
// Scrobble
|
||||
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
|
||||
|
|
@ -611,11 +618,10 @@ export async function showAddToPlaylistModal(track) {
|
|||
return `
|
||||
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
|
||||
<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>`
|
||||
: ''
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
|
|
@ -962,11 +968,10 @@ export async function handleTrackAction(
|
|||
return `
|
||||
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
|
||||
<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>`
|
||||
: ''
|
||||
}
|
||||
}
|
||||
</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>` : ''}
|
||||
</div>
|
||||
|
||||
${
|
||||
item.trackerInfo.description
|
||||
? `
|
||||
${item.trackerInfo.description
|
||||
? `
|
||||
<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="font-size: 0.85rem; line-height: 1.6;">${item.trackerInfo.description}</p>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
item.trackerInfo.notes
|
||||
? `
|
||||
${item.trackerInfo.notes
|
||||
? `
|
||||
<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="font-size: 0.85rem; line-height: 1.6;">${item.trackerInfo.notes}</p>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
item.trackerInfo.sourceUrl
|
||||
? `
|
||||
${item.trackerInfo.sourceUrl
|
||||
? `
|
||||
<div style="margin-top: 1rem;">
|
||||
<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;">
|
||||
|
|
@ -1117,8 +1119,8 @@ export async function handleTrackAction(
|
|||
</a>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: ''
|
||||
}
|
||||
|
||||
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${item.id}</p>` : ''}
|
||||
</div>
|
||||
|
|
@ -1149,9 +1151,8 @@ export async function handleTrackAction(
|
|||
<p><strong style="color: var(--foreground);">Quality:</strong> ${quality} ${bitrate ? `(${bitrate})` : ''}</p>
|
||||
</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;">
|
||||
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Credits</p>
|
||||
<div style="font-size: 0.85rem; line-height: 1.6;">
|
||||
|
|
@ -1159,26 +1160,24 @@ export async function handleTrackAction(
|
|||
</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>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
item.lyrics?.text
|
||||
? `
|
||||
${item.lyrics?.text
|
||||
? `
|
||||
<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>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: ''
|
||||
}
|
||||
|
||||
${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>` : ''}
|
||||
|
|
@ -1453,12 +1452,12 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
const type = card.dataset.albumId
|
||||
? 'album'
|
||||
: card.dataset.playlistId
|
||||
? 'playlist'
|
||||
: card.dataset.mixId
|
||||
? 'mix'
|
||||
: card.dataset.href
|
||||
? card.dataset.href.split('/')[1]
|
||||
: 'item';
|
||||
? 'playlist'
|
||||
: card.dataset.mixId
|
||||
? 'mix'
|
||||
: card.dataset.href
|
||||
? card.dataset.href.split('/')[1]
|
||||
: 'item';
|
||||
const id = card.dataset.albumId || card.dataset.playlistId || card.dataset.mixId;
|
||||
|
||||
const item = trackDataStore.get(card) || {
|
||||
|
|
|
|||
140
js/settings.js
140
js/settings.js
|
|
@ -15,7 +15,9 @@ import {
|
|||
visualizerSettings,
|
||||
bulkDownloadSettings,
|
||||
playlistSettings,
|
||||
equalizerSettings,
|
||||
} from './storage.js';
|
||||
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
||||
import { db } from './db.js';
|
||||
import { authManager } from './accounts/auth.js';
|
||||
import { syncManager } from './accounts/pocketbase.js';
|
||||
|
|
@ -99,7 +101,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
}
|
||||
try {
|
||||
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
|
||||
const nowPlayingMode = document.getElementById('now-playing-mode');
|
||||
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 = {
|
||||
STORAGE_KEY: 'monochrome-queue',
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { visualizerSettings } from './storage.js';
|
|||
import { LCDPreset } from './visualizers/lcd.js';
|
||||
import { ParticlesPreset } from './visualizers/particles.js';
|
||||
import { UnknownPleasuresPreset } from './visualizers/unknown_pleasures.js';
|
||||
import { equalizer } from './equalizer.js';
|
||||
|
||||
export class Visualizer {
|
||||
constructor(canvas, audio) {
|
||||
|
|
@ -45,6 +46,11 @@ export class Visualizer {
|
|||
// ---- CACHED STATE ----
|
||||
this._lastPrimaryColor = '';
|
||||
this._resizeBound = () => this.resize();
|
||||
|
||||
// Listen for EQ toggle events to reconnect audio graph
|
||||
window.addEventListener('equalizer-toggle', () => {
|
||||
this._reconnectAudioGraph();
|
||||
});
|
||||
}
|
||||
|
||||
get activePreset() {
|
||||
|
|
@ -66,13 +72,73 @@ export class Visualizer {
|
|||
this.dataArray = new Uint8Array(this.bufferLength);
|
||||
|
||||
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) {
|
||||
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() {
|
||||
if (this.ctx) return;
|
||||
|
||||
|
|
|
|||
322
styles.css
322
styles.css
|
|
@ -5067,4 +5067,326 @@ textarea:focus {
|
|||
fill: #ef4444;
|
||||
/* Standardize heart red */
|
||||
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