feat: AutoEQ and speaker EQ enhancements
Adds AutoEQ integration with interactive parametric EQ graph, speaker/room correction with shelf filters, and improved EQ persistence via IndexedDB.
This commit is contained in:
parent
6e98830fdd
commit
d4d1fe8494
13 changed files with 10305 additions and 1890 deletions
1
.github/workflows/lint.yml
vendored
1
.github/workflows/lint.yml
vendored
|
|
@ -19,6 +19,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
|
||||
- name: Setup Bun
|
||||
|
|
|
|||
978
index.html
978
index.html
File diff suppressed because it is too large
Load diff
|
|
@ -1883,7 +1883,13 @@ export class LosslessAPI {
|
|||
}
|
||||
|
||||
if (!isVideo) {
|
||||
blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal, postProcessingQuality);
|
||||
blob = await applyAudioPostProcessing(
|
||||
blob,
|
||||
quality,
|
||||
onProgress,
|
||||
options.signal,
|
||||
lookup.info?.audioQuality ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// Add metadata if track information is provided
|
||||
|
|
|
|||
|
|
@ -239,9 +239,10 @@ class AudioContextManager {
|
|||
// Create biquad filters for each frequency band
|
||||
this.filters = this.frequencies.map((freq, index) => {
|
||||
const filter = this.audioContext.createBiquadFilter();
|
||||
filter.type = 'peaking';
|
||||
filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||
filter.frequency.value = freq;
|
||||
filter.Q.value = this._calculateQ(index);
|
||||
filter.Q.value =
|
||||
this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
||||
filter.gain.value = this.currentGains[index] || 0;
|
||||
return filter;
|
||||
});
|
||||
|
|
@ -312,10 +313,10 @@ class AudioContextManager {
|
|||
try {
|
||||
this.audioContext = new AudioContext(highResOptions);
|
||||
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
try {
|
||||
this.audioContext = new AudioContext({ latencyHint: 'playback' });
|
||||
} catch (e2) {
|
||||
} catch {
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
}
|
||||
|
|
@ -358,7 +359,9 @@ class AudioContextManager {
|
|||
if (this.source) {
|
||||
try {
|
||||
this.source.disconnect();
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// node may already be disconnected
|
||||
}
|
||||
}
|
||||
|
||||
this.audio = audioElement;
|
||||
|
|
@ -386,7 +389,9 @@ class AudioContextManager {
|
|||
// Disconnect everything first
|
||||
try {
|
||||
this.source.disconnect();
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// node may already be disconnected
|
||||
}
|
||||
this.outputNode.disconnect();
|
||||
if (this.volumeNode) {
|
||||
this.volumeNode.disconnect();
|
||||
|
|
@ -405,16 +410,23 @@ class AudioContextManager {
|
|||
|
||||
// Apply mono audio if enabled
|
||||
if (this.isMonoAudioEnabled && this.monoMergerNode) {
|
||||
// Create a gain node to mix channels before the merger
|
||||
const monoGain = this.audioContext.createGain();
|
||||
monoGain.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
|
||||
// Reuse persistent gain node to avoid leaking AudioNodes
|
||||
if (!this.monoGainNode) {
|
||||
this.monoGainNode = this.audioContext.createGain();
|
||||
this.monoGainNode.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
|
||||
}
|
||||
try {
|
||||
this.monoGainNode.disconnect();
|
||||
} catch {
|
||||
/* not connected */
|
||||
}
|
||||
|
||||
// Connect source to mono gain
|
||||
this.source.connect(monoGain);
|
||||
this.source.connect(this.monoGainNode);
|
||||
|
||||
// Connect mono gain to both inputs of the merger
|
||||
monoGain.connect(this.monoMergerNode, 0, 0);
|
||||
monoGain.connect(this.monoMergerNode, 0, 1);
|
||||
this.monoGainNode.connect(this.monoMergerNode, 0, 0);
|
||||
this.monoGainNode.connect(this.monoMergerNode, 0, 1);
|
||||
|
||||
lastNode = this.monoMergerNode;
|
||||
console.log('[AudioContext] Mono audio enabled');
|
||||
|
|
@ -573,6 +585,57 @@ class AudioContextManager {
|
|||
return equalizerSettings.getRange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate biquad filter magnitude response in dB at a given frequency
|
||||
*/
|
||||
_biquadResponseDb(f, band, sr) {
|
||||
if (!band.enabled || !band.type) return 0;
|
||||
const w = (2 * Math.PI * band.freq) / sr;
|
||||
const p = (2 * Math.PI * f) / sr;
|
||||
const s = Math.sin(w) / (2 * band.q);
|
||||
const A = Math.pow(10, band.gain / 40);
|
||||
const c = Math.cos(w);
|
||||
let b0, b1, b2, a0, a1, a2;
|
||||
const t = band.type[0];
|
||||
if (t === 'p') {
|
||||
b0 = 1 + s * A;
|
||||
b1 = -2 * c;
|
||||
b2 = 1 - s * A;
|
||||
a0 = 1 + s / A;
|
||||
a1 = -2 * c;
|
||||
a2 = 1 - s / A;
|
||||
} else if (t === 'l') {
|
||||
const sq = 2 * Math.sqrt(A) * s;
|
||||
b0 = A * (A + 1 - (A - 1) * c + sq);
|
||||
b1 = 2 * A * (A - 1 - (A + 1) * c);
|
||||
b2 = A * (A + 1 - (A - 1) * c - sq);
|
||||
a0 = A + 1 + (A - 1) * c + sq;
|
||||
a1 = -2 * (A - 1 + (A + 1) * c);
|
||||
a2 = A + 1 + (A - 1) * c - sq;
|
||||
} else if (t === 'h') {
|
||||
const sq = 2 * Math.sqrt(A) * s;
|
||||
b0 = A * (A + 1 + (A - 1) * c + sq);
|
||||
b1 = -2 * A * (A - 1 + (A + 1) * c);
|
||||
b2 = A * (A + 1 + (A - 1) * c - sq);
|
||||
a0 = A + 1 - (A - 1) * c + sq;
|
||||
a1 = 2 * (A - 1 - (A + 1) * c);
|
||||
a2 = A + 1 - (A - 1) * c - sq;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
const _a0 = 1 / a0;
|
||||
const b0n = b0 * _a0,
|
||||
b1n = b1 * _a0,
|
||||
b2n = b2 * _a0;
|
||||
const a1n = a1 * _a0,
|
||||
a2n = a2 * _a0;
|
||||
const cp = Math.cos(p),
|
||||
c2p = Math.cos(2 * p);
|
||||
const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p;
|
||||
const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p;
|
||||
return 10 * Math.log10(n / d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp gain to valid range
|
||||
*/
|
||||
|
|
@ -667,6 +730,8 @@ class AudioContextManager {
|
|||
this.freqRange = equalizerSettings.getFreqRange();
|
||||
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
||||
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
||||
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
|
||||
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
|
||||
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
||||
this.preamp = equalizerSettings.getPreamp();
|
||||
}
|
||||
|
|
@ -697,7 +762,88 @@ class AudioContextManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Called when the app enters the background (screen lock, app switch).
|
||||
* Apply AutoEQ-generated bands to the equalizer
|
||||
* Unlike regular presets, AutoEQ bands have specific frequencies, gains, and Q values
|
||||
* @param {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>} bands
|
||||
* @returns {string} Exported text representation of the applied EQ
|
||||
*/
|
||||
applyAutoEQBands(bands, skipPreamp = false) {
|
||||
if (!bands || bands.length === 0) return '';
|
||||
|
||||
const enabledBands = bands.filter((b) => b.enabled);
|
||||
const count = Math.max(equalizerSettings.MIN_BANDS, Math.min(equalizerSettings.MAX_BANDS, enabledBands.length));
|
||||
|
||||
// Calculate preamp: negative of cumulative peak gain across all bands to prevent clipping
|
||||
let cumulativePeak = 0;
|
||||
if (!skipPreamp) {
|
||||
const sr = this.audioContext?.sampleRate ?? 48000;
|
||||
// Sweep log-spaced frequencies (24 points/octave from 20-20kHz) to catch narrow peaks
|
||||
for (let f = 20; f <= 20000; f *= Math.pow(2, 1 / 24)) {
|
||||
let sum = 0;
|
||||
for (const b of enabledBands) {
|
||||
sum += this._biquadResponseDb(f, b, sr);
|
||||
}
|
||||
if (sum > cumulativePeak) cumulativePeak = sum;
|
||||
}
|
||||
}
|
||||
const preamp = skipPreamp
|
||||
? equalizerSettings.getPreamp()
|
||||
: cumulativePeak > 0
|
||||
? -Math.round(cumulativePeak * 10) / 10
|
||||
: 0;
|
||||
|
||||
// Sort bands by frequency so index order is deterministic
|
||||
const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq);
|
||||
|
||||
// Build normalized band descriptor arrays
|
||||
const newFrequencies = sortedBands
|
||||
.slice(0, count)
|
||||
.map((b) => Math.round(Math.min(b.freq, (this.audioContext?.sampleRate ?? 48000) / 2 - 1)));
|
||||
const newTypes = sortedBands.slice(0, count).map((b) => b.type || 'peaking');
|
||||
const newQs = sortedBands.slice(0, count).map((b) => b.q);
|
||||
const newGains = sortedBands.slice(0, count).map((b) => this._clampGain(b.gain));
|
||||
|
||||
// Update band count via class setter to trigger equalizer-band-count-changed event
|
||||
if (count !== this.bandCount) {
|
||||
this.setBandCount(count);
|
||||
}
|
||||
|
||||
// Override frequencies, types, and Qs with band-specific values
|
||||
this.frequencies = newFrequencies;
|
||||
this.currentTypes = newTypes;
|
||||
this.currentQs = newQs;
|
||||
this.currentGains = newGains;
|
||||
|
||||
// Rebuild EQ so _createEQ picks up the new types/Qs
|
||||
if (this.isInitialized && this.audioContext) {
|
||||
this._destroyEQ();
|
||||
this._createEQ();
|
||||
this._connectGraph();
|
||||
}
|
||||
|
||||
// Apply preamp (skip if caller manages preamp externally)
|
||||
if (!skipPreamp) {
|
||||
this.setPreamp(preamp);
|
||||
}
|
||||
|
||||
// Persist normalized band descriptors to settings store
|
||||
equalizerSettings.setGains(this.currentGains);
|
||||
equalizerSettings.setBandTypes(this.currentTypes);
|
||||
equalizerSettings.setBandQs(this.currentQs);
|
||||
|
||||
// Generate export text using clamped gain values
|
||||
const lines = [`Preamp: ${preamp.toFixed(1)} dB`];
|
||||
sortedBands.forEach((band, index) => {
|
||||
if (index >= count) return;
|
||||
const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';
|
||||
lines.push(
|
||||
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
|
||||
);
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export equalizer settings to text format
|
||||
* @returns {string} Exported settings in text format
|
||||
|
|
@ -709,8 +855,13 @@ class AudioContextManager {
|
|||
|
||||
this.frequencies.forEach((freq, index) => {
|
||||
const gain = this.currentGains[index] || 0;
|
||||
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||
const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK';
|
||||
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
||||
const filterNum = index + 1;
|
||||
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
|
||||
lines.push(
|
||||
`Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`
|
||||
);
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
|
|
@ -760,24 +911,42 @@ class AudioContextManager {
|
|||
this.setPreamp(preamp);
|
||||
|
||||
// If different number of bands, adjust
|
||||
if (filters.length !== this.bandCount) {
|
||||
const newCount = Math.max(
|
||||
equalizerSettings.MIN_BANDS,
|
||||
Math.min(equalizerSettings.MAX_BANDS, filters.length)
|
||||
);
|
||||
const newCount = Math.max(
|
||||
equalizerSettings.MIN_BANDS,
|
||||
Math.min(equalizerSettings.MAX_BANDS, filters.length)
|
||||
);
|
||||
if (newCount !== this.bandCount) {
|
||||
this.setBandCount(newCount);
|
||||
}
|
||||
|
||||
// Extract gains from filters
|
||||
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
|
||||
this.setAllGains(gains);
|
||||
// Apply per-band frequencies, types, Qs, and gains from import
|
||||
const sliced = filters.slice(0, this.bandCount);
|
||||
const typeMap = {
|
||||
PK: 'peaking',
|
||||
LS: 'lowshelf',
|
||||
LSC: 'lowshelf',
|
||||
LSF: 'lowshelf',
|
||||
HS: 'highshelf',
|
||||
HSC: 'highshelf',
|
||||
HSF: 'highshelf',
|
||||
};
|
||||
this.frequencies = sliced.map((f) => f.freq);
|
||||
this.currentTypes = sliced.map((f) => typeMap[f.type] || 'peaking');
|
||||
this.currentQs = sliced.map((f) => f.q);
|
||||
this.currentGains = sliced.map((f) => this._clampGain(f.gain));
|
||||
|
||||
// Store filter frequencies if different
|
||||
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
|
||||
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
|
||||
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
|
||||
// Rebuild EQ chain to apply new frequencies, types, and Qs
|
||||
if (this.isInitialized && this.audioContext) {
|
||||
this._destroyEQ();
|
||||
this._createEQ();
|
||||
this._connectGraph();
|
||||
}
|
||||
|
||||
// Persist all band settings
|
||||
equalizerSettings.setGains(this.currentGains);
|
||||
equalizerSettings.setBandTypes(this.currentTypes);
|
||||
equalizerSettings.setBandQs(this.currentQs);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[AudioContext] Failed to import EQ settings:', e);
|
||||
|
|
|
|||
4396
js/autoeq-data.js
Normal file
4396
js/autoeq-data.js
Normal file
File diff suppressed because it is too large
Load diff
221
js/autoeq-engine.js
Normal file
221
js/autoeq-engine.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
// js/autoeq-engine.js
|
||||
// AutoEQ Algorithm - Ported from Seap Engine AutoEqEngine.ts
|
||||
// Iterative peak-flattening parametric EQ optimization
|
||||
|
||||
// Constants
|
||||
const MAX_BOOST = 30.0;
|
||||
const MAX_CUT = 30.0;
|
||||
const MIN_Q = 0.6;
|
||||
const DEFAULT_SR = 48000;
|
||||
const PI = Math.PI;
|
||||
const DB_BASE = 10;
|
||||
const DB_DIVISOR = 40;
|
||||
|
||||
/**
|
||||
* Calculate biquad filter magnitude response at a given frequency
|
||||
* @param {number} f - Frequency to evaluate (Hz)
|
||||
* @param {object} band - EQ band {type, freq, gain, q, enabled}
|
||||
* @param {number} sr - Sample rate
|
||||
* @returns {number} Magnitude in dB
|
||||
*/
|
||||
function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
|
||||
if (!band.enabled) return 0;
|
||||
if (!band.type || band.type.length === 0) return 0;
|
||||
const w = 2 * PI * band.freq / sr;
|
||||
const p = 2 * PI * f / sr;
|
||||
const s = Math.sin(w) / (2 * band.q);
|
||||
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
|
||||
const c = Math.cos(w);
|
||||
let b0 = 0, b1 = 0, b2 = 0, a0 = 0, a1 = 0, a2 = 0;
|
||||
|
||||
const t = band.type[0];
|
||||
|
||||
if (t === 'p') {
|
||||
b0 = 1 + s * A; b1 = -2 * c; b2 = 1 - s * A;
|
||||
a0 = 1 + s / A; a1 = -2 * c; a2 = 1 - s / A;
|
||||
} else if (t === 'l') {
|
||||
const sq = 2 * Math.sqrt(A) * s;
|
||||
b0 = A * ((A + 1) - (A - 1) * c + sq);
|
||||
b1 = 2 * A * ((A - 1) - (A + 1) * c);
|
||||
b2 = A * ((A + 1) - (A - 1) * c - sq);
|
||||
a0 = (A + 1) + (A - 1) * c + sq;
|
||||
a1 = -2 * ((A - 1) + (A + 1) * c);
|
||||
a2 = (A + 1) + (A - 1) * c - sq;
|
||||
} else if (t === 'h') {
|
||||
const sq = 2 * Math.sqrt(A) * s;
|
||||
b0 = A * ((A + 1) + (A - 1) * c + sq);
|
||||
b1 = -2 * A * ((A - 1) + (A + 1) * c);
|
||||
b2 = A * ((A + 1) + (A - 1) * c - sq);
|
||||
a0 = (A + 1) - (A - 1) * c + sq;
|
||||
a1 = 2 * ((A - 1) - (A + 1) * c);
|
||||
a2 = (A + 1) - (A - 1) * c - sq;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const _a0 = 1 / a0;
|
||||
const b0n = b0 * _a0, b1n = b1 * _a0, b2n = b2 * _a0;
|
||||
const a1n = a1 * _a0, a2n = a2 * _a0;
|
||||
const cp = Math.cos(p), c2p = Math.cos(2 * p);
|
||||
const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p;
|
||||
const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p;
|
||||
return 10 * Math.log10(n / d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear interpolation on frequency response data
|
||||
* @param {number} freq - Frequency to interpolate at
|
||||
* @param {Array<{freq: number, gain: number}>} data - Frequency response data
|
||||
* @returns {number} Interpolated gain value
|
||||
*/
|
||||
function interpolate(freq, data) {
|
||||
if (data.length === 0) return 0;
|
||||
if (freq <= data[0].freq) return data[0].gain;
|
||||
if (freq >= data[data.length - 1].freq) return data[data.length - 1].gain;
|
||||
for (let i = 0; i < data.length - 1; i++) {
|
||||
if (freq >= data[i].freq && freq <= data[i + 1].freq) {
|
||||
return data[i].gain + (freq - data[i].freq) / (data[i + 1].freq - data[i].freq) * (data[i + 1].gain - data[i].gain);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate normalization offset based on midrange average (250-2500 Hz)
|
||||
* @param {Array<{freq: number, gain: number}>} data - Frequency response data
|
||||
* @returns {number} Average gain in midrange
|
||||
*/
|
||||
function getNormalizationOffset(data) {
|
||||
let sum = 0, count = 0;
|
||||
for (const p of data) {
|
||||
if (p.freq >= 250 && p.freq <= 2500) {
|
||||
sum += p.gain;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count > 0 ? sum / count : interpolate(1000, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the AutoEQ algorithm to generate parametric EQ bands
|
||||
* Iterative peak-flattening: finds largest error, places a corrective filter, repeats
|
||||
*
|
||||
* @param {Array<{freq: number, gain: number}>} measurement - Headphone frequency response
|
||||
* @param {Array<{freq: number, gain: number}>} target - Target frequency response curve
|
||||
* @param {number} bandCount - Number of EQ bands to generate
|
||||
* @param {number} maxFreq - Maximum frequency limit (Hz)
|
||||
* @param {number} minFreq - Minimum frequency limit (Hz)
|
||||
* @param {number} maxQ - Maximum Q factor
|
||||
* @returns {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>}
|
||||
*/
|
||||
function runAutoEqAlgorithm(measurement, target, bandCount, maxFreq = 16000, minFreq = 20, maxQ = 5.0, sampleRate = DEFAULT_SR) {
|
||||
if (minFreq > maxFreq) return [];
|
||||
const off = getNormalizationOffset(target) - getNormalizationOffset(measurement);
|
||||
let err = measurement.map(p => ({ freq: p.freq, gain: (p.gain + off) - interpolate(p.freq, target) }));
|
||||
|
||||
const hasInRangePoints = err.some(p => p.freq >= minFreq && p.freq <= maxFreq);
|
||||
if (!hasInRangePoints) return [];
|
||||
|
||||
const out = [];
|
||||
|
||||
for (let i = 0; i < bandCount; i++) {
|
||||
let maxDev = 0, maxWeightedDev = 0, peakFreq = 1000, peakIdx = 0;
|
||||
|
||||
// Scan for maximum weighted error
|
||||
for (let j = 0; j < err.length; j++) {
|
||||
const p = err[j];
|
||||
if (p.freq < minFreq || p.freq > maxFreq) continue;
|
||||
|
||||
// 3-point smoothing
|
||||
let v = p.gain;
|
||||
if (j > 0 && j < err.length - 1) {
|
||||
v = (err[j - 1].gain + v + err[j + 1].gain) / 3;
|
||||
}
|
||||
|
||||
// Frequency-dependent weighting
|
||||
let w = 1.0;
|
||||
if (p.freq < 300) w = 1.5;
|
||||
else if (p.freq < 4000) w = 1.0;
|
||||
else if (p.freq < 8000) w = 0.5;
|
||||
else w = 0.25;
|
||||
|
||||
if (Math.abs(v * w) > Math.abs(maxWeightedDev)) {
|
||||
maxWeightedDev = Math.abs(v * w);
|
||||
maxDev = v;
|
||||
peakFreq = p.freq;
|
||||
peakIdx = j;
|
||||
}
|
||||
}
|
||||
|
||||
let gain = -maxDev;
|
||||
|
||||
// Safety clamps - reduce max boost at higher frequencies
|
||||
let safeBoost = MAX_BOOST;
|
||||
if (peakFreq > 3000) safeBoost = 6.0;
|
||||
if (peakFreq > 6000) safeBoost = 3.0;
|
||||
if (gain > safeBoost) gain = safeBoost;
|
||||
if (gain < -MAX_CUT) gain = -MAX_CUT;
|
||||
if (Math.abs(gain) < 0.2) break;
|
||||
|
||||
// Q factor calculation from error bandwidth (half-gain points)
|
||||
let upperFreq = peakFreq, lowerFreq = peakFreq;
|
||||
let foundLower = false, foundUpper = false;
|
||||
const thresholdError = maxDev / 2;
|
||||
for (let k = peakIdx; k >= 0; k--) {
|
||||
if (Math.abs(err[k].gain) < Math.abs(thresholdError)) {
|
||||
lowerFreq = err[k].freq;
|
||||
foundLower = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let k = peakIdx; k < err.length; k++) {
|
||||
if (Math.abs(err[k].gain) < Math.abs(thresholdError)) {
|
||||
upperFreq = err[k].freq;
|
||||
foundUpper = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If half-gain boundary not found on one side, mirror the other side
|
||||
// to avoid degenerate bandwidth = 0 producing extremely narrow filters
|
||||
if (!foundLower && foundUpper) {
|
||||
lowerFreq = peakFreq * peakFreq / upperFreq;
|
||||
} else if (!foundUpper && foundLower) {
|
||||
upperFreq = peakFreq * peakFreq / lowerFreq;
|
||||
} else if (!foundLower && !foundUpper) {
|
||||
// Neither boundary found — use 1 octave default
|
||||
lowerFreq = peakFreq / Math.SQRT2;
|
||||
upperFreq = peakFreq * Math.SQRT2;
|
||||
}
|
||||
|
||||
let bandwidth = Math.log2(upperFreq / Math.max(1, lowerFreq));
|
||||
if (bandwidth < 0.1) bandwidth = 0.1;
|
||||
let q = Math.sqrt(Math.pow(2, bandwidth)) / (Math.pow(2, bandwidth) - 1);
|
||||
q = Math.max(MIN_Q, Math.min(maxQ, q));
|
||||
if (peakFreq > 5000 && q > 3.0) q = 3.0;
|
||||
if (gain > 0 && q > 2.0) q = 2.0;
|
||||
|
||||
const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true };
|
||||
|
||||
// Check cumulative gain at the peak frequency across all existing bands + this one
|
||||
let cumulativeGain = gain;
|
||||
for (const existing of out) {
|
||||
cumulativeGain += calculateBiquadResponse(peakFreq, existing, sampleRate);
|
||||
}
|
||||
// If cumulative boost exceeds safe limits, reduce this band's gain
|
||||
const cumulativeLimit = MAX_BOOST;
|
||||
if (cumulativeGain > cumulativeLimit) {
|
||||
newBand.gain = gain - (cumulativeGain - cumulativeLimit);
|
||||
if (newBand.gain < 0.2) continue;
|
||||
}
|
||||
|
||||
out.push(newBand);
|
||||
|
||||
// Update error curve by applying the new band's response
|
||||
err = err.map(p => ({ ...p, gain: p.gain + calculateBiquadResponse(p.freq, newBand, sampleRate) }));
|
||||
}
|
||||
|
||||
return out.sort((a, b) => a.freq - b.freq).map((b, i) => ({ ...b, id: i }));
|
||||
}
|
||||
|
||||
export { calculateBiquadResponse, interpolate, getNormalizationOffset, runAutoEqAlgorithm };
|
||||
219
js/autoeq-importer.js
Normal file
219
js/autoeq-importer.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// js/autoeq-importer.js
|
||||
// Headphone Database Browser - Fetches from AutoEq GitHub repository
|
||||
// Provides access to 4000+ headphone measurement profiles
|
||||
|
||||
import { parseRawData } from './autoeq-data.js';
|
||||
import { db } from './db.js';
|
||||
|
||||
const CACHE_KEY = 'autoeq_index_v4';
|
||||
const OLD_LS_CACHE_KEY = 'monochrome_autoeq_index_v4';
|
||||
const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// 5 most popular headphones — pre-loaded as defaults and shown in the headphone select
|
||||
// All measured on Rtings B&K 5128 rig for consistency
|
||||
const POPULAR_HEADPHONES = [
|
||||
{ name: 'Sony WH-1000XM5 (Rtings)', type: 'over-ear', path: 'Rtings/Bruel & Kjaer 5128 over-ear/Sony WH-1000XM5', fileName: 'Sony WH-1000XM5.csv' },
|
||||
{ name: 'Apple AirPods Pro2 (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Apple AirPods Pro2', fileName: 'Apple AirPods Pro2.csv' },
|
||||
{ name: 'Sony WF-1000XM5 (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Sony WF-1000XM5', fileName: 'Sony WF-1000XM5.csv' },
|
||||
{ name: 'Samsung Galaxy Buds3 Pro (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Samsung Galaxy Buds3 Pro', fileName: 'Samsung Galaxy Buds3 Pro.csv' },
|
||||
{ name: 'Sennheiser HD 600 (Rtings)', type: 'over-ear', path: 'Rtings/Bruel & Kjaer 5128 over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv' },
|
||||
];
|
||||
|
||||
// Static fallback list in case GitHub API fails — popular picks + additional well-known models
|
||||
const FALLBACK_INDEX = [
|
||||
...POPULAR_HEADPHONES,
|
||||
{ name: 'Sennheiser HD 600 (Filk)', type: 'over-ear', path: 'Filk/over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv' },
|
||||
{ name: 'Sennheiser HD 600 (Innerfidelity)', type: 'over-ear', path: 'Innerfidelity/over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv' },
|
||||
{ name: 'Samsung Galaxy Buds2 Pro (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Samsung Galaxy Buds2 Pro', fileName: 'Samsung Galaxy Buds2 Pro.csv' },
|
||||
{ name: 'Sony WF-1000XM5 (Kazi)', type: 'in-ear', path: 'Kazi/in-ear/Sony WF-1000XM5', fileName: 'Sony WF-1000XM5.csv' },
|
||||
{ name: 'Samsung Galaxy Buds3 Pro (DHRME)', type: 'in-ear', path: 'DHRME/in-ear/Samsung Galaxy Buds3 Pro', fileName: 'Samsung Galaxy Buds3 Pro.csv' },
|
||||
{ name: 'Apple AirPods Pro (Super Review)', type: 'in-ear', path: 'Super Review/in-ear/Apple AirPods Pro', fileName: 'Apple AirPods Pro.csv' },
|
||||
{ name: 'Sennheiser HD 600 (2020) (Kuulokenurkka)', type: 'over-ear', path: 'Kuulokenurkka/over-ear/Sennheiser HD 600 (2020)', fileName: 'Sennheiser HD 600 (2020).csv' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Fetch the full AutoEq headphone index from GitHub
|
||||
* Uses GitHub API to get the repository tree, then parses it for measurement files
|
||||
* Caches results in localStorage for 24 hours
|
||||
* @returns {Promise<Array<{name: string, type: string, path: string, fileName: string}>>}
|
||||
*/
|
||||
async function fetchAutoEqIndex() {
|
||||
// Migrate: remove old localStorage cache to free quota
|
||||
try { localStorage.removeItem(OLD_LS_CACHE_KEY); } catch { /* ignore */ }
|
||||
|
||||
// 1. Try loading from IndexedDB cache
|
||||
try {
|
||||
const cached = await db.getSetting(CACHE_KEY);
|
||||
if (cached && cached.timestamp && cached.data) {
|
||||
if (Date.now() - cached.timestamp < CACHE_EXPIRY) {
|
||||
console.log('[AutoEQ] Loaded index from cache');
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[AutoEQ] Failed to read cache:', e);
|
||||
}
|
||||
|
||||
// 2. Fetch from GitHub API
|
||||
try {
|
||||
console.log('[AutoEQ] Fetching index from GitHub...');
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch('https://api.github.com/repos/jaakkopasanen/AutoEq/git/trees/master?recursive=1', { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const cached = await db.getSetting(CACHE_KEY);
|
||||
if (cached?.data) {
|
||||
console.warn('[AutoEQ] GitHub API limit reached. Using stale cache.');
|
||||
return cached.data;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
console.warn('[AutoEQ] GitHub API error. Using fallback.');
|
||||
return FALLBACK_INDEX;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const entries = [];
|
||||
|
||||
for (const item of data.tree) {
|
||||
if (!item.path.startsWith('results/')) continue;
|
||||
if (!item.path.endsWith('.csv') && !item.path.endsWith('.txt')) continue;
|
||||
|
||||
const parts = item.path.split('/');
|
||||
if (parts.length < 4) continue;
|
||||
|
||||
const fileName = parts.pop();
|
||||
const fileNameLower = fileName.toLowerCase();
|
||||
|
||||
// Skip non-measurement files (EQ presets, not raw frequency response)
|
||||
if (fileNameLower.includes('parametriceq') ||
|
||||
fileNameLower.includes('fixedbandeq') ||
|
||||
fileNameLower.includes('graphiceq') ||
|
||||
fileNameLower.includes('convolution') ||
|
||||
fileNameLower.includes('fixed band eq') ||
|
||||
fileNameLower.includes('parametric eq') ||
|
||||
fileNameLower.includes('graphic eq')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const headphoneName = parts[parts.length - 1];
|
||||
const folderPath = parts.slice(1).join('/');
|
||||
const source = parts[1];
|
||||
|
||||
let type = 'over-ear';
|
||||
const lowerPath = item.path.toLowerCase();
|
||||
if (lowerPath.includes('in-ear') || lowerPath.includes('iem')) {
|
||||
type = 'in-ear';
|
||||
} else if (lowerPath.includes('earbud')) {
|
||||
type = 'in-ear';
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: `${headphoneName} (${source})`,
|
||||
type,
|
||||
path: folderPath,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length === 0) return FALLBACK_INDEX;
|
||||
|
||||
const sortedEntries = entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// 3. Save to IndexedDB cache
|
||||
try {
|
||||
await db.saveSetting(CACHE_KEY, {
|
||||
timestamp: Date.now(),
|
||||
data: sortedEntries,
|
||||
});
|
||||
console.log(`[AutoEQ] Cached ${sortedEntries.length} entries`);
|
||||
} catch (e) {
|
||||
console.warn('[AutoEQ] Failed to save cache:', e);
|
||||
}
|
||||
|
||||
return sortedEntries;
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
console.warn('[AutoEQ] GitHub API request timed out. Falling back to cache or fallback index.');
|
||||
try {
|
||||
const cached = await db.getSetting(CACHE_KEY);
|
||||
if (cached?.data) return cached.data;
|
||||
} catch { /* ignore */ }
|
||||
} else {
|
||||
console.error('[AutoEQ] Failed to fetch index:', err);
|
||||
}
|
||||
return FALLBACK_INDEX;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the frequency response measurement data for a specific headphone
|
||||
* Tries raw GitHub first, falls back to jsDelivr CDN
|
||||
* @param {object} entry - AutoEq entry {name, type, path, fileName}
|
||||
* @returns {Promise<Array<{freq: number, gain: number}>>}
|
||||
*/
|
||||
async function fetchHeadphoneData(entry) {
|
||||
const encodedPath = entry.path.split('/').map(encodeURIComponent).join('/');
|
||||
const encodedFileName = encodeURIComponent(entry.fileName);
|
||||
|
||||
const urls = [
|
||||
`https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results/${encodedPath}/${encodedFileName}`,
|
||||
`https://cdn.jsdelivr.net/gh/jaakkopasanen/AutoEq@master/results/${encodedPath}/${encodedFileName}`,
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (!response.ok) continue;
|
||||
|
||||
const text = await response.text();
|
||||
// Validate it's not an HTML error page
|
||||
if (text.trim().startsWith('<!') || text.trim().startsWith('<html')) continue;
|
||||
|
||||
const points = parseRawData(text);
|
||||
if (points.length > 0) return points;
|
||||
} catch (e) {
|
||||
console.warn(`[AutoEQ] Fetch failed for ${url}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch data for ${entry.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search/filter headphone entries by query and optional type filter
|
||||
* @param {string} query - Search query
|
||||
* @param {Array} entries - Full list of entries
|
||||
* @param {string} typeFilter - Optional type filter ('all', 'over-ear', 'in-ear')
|
||||
* @param {number} limit - Maximum results to return
|
||||
* @returns {Array}
|
||||
*/
|
||||
function searchHeadphones(query, entries, typeFilter = 'all', limit = 100) {
|
||||
let filtered = entries;
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(e => e.type === typeFilter);
|
||||
}
|
||||
|
||||
if (query && query.trim()) {
|
||||
const lower = query.toLowerCase().trim();
|
||||
filtered = filtered.filter(e => e.name.toLowerCase().includes(lower));
|
||||
}
|
||||
|
||||
return filtered.slice(0, limit);
|
||||
}
|
||||
|
||||
export { fetchAutoEqIndex, fetchHeadphoneData, searchHeadphones, POPULAR_HEADPHONES };
|
||||
|
|
@ -621,8 +621,9 @@ export class Equalizer {
|
|||
|
||||
this.frequencies.forEach((freq, index) => {
|
||||
const gain = this.currentGains[index] || 0;
|
||||
const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index);
|
||||
const filterNum = index + 1;
|
||||
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
|
||||
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
|
|
@ -680,16 +681,25 @@ export class Equalizer {
|
|||
this.setBandCount(newCount);
|
||||
}
|
||||
|
||||
// Extract gains from filters
|
||||
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
|
||||
this.setAllGains(gains);
|
||||
// Apply imported filter frequencies directly instead of regenerating
|
||||
const sliced = filters.slice(0, this.bandCount);
|
||||
const newFreqs = sliced.map((f) => f.freq);
|
||||
this.frequencies = newFreqs;
|
||||
this.frequencyLabels = generateFrequencyLabels(newFreqs);
|
||||
|
||||
// Store filter frequencies if different
|
||||
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
|
||||
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
|
||||
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
|
||||
// Update filter frequencies on the actual biquad nodes
|
||||
if (this.filters.length === newFreqs.length) {
|
||||
newFreqs.forEach((freq, i) => {
|
||||
if (this.filters[i]) {
|
||||
this.filters[i].frequency.value = freq;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract and apply gains
|
||||
const gains = sliced.map((f) => f.gain);
|
||||
this.setAllGains(gains);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[Equalizer] Failed to import settings:', e);
|
||||
|
|
|
|||
30
js/player.js
30
js/player.js
|
|
@ -16,7 +16,6 @@ import {
|
|||
exponentialVolumeSettings,
|
||||
audioEffectsSettings,
|
||||
radioSettings,
|
||||
playbackSettings,
|
||||
} from './storage.js';
|
||||
import { audioContextManager } from './audio-context.js';
|
||||
import { isIos, isSafari } from './platform-detection.js';
|
||||
|
|
@ -49,7 +48,6 @@ export class Player {
|
|||
this.repeatMode = REPEAT_MODE.OFF;
|
||||
this.preloadCache = new Map();
|
||||
this.preloadAbortController = null;
|
||||
this._lastPreloadTime = null;
|
||||
this.currentTrack = null;
|
||||
this.currentRgValues = null;
|
||||
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
|
||||
|
|
@ -108,6 +106,7 @@ export class Player {
|
|||
bufferingGoal: 30,
|
||||
rebufferingGoal: 2,
|
||||
bufferBehind: 30,
|
||||
jumpLargeGaps: true,
|
||||
},
|
||||
abr: {
|
||||
enabled: true,
|
||||
|
|
@ -151,6 +150,7 @@ export class Player {
|
|||
document.addEventListener('visibilitychange', () => {
|
||||
const el = this.activeElement;
|
||||
if (document.visibilityState === 'visible' && !el.paused) {
|
||||
// Ensure audio context is resumed when user returns to the app
|
||||
if (!audioContextManager.isReady()) {
|
||||
audioContextManager.init(el);
|
||||
}
|
||||
|
|
@ -162,17 +162,6 @@ export class Player {
|
|||
}
|
||||
});
|
||||
|
||||
// Time-based preload trigger for Safari background playback
|
||||
this._timeUpdateHandler = this._handleTimeUpdateForPreload.bind(this);
|
||||
this.audio.addEventListener('timeupdate', this._timeUpdateHandler);
|
||||
if (this.video) {
|
||||
this.video.addEventListener('timeupdate', this._timeUpdateHandler);
|
||||
}
|
||||
|
||||
window.addEventListener('preload-time-change', () => {
|
||||
this._lastPreloadTime = null;
|
||||
});
|
||||
|
||||
this._setupVideoSync();
|
||||
}
|
||||
|
||||
|
|
@ -527,21 +516,6 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
_handleTimeUpdateForPreload() {
|
||||
const el = this.activeElement;
|
||||
if (!el || !el.duration || el.paused) return;
|
||||
|
||||
const preloadTime = playbackSettings.getPreloadTime();
|
||||
const timeRemaining = el.duration - el.currentTime;
|
||||
if (timeRemaining <= preloadTime && timeRemaining > 0) {
|
||||
const now = Date.now();
|
||||
if (!this._lastPreloadTime || now - this._lastPreloadTime > 5000) {
|
||||
this._lastPreloadTime = now;
|
||||
this.preloadNextTracks();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setupHlsVideo(video, result, fallbackImg) {
|
||||
const url = result.videoUrl || result.hlsUrl || result;
|
||||
const Hls = (await import('hls.js')).default;
|
||||
|
|
|
|||
4122
js/settings.js
4122
js/settings.js
File diff suppressed because it is too large
Load diff
228
js/storage.js
228
js/storage.js
|
|
@ -999,39 +999,11 @@ export const visualizerSettings = {
|
|||
},
|
||||
};
|
||||
|
||||
export const playbackSettings = {
|
||||
FULLSCREEN_TILT_KEY: 'playback-fullscreen-tilt',
|
||||
PRELOAD_TIME_KEY: 'playback-preload-time',
|
||||
|
||||
isFullscreenTiltEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(this.FULLSCREEN_TILT_KEY) !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
setFullscreenTiltEnabled(enabled) {
|
||||
localStorage.setItem(this.FULLSCREEN_TILT_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
getPreloadTime() {
|
||||
try {
|
||||
const val = localStorage.getItem(this.PRELOAD_TIME_KEY);
|
||||
return val ? parseInt(val, 10) : 15;
|
||||
} catch {
|
||||
return 15;
|
||||
}
|
||||
},
|
||||
|
||||
setPreloadTime(seconds) {
|
||||
localStorage.setItem(this.PRELOAD_TIME_KEY, seconds.toString());
|
||||
},
|
||||
};
|
||||
|
||||
export const equalizerSettings = {
|
||||
ENABLED_KEY: 'equalizer-enabled',
|
||||
GAINS_KEY: 'equalizer-gains',
|
||||
BAND_TYPES_KEY: 'equalizer-band-types',
|
||||
BAND_QS_KEY: 'equalizer-band-qs',
|
||||
PRESET_KEY: 'equalizer-preset',
|
||||
CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
|
||||
BAND_COUNT_KEY: 'equalizer-band-count',
|
||||
|
|
@ -1308,6 +1280,62 @@ export const equalizerSettings = {
|
|||
}
|
||||
},
|
||||
|
||||
getBandTypes(bandCount) {
|
||||
const count = bandCount || this.getBandCount();
|
||||
try {
|
||||
const stored = localStorage.getItem(this.BAND_TYPES_KEY);
|
||||
if (stored) {
|
||||
const types = JSON.parse(stored);
|
||||
if (Array.isArray(types) && types.length === count) {
|
||||
return types;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return new Array(count).fill('peaking');
|
||||
},
|
||||
|
||||
setBandTypes(types) {
|
||||
try {
|
||||
if (Array.isArray(types) && types.length >= this.MIN_BANDS && types.length <= this.MAX_BANDS) {
|
||||
localStorage.setItem(this.BAND_TYPES_KEY, JSON.stringify(types));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EQ] Failed to save band types:', e);
|
||||
}
|
||||
},
|
||||
|
||||
getBandQs(bandCount) {
|
||||
const count = bandCount || this.getBandCount();
|
||||
try {
|
||||
const stored = localStorage.getItem(this.BAND_QS_KEY);
|
||||
if (stored) {
|
||||
const qs = JSON.parse(stored);
|
||||
if (Array.isArray(qs) && qs.length === count) {
|
||||
return qs;
|
||||
}
|
||||
// Interpolate stored Qs to match requested band count instead of discarding
|
||||
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS) {
|
||||
return this._interpolateGains(qs, count);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
setBandQs(qs) {
|
||||
try {
|
||||
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS && qs.length <= this.MAX_BANDS) {
|
||||
localStorage.setItem(this.BAND_QS_KEY, JSON.stringify(qs));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EQ] Failed to save band Qs:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Interpolate gains array to match target band count
|
||||
*/
|
||||
|
|
@ -1440,6 +1468,130 @@ export const equalizerSettings = {
|
|||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// AutoEQ Profile Storage
|
||||
// ========================================
|
||||
AUTOEQ_PROFILES_KEY: 'autoeq-saved-profiles',
|
||||
AUTOEQ_ACTIVE_PROFILE_KEY: 'autoeq-active-profile',
|
||||
AUTOEQ_SAMPLE_RATE_KEY: 'autoeq-sample-rate',
|
||||
|
||||
getAutoEQProfiles() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.AUTOEQ_PROFILES_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
saveAutoEQProfile(profile) {
|
||||
try {
|
||||
const profiles = this.getAutoEQProfiles();
|
||||
const id = profile.id || 'autoeq_' + Date.now();
|
||||
const profileCopy = { ...profile, id };
|
||||
profiles[id] = profileCopy;
|
||||
localStorage.setItem(this.AUTOEQ_PROFILES_KEY, JSON.stringify(profiles));
|
||||
return id;
|
||||
} catch (e) {
|
||||
console.warn('[AutoEQ] Failed to save profile:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
deleteAutoEQProfile(profileId) {
|
||||
try {
|
||||
const profiles = this.getAutoEQProfiles();
|
||||
if (profiles[profileId]) {
|
||||
delete profiles[profileId];
|
||||
localStorage.setItem(this.AUTOEQ_PROFILES_KEY, JSON.stringify(profiles));
|
||||
if (this.getActiveAutoEQProfile() === profileId) {
|
||||
localStorage.removeItem(this.AUTOEQ_ACTIVE_PROFILE_KEY);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.warn('[AutoEQ] Failed to delete profile:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
getActiveAutoEQProfile() {
|
||||
try {
|
||||
return localStorage.getItem(this.AUTOEQ_ACTIVE_PROFILE_KEY) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setActiveAutoEQProfile(profileId) {
|
||||
if (profileId) {
|
||||
localStorage.setItem(this.AUTOEQ_ACTIVE_PROFILE_KEY, profileId);
|
||||
} else {
|
||||
localStorage.removeItem(this.AUTOEQ_ACTIVE_PROFILE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
getSampleRate() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.AUTOEQ_SAMPLE_RATE_KEY);
|
||||
const val = parseInt(stored, 10);
|
||||
return [44100, 48000, 96000].includes(val) ? val : 48000;
|
||||
} catch {
|
||||
return 48000;
|
||||
}
|
||||
},
|
||||
|
||||
setSampleRate(rate) {
|
||||
localStorage.setItem(this.AUTOEQ_SAMPLE_RATE_KEY, rate.toString());
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Last Selected Headphone Persistence
|
||||
// ========================================
|
||||
AUTOEQ_LAST_HEADPHONE_KEY: 'autoeq-last-headphone',
|
||||
|
||||
/**
|
||||
* Save the last selected headphone entry + its measurement data
|
||||
* so it persists across page reloads without re-fetching from GitHub
|
||||
* @param {object} entry - {name, type, path, fileName}
|
||||
* @param {Array} measurementData - [{freq, gain}, ...]
|
||||
*/
|
||||
setLastHeadphone(entry, measurementData) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
this.AUTOEQ_LAST_HEADPHONE_KEY,
|
||||
JSON.stringify({
|
||||
entry,
|
||||
measurementData,
|
||||
savedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[AutoEQ] Failed to save last headphone:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve the last selected headphone entry + cached measurement data
|
||||
* @returns {{entry: object, measurementData: Array}|null}
|
||||
*/
|
||||
getLastHeadphone() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
||||
if (!stored) return null;
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && parsed.entry && parsed.measurementData) return parsed;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
clearLastHeadphone() {
|
||||
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
export const monoAudioSettings = {
|
||||
|
|
@ -2573,7 +2725,7 @@ export const contentBlockingSettings = {
|
|||
|
||||
isArtistBlocked(artistId) {
|
||||
if (!artistId) return false;
|
||||
return this.getBlockedArtists().some((a) => a.id == artistId);
|
||||
return this.getBlockedArtists().some((a) => String(a.id) === String(artistId));
|
||||
},
|
||||
|
||||
blockArtist(artist) {
|
||||
|
|
@ -2590,7 +2742,7 @@ export const contentBlockingSettings = {
|
|||
},
|
||||
|
||||
unblockArtist(artistId) {
|
||||
const blocked = this.getBlockedArtists().filter((a) => a.id != artistId);
|
||||
const blocked = this.getBlockedArtists().filter((a) => a.id !== artistId);
|
||||
this.setBlockedArtists(blocked);
|
||||
},
|
||||
|
||||
|
|
@ -2610,13 +2762,13 @@ export const contentBlockingSettings = {
|
|||
|
||||
isTrackBlocked(trackId) {
|
||||
if (!trackId) return false;
|
||||
return this.getBlockedTracks().some((t) => t.id == trackId);
|
||||
return this.getBlockedTracks().some((t) => t.id === trackId);
|
||||
},
|
||||
|
||||
blockTrack(track) {
|
||||
if (!track || !track.id) return;
|
||||
const blocked = this.getBlockedTracks();
|
||||
if (!blocked.some((t) => t.id == track.id)) {
|
||||
if (!blocked.some((t) => t.id === track.id)) {
|
||||
blocked.push({
|
||||
id: track.id,
|
||||
title: track.title || 'Unknown Track',
|
||||
|
|
@ -2628,7 +2780,7 @@ export const contentBlockingSettings = {
|
|||
},
|
||||
|
||||
unblockTrack(trackId) {
|
||||
const blocked = this.getBlockedTracks().filter((t) => t.id != trackId);
|
||||
const blocked = this.getBlockedTracks().filter((t) => t.id !== trackId);
|
||||
this.setBlockedTracks(blocked);
|
||||
},
|
||||
|
||||
|
|
@ -2648,13 +2800,13 @@ export const contentBlockingSettings = {
|
|||
|
||||
isAlbumBlocked(albumId) {
|
||||
if (!albumId) return false;
|
||||
return this.getBlockedAlbums().some((a) => a.id == albumId);
|
||||
return this.getBlockedAlbums().some((a) => a.id === albumId);
|
||||
},
|
||||
|
||||
blockAlbum(album) {
|
||||
if (!album || !album.id) return;
|
||||
const blocked = this.getBlockedAlbums();
|
||||
if (!blocked.some((a) => a.id == album.id)) {
|
||||
if (!blocked.some((a) => a.id === album.id)) {
|
||||
blocked.push({
|
||||
id: album.id,
|
||||
title: album.title || 'Unknown Album',
|
||||
|
|
@ -2666,7 +2818,7 @@ export const contentBlockingSettings = {
|
|||
},
|
||||
|
||||
unblockAlbum(albumId) {
|
||||
const blocked = this.getBlockedAlbums().filter((a) => a.id != albumId);
|
||||
const blocked = this.getBlockedAlbums().filter((a) => a.id !== albumId);
|
||||
this.setBlockedAlbums(blocked);
|
||||
},
|
||||
|
||||
|
|
|
|||
52
js/ui.js
52
js/ui.js
|
|
@ -26,7 +26,6 @@ import {
|
|||
fontSettings,
|
||||
contentBlockingSettings,
|
||||
settingsUiState,
|
||||
playbackSettings,
|
||||
} from './storage.js';
|
||||
import { db } from './db.js';
|
||||
import { getVibrantColorFromImage } from './vibrant-color.js';
|
||||
|
|
@ -151,9 +150,6 @@ export class UIRenderer {
|
|||
this.lastRecommendedTracks = [];
|
||||
this.currentArtistId = null;
|
||||
|
||||
this._handleTiltMove = this._handleTiltMove.bind(this);
|
||||
this._handleTiltLeave = this._handleTiltLeave.bind(this);
|
||||
|
||||
// Listen for dynamic color reset events
|
||||
window.addEventListener('reset-dynamic-color', () => {
|
||||
this.resetVibrantColor();
|
||||
|
|
@ -1231,14 +1227,6 @@ export class UIRenderer {
|
|||
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
// Apply vanilla-tilt effect to fullscreen cover if enabled
|
||||
this._applyFullscreenTilt(overlay);
|
||||
|
||||
// Listen for tilt setting changes
|
||||
window.addEventListener('fullscreen-tilt-toggle', (e) => {
|
||||
this._applyFullscreenTilt(overlay, e.detail.enabled);
|
||||
});
|
||||
|
||||
const startVisualizer = async () => {
|
||||
if (!visualizerSettings.isEnabled()) {
|
||||
if (this.visualizer) this.visualizer.stop();
|
||||
|
|
@ -1332,46 +1320,6 @@ export class UIRenderer {
|
|||
clearTimeout(this.uiToggleMouseTimer);
|
||||
this.uiToggleMouseTimer = null;
|
||||
}
|
||||
|
||||
// Clean up vanilla-tilt if applied
|
||||
this._removeFullscreenTilt();
|
||||
}
|
||||
|
||||
_applyFullscreenTilt(overlay, enabled = playbackSettings.isFullscreenTiltEnabled()) {
|
||||
const image = document.getElementById('fullscreen-cover-image');
|
||||
if (!image) return;
|
||||
|
||||
this._removeFullscreenTilt();
|
||||
|
||||
if (!enabled) return;
|
||||
|
||||
image.addEventListener('mousemove', this._handleTiltMove);
|
||||
image.addEventListener('mouseleave', this._handleTiltLeave);
|
||||
}
|
||||
|
||||
_handleTiltMove(e) {
|
||||
const image = e.target;
|
||||
const rect = image.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
const rotateX = ((y - centerY) / centerY) * -10;
|
||||
const rotateY = ((x - centerX) / centerX) * 10;
|
||||
|
||||
image.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.02)`;
|
||||
}
|
||||
|
||||
_handleTiltLeave(e) {
|
||||
e.target.style.transform = 'perspective(1000px) rotateX(0) rotateY(0) scale(1)';
|
||||
}
|
||||
|
||||
_removeFullscreenTilt() {
|
||||
const image = document.getElementById('fullscreen-cover-image');
|
||||
if (!image) return;
|
||||
image.removeEventListener('mousemove', this._handleTiltMove);
|
||||
image.removeEventListener('mouseleave', this._handleTiltLeave);
|
||||
image.style.transform = '';
|
||||
}
|
||||
|
||||
setupUIToggleButton(overlay) {
|
||||
|
|
|
|||
1693
styles.css
1693
styles.css
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue