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:
tryptz 2026-04-01 16:49:42 -04:00 committed by edideaur
parent 6e98830fdd
commit d4d1fe8494
13 changed files with 10305 additions and 1890 deletions

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

221
js/autoeq-engine.js Normal file
View 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
View 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 };

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff