diff --git a/src/assets/icons/equalizer.svg b/src/assets/icons/equalizer.svg new file mode 100644 index 0000000..e7e27da --- /dev/null +++ b/src/assets/icons/equalizer.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/BottomBar/Equalizer.vue b/src/components/BottomBar/Equalizer.vue new file mode 100644 index 0000000..43919e0 --- /dev/null +++ b/src/components/BottomBar/Equalizer.vue @@ -0,0 +1,547 @@ + + + + + + + + + + + {{ eq.enabled ? 'ON' : 'OFF' }} + + + + {{ selectedPreset }} + + + + + {{ preset }} + + + + + + + + + + + + + +12 + 0 + -12 + + handleBandWheel(index, e)"> + {{ displayBands[index] > 0 ? '+' : '' }}{{ displayBands[index].toFixed(1) }} + changeBandGain(index, e)" + class="eq-slider" + :style="{ + background: `linear-gradient(to top, #ffffff ${((displayBands[index] + 12) / 24) * 100}%, rgba(255, 255, 255, 0.1) ${((displayBands[index] + 12) / 24) * 100}%)` + }" + /> + {{ formatFreq(frequencies[index]) }} + + + +12 + 0 + -12 + + + + + + + + + diff --git a/src/components/BottomBar/Right.vue b/src/components/BottomBar/Right.vue index 72f8f31..2f16d9b 100644 --- a/src/components/BottomBar/Right.vue +++ b/src/components/BottomBar/Right.vue @@ -1,6 +1,7 @@ + ({ + enabled: false, // EQ starts disabled + bands: [0, 0, 0, 0, 0, 0, 0, 0], // 8 bands, all at 0dB initially + currentPreset: 'Flat', + }), + + getters: { + /** + * Check if any band is modified from flat (0dB) + */ + isModified(): boolean { + return this.bands.some(gain => gain !== 0) + }, + + /** + * Get available presets + */ + availablePresets(): string[] { + return EQ_PRESETS.map(p => p.name) + }, + }, + + actions: { + /** + * Set gain for a specific band + * Auto-enables EQ if user changes any band + */ + setBandGain(bandIndex: number, gainDB: number) { + if (bandIndex < 0 || bandIndex >= 8) return + + const clampedGain = Math.max(-12, Math.min(12, gainDB)) + this.bands[bandIndex] = clampedGain + + // Auto-enable EQ when user modifies any band + if (!this.enabled && clampedGain !== 0) { + this.enabled = true + equalizerEngine.setEnabled(true) + } + + // Apply to audio engine if EQ is enabled + if (this.enabled) { + equalizerEngine.setBandGain(bandIndex, clampedGain) + } + + // Save to localStorage + this.saveToLocalStorage() + }, + + /** + * Toggle EQ on/off + * When off, keeps values but bypasses processing + */ + toggleEnabled() { + this.enabled = !this.enabled + + if (this.enabled) { + // Apply current bands to audio engine + equalizerEngine.setEnabled(true) + equalizerEngine.setAllBands(this.bands) + } else { + // Bypass EQ (set all gains to 0 in engine) + equalizerEngine.setEnabled(false) + } + + this.saveToLocalStorage() + }, + + /** + * Load a preset + */ + loadPreset(presetName: string) { + const preset = getPreset(presetName) + this.bands = [...preset.gains] + this.currentPreset = presetName + + // Auto-enable EQ if preset is not flat + if (this.isModified && !this.enabled) { + this.enabled = true + } + + // Apply to audio engine + if (this.enabled) { + equalizerEngine.setEnabled(true) + equalizerEngine.setAllBands(this.bands) + } + + this.saveToLocalStorage() + }, + + /** + * Reset all bands to 0 (flat) + */ + reset() { + this.bands = [0, 0, 0, 0, 0, 0, 0, 0] + this.currentPreset = 'Flat' + + if (this.enabled) { + equalizerEngine.setAllBands(this.bands) + } + + this.saveToLocalStorage() + }, + + /** + * Load EQ settings from localStorage + */ + loadFromLocalStorage() { + try { + const savedEnabled = localStorage.getItem('eq_enabled') + const savedBands = localStorage.getItem('eq_bands') + const savedPreset = localStorage.getItem('eq_preset') + + if (savedEnabled !== null) { + this.enabled = JSON.parse(savedEnabled) + } + + if (savedBands) { + const parsed = JSON.parse(savedBands) + if (Array.isArray(parsed) && parsed.length === 8) { + this.bands = parsed + } + } + + if (savedPreset) { + this.currentPreset = savedPreset + } + + // Apply to audio engine if enabled + if (this.enabled) { + equalizerEngine.setEnabled(true) + equalizerEngine.setAllBands(this.bands) + } + } catch (error) { + console.warn('Failed to load EQ settings from localStorage:', error) + } + }, + + /** + * Save EQ settings to localStorage + */ + saveToLocalStorage() { + try { + localStorage.setItem('eq_enabled', JSON.stringify(this.enabled)) + localStorage.setItem('eq_bands', JSON.stringify(this.bands)) + localStorage.setItem('eq_preset', this.currentPreset) + + // If custom preset, save it + if (this.currentPreset === 'Custom') { + localStorage.setItem('eq_custom_preset', JSON.stringify(this.bands)) + } + } catch (error) { + console.warn('Failed to save EQ settings to localStorage:', error) + } + }, + + /** + * Load custom preset from localStorage if it exists + */ + loadCustomPreset() { + try { + const customPreset = localStorage.getItem('eq_custom_preset') + if (customPreset) { + const gains = JSON.parse(customPreset) + if (Array.isArray(gains) && gains.length === 8) { + this.bands = gains + this.currentPreset = 'Custom' + return true + } + } + } catch (error) { + console.warn('Failed to load custom preset:', error) + } + return false + }, + }, +}) + +export default useEqualizer diff --git a/src/stores/player.ts b/src/stores/player.ts index ee4141c..3d53d1a 100644 --- a/src/stores/player.ts +++ b/src/stores/player.ts @@ -13,6 +13,7 @@ import useTracker from './tracker' import { getBaseUrl, paths } from '@/config' import updateMediaNotif from '@/helpers/mediaNotification' import { crossFade } from '@/utils/audio/crossFade' +import { equalizerEngine } from '@/utils/equalizer/equalizer' class AudioSource { private sources: HTMLAudioElement[] = [] @@ -20,6 +21,7 @@ class AudioSource { private handlers: { [key: string]: (err: Event | string) => void } = {} private requiredAPBlockBypass: boolean = false settings: ReturnType | null = null + private audioContextInitialized: boolean = false constructor() { this.sources = [new Audio(), new Audio()] @@ -31,6 +33,27 @@ class AudioSource { }) this.requiredAPBlockBypass = true + this.initializeWebAudio() + } + + /** + * Initialize Web Audio API for equalizer + */ + private initializeWebAudio() { + try { + const audioContext = new AudioContext() + equalizerEngine.initialize(audioContext) + + // Connect both audio sources to the EQ chain + this.sources.forEach(audio => { + equalizerEngine.connectAudioElement(audio) + }) + + this.audioContextInitialized = true + } catch (error) { + console.warn('Failed to initialize Web Audio API:', error) + this.audioContextInitialized = false + } } get standbySource() { @@ -117,6 +140,11 @@ class AudioSource { .catch(() => {}) this.requiredAPBlockBypass = false + + // Resume audio context for iOS/Safari + if (this.audioContextInitialized) { + equalizerEngine.resumeContext() + } } } diff --git a/src/utils/equalizer/eqPresets.ts b/src/utils/equalizer/eqPresets.ts new file mode 100644 index 0000000..65e713a --- /dev/null +++ b/src/utils/equalizer/eqPresets.ts @@ -0,0 +1,103 @@ +/** + * Equalizer preset configurations + * 8 bands: 60Hz, 150Hz, 400Hz, 1kHz, 2.4kHz, 6kHz, 12kHz, 15kHz + * Values in dB (-12 to +12) + */ + +import presetsData from './presets.json' + +// Constants for band configuration +export const EQ_BAND_COUNT = 8 +export const EQ_GAIN_MIN = -12 +export const EQ_GAIN_MAX = 12 + +// Readonly frequency array - prevents accidental mutations +export const EQ_FREQUENCIES = [60, 150, 400, 1000, 2400, 6000, 12000, 15000] as const + +// Type-safe gains array with exactly 8 bands +export type EQGains = readonly [number, number, number, number, number, number, number, number] + +export interface EQPreset { + name: string + gains: EQGains +} + +// Default flat preset as fallback +const DEFAULT_PRESET: EQPreset = { + name: 'Flat', + gains: [0, 0, 0, 0, 0, 0, 0, 0] as EQGains +} + +/** + * Validate presets data structure + */ +function validatePresetsData(data: any): data is { presets: EQPreset[] } { + return ( + data && + Array.isArray(data.presets) && + data.presets.every((p: any) => + typeof p.name === 'string' && + Array.isArray(p.gains) && + p.gains.length === EQ_BAND_COUNT && + p.gains.every((g: any) => + typeof g === 'number' && + g >= EQ_GAIN_MIN && + g <= EQ_GAIN_MAX + ) + ) + ) +} + +/** + * Type guard for gains array + */ +export function isValidGains(gains: unknown): gains is EQGains { + return ( + Array.isArray(gains) && + gains.length === EQ_BAND_COUNT && + gains.every(g => typeof g === 'number' && g >= EQ_GAIN_MIN && g <= EQ_GAIN_MAX) + ) +} + +/** + * Create validated EQ gains with clamping + */ +export function createEQGains(gains: number[]): EQGains { + if (gains.length !== EQ_BAND_COUNT) { + throw new Error(`Expected ${EQ_BAND_COUNT} bands, got ${gains.length}`) + } + + return gains.map(g => + Math.max(EQ_GAIN_MIN, Math.min(EQ_GAIN_MAX, g)) + ) as EQGains +} + +// Load and validate presets with fallback +export const EQ_PRESETS: EQPreset[] = validatePresetsData(presetsData) + ? presetsData.presets + : [DEFAULT_PRESET] + +/** + * Get preset by name with safe fallback + */ +export function getPreset(name: string): EQPreset { + const preset = EQ_PRESETS.find(p => p.name === name) + + if (preset) return preset + + if (EQ_PRESETS.length > 0) return EQ_PRESETS[0] + + // Fallback if presets array is somehow empty + return DEFAULT_PRESET +} + +/** + * Format frequency for display (e.g., 1000 -> "1kHz") + */ +export function formatFrequency(freq: number): string { + if (freq >= 1000) { + const kHz = freq / 1000 + return kHz % 1 === 0 ? `${kHz}kHz` : `${kHz.toFixed(1)}kHz` + } + return `${freq}Hz` +} diff --git a/src/utils/equalizer/equalizer.ts b/src/utils/equalizer/equalizer.ts new file mode 100644 index 0000000..5e75337 --- /dev/null +++ b/src/utils/equalizer/equalizer.ts @@ -0,0 +1,145 @@ +import { EQ_FREQUENCIES } from './eqPresets' + +/** + * Web Audio API Equalizer Engine + * Creates and manages 8-band parametric equalizer using BiquadFilterNode + */ +export class EqualizerEngine { + private audioContext: AudioContext | null = null + private filters: BiquadFilterNode[] = [] + private sourceNodes: Map = new Map() + private enabled: boolean = false + + /** + * Initialize the audio context and create filter nodes + */ + initialize(audioContext: AudioContext) { + this.audioContext = audioContext + + // Create 8 biquad filters for each frequency band + this.filters = EQ_FREQUENCIES.map(freq => { + const filter = audioContext.createBiquadFilter() + filter.type = 'peaking' + filter.frequency.value = freq + filter.Q.value = 1.0 // Q factor (bandwidth) + filter.gain.value = 0 // neutral (no boost/cut) + return filter + }) + + // Chain filters together + for (let i = 0; i < this.filters.length - 1; i++) { + this.filters[i].connect(this.filters[i + 1]) + } + } + + /** + * Connect an audio element to the EQ chain + * HTMLAudioElement → EQ Filters → Destination + */ + connectAudioElement(audioElement: HTMLAudioElement): MediaElementAudioSourceNode | null { + if (!this.audioContext) return null + + // Check if already connected + if (this.sourceNodes.has(audioElement)) { + return this.sourceNodes.get(audioElement)! + } + + try { + // Create source node from audio element + const sourceNode = this.audioContext.createMediaElementSource(audioElement) + this.sourceNodes.set(audioElement, sourceNode) + + // Connect: source → filter chain → destination + if (this.filters.length > 0) { + sourceNode.connect(this.filters[0]) + this.filters[this.filters.length - 1].connect(this.audioContext.destination) + } else { + // Fallback: direct connection if no filters + sourceNode.connect(this.audioContext.destination) + } + + return sourceNode + } catch (error) { + console.warn('Failed to connect audio element to EQ:', error) + return null + } + } + + /** + * Update a specific EQ band gain + * @param bandIndex - Index of the band (0-7) + * @param gainDB - Gain in decibels (-12 to +12) + */ + setBandGain(bandIndex: number, gainDB: number) { + if (bandIndex < 0 || bandIndex >= this.filters.length) return + + const clampedGain = Math.max(-12, Math.min(12, gainDB)) + this.filters[bandIndex].gain.value = clampedGain + } + + /** + * Update all bands at once + * @param gains - Array of 8 gain values in dB + */ + setAllBands(gains: number[]) { + gains.forEach((gain, index) => { + this.setBandGain(index, gain) + }) + } + + /** + * Enable or disable EQ (bypass mode) + * When disabled, all gains are set to 0 + */ + setEnabled(enabled: boolean) { + this.enabled = enabled + + if (!enabled) { + // Bypass: set all gains to 0 (neutral) + this.filters.forEach(filter => { + filter.gain.value = 0 + }) + } + } + + /** + * Get current gain for a specific band + */ + getBandGain(bandIndex: number): number { + if (bandIndex < 0 || bandIndex >= this.filters.length) return 0 + return this.filters[bandIndex].gain.value + } + + /** + * Get all current gains + */ + getAllGains(): number[] { + return this.filters.map(filter => filter.gain.value) + } + + /** + * Check if EQ is enabled + */ + isEnabled(): boolean { + return this.enabled + } + + /** + * Resume audio context (required for iOS/Safari) + */ + async resumeContext() { + if (this.audioContext?.state === 'suspended') { + await this.audioContext.resume() + } + } + + /** + * Get audio context + */ + getContext(): AudioContext | null { + return this.audioContext + } +} + +// Singleton instance +export const equalizerEngine = new EqualizerEngine() diff --git a/src/utils/equalizer/presets.json b/src/utils/equalizer/presets.json new file mode 100644 index 0000000..fc3f4b2 --- /dev/null +++ b/src/utils/equalizer/presets.json @@ -0,0 +1,48 @@ +{ + "presets": [ + { + "name": "Flat", + "gains": [0, 0, 0, 0, 0, 0, 0, 0] + }, + { + "name": "Rock", + "gains": [4.8, 3.2, -3.2, -4.8, -1.6, 2.4, 5.6, 6.8] + }, + { + "name": "Pop", + "gains": [-1.6, 3.2, 4.8, 5.2, 3.6, 0, -1.6, -1.6] + }, + { + "name": "Jazz", + "gains": [2.4, 1.6, 0, 1.6, -1.6, -1.6, 0, 2.4] + }, + { + "name": "Classical", + "gains": [3.2, 2.4, -1.6, -1.6, 0, 1.6, 2.4, 3.2] + }, + { + "name": "Bass Boost", + "gains": [8.0, 6.4, 4.8, 2.4, 0, 0, 0, 0] + }, + { + "name": "Treble Boost", + "gains": [0, 0, 0, 0, 2.4, 4.8, 6.4, 8.0] + }, + { + "name": "Vocal", + "gains": [-1.6, -2.4, -2.4, 1.6, 4.0, 4.0, 2.4, 0] + }, + { + "name": "Electronic", + "gains": [3.2, 4.8, 1.6, 0, -3.2, 2.4, 4.0, 4.8] + }, + { + "name": "Acoustic", + "gains": [4.0, 3.2, 1.6, 0.8, 1.6, 2.4, 3.2, 2.4] + }, + { + "name": "Deep", + "gains": [12.0, 0.2, 0.6, 0.8, 0.6, 0.4, 0.2, -12.0] + } + ] +}