EQUALIZER FINALLY

This commit is contained in:
EduardPrigoana 2026-02-01 22:14:35 +02:00
parent b726a0b6bf
commit 47cc05e60e
8 changed files with 3497 additions and 2941 deletions

5101
index.html

File diff suppressed because one or more lines are too long

308
js/audio-context.js Normal file
View 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
View 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 };

View file

@ -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) || {

View file

@ -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) {

View file

@ -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',

View file

@ -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;

View file

@ -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;
}
}