fix: address all CodeRabbit review findings from PR #477

Engine & algorithm:
- Use default shelf Q (1/√2) in calculateBiquadResponse for shelf filters
- Compute normalization offset on measurement frequency grid to avoid bias
- Try stale cache for all fetch errors in autoeq-importer, not just timeouts

Audio pipeline:
- Pass postProcessingQuality (preserves Dolby Atmos override) in api.js
- Persist custom band frequencies in equalizerSettings storage
- Restore custom frequencies on _loadSettings instead of regenerating defaults
- Export clamped preamp value in applyAutoEQBands text output
- Propagate filter type and Q values through equalizer import chain
- Update freqRange after importing custom filter frequencies
- Remove return in finally block that hid LOSSLESS fallback failures

Data consistency:
- Normalize artist IDs with String() in blockArtist/unblockArtist

Lint & code quality:
- Annotate empty catch blocks (Atmos codec probes)
- Remove unused catch parameters

Accessibility:
- Add aria-label and for attributes to all AutoEQ form controls
- Add role="status" aria-live="polite" to feedback spans
- Update filter type documentation to reflect shelf support
- Hide parametric-only sections by default to match active tab

UI:
- Move AutoEq button directly under graph
- Hide shared button in Parametric/Speaker modes
- Replace hardcoded white legend dot with theme-adaptive color-mix
- Add pointer-events:none and focus-within to profile delete button
This commit is contained in:
tryptz 2026-04-01 21:34:14 -04:00 committed by edideaur
parent 77f9e10fdc
commit 782e98061b
10 changed files with 149 additions and 50 deletions

View file

@ -4046,8 +4046,8 @@
<h4>Parametric EQ &mdash; Manual Control</h4>
<ol>
<li>
Each band is a <b>peaking filter</b> with frequency, gain, and Q
(width).
Each band supports <b>peaking, low-shelf, and high-shelf</b> filter
types with frequency, gain, and Q (width).
</li>
<li>
<b>Drag nodes</b> on the graph to adjust frequency and gain
@ -4162,6 +4162,7 @@
<span class="slider"></span>
</label>
</div>
<button id="autoeq-run-btn" class="autoeq-run-btn" disabled>AutoEq</button>
</div>
<!-- Database Browser -->
@ -4180,6 +4181,7 @@
id="autoeq-headphone-search"
placeholder="Search model (e.g. HD 600)..."
autocomplete="off"
aria-label="Search headphone model"
/>
</div>
<div class="autoeq-database-content">
@ -4194,11 +4196,10 @@
<!-- AutoEQ Controls -->
<div class="autoeq-controls-section">
<button id="autoeq-run-btn" class="autoeq-run-btn" disabled>AutoEq</button>
<div class="autoeq-control-group">
<label class="autoeq-control-label">HEADPHONE MODEL</label>
<label class="autoeq-control-label" for="autoeq-headphone-select">HEADPHONE MODEL</label>
<div class="autoeq-select-wrapper">
<select id="autoeq-headphone-select">
<select id="autoeq-headphone-select" aria-label="Headphone model">
<option value="">Select a headphone...</option>
</select>
<button
@ -4257,8 +4258,8 @@
<div class="autoeq-controls-row">
<div class="autoeq-control-mini">
<label class="autoeq-control-label">FILTER BANDS</label>
<select id="autoeq-band-count">
<label class="autoeq-control-label" for="autoeq-band-count">FILTER BANDS</label>
<select id="autoeq-band-count" aria-label="Filter bands">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="15">15</option>
@ -4267,8 +4268,8 @@
</select>
</div>
<div class="autoeq-control-mini">
<label class="autoeq-control-label">MAX HZ</label>
<select id="autoeq-max-freq">
<label class="autoeq-control-label" for="autoeq-max-freq">MAX HZ</label>
<select id="autoeq-max-freq" aria-label="Maximum frequency">
<option value="6000">6k</option>
<option value="8000">8k</option>
<option value="10000">10k</option>
@ -4278,8 +4279,8 @@
</select>
</div>
<div class="autoeq-control-mini">
<label class="autoeq-control-label">SAMPLE RATE</label>
<select id="autoeq-sample-rate">
<label class="autoeq-control-label" for="autoeq-sample-rate">SAMPLE RATE</label>
<select id="autoeq-sample-rate" aria-label="Sample rate">
<option value="44100">44.1k</option>
<option value="48000" selected>48k</option>
<option value="96000">96k</option>
@ -4297,7 +4298,7 @@
<use svg="!lucide/download.svg" size="20" />
</button>
</div>
<span id="autoeq-status" class="autoeq-status"></span>
<span id="autoeq-status" class="autoeq-status" role="status" aria-live="polite"></span>
</div>
<!-- Saved Profiles -->
@ -4313,6 +4314,7 @@
id="autoeq-profile-name"
class="autoeq-profile-name-input"
placeholder="Profile name..."
aria-label="AutoEQ profile name"
maxlength="50"
/>
<button id="autoeq-save-btn" class="btn-primary autoeq-save-btn">
@ -4524,7 +4526,7 @@
>
Measure All
</button>
<span id="speaker-eq-status" class="autoeq-status"></span>
<span id="speaker-eq-status" class="autoeq-status" role="status" aria-live="polite"></span>
</div>
</div>
</div>
@ -4542,6 +4544,7 @@
id="speaker-profile-name"
class="autoeq-profile-name-input"
placeholder="Profile name..."
aria-label="Speaker EQ profile name"
maxlength="50"
/>
<button id="speaker-save-btn" class="btn-primary autoeq-save-btn">
@ -4577,7 +4580,7 @@
</div>
<div class="autoeq-filters-content" id="autoeq-filters-content">
<!-- Preset Selector (visible in parametric mode) -->
<div class="autoeq-preset-row" id="autoeq-preset-row">
<div class="autoeq-preset-row" id="autoeq-preset-row" style="display: none">
<div class="autoeq-control-group">
<label class="autoeq-control-label">PRESET</label>
<select id="parametric-preset-select">
@ -4603,7 +4606,7 @@
</div>
<!-- Saved Profiles for Parametric EQ -->
<div class="autoeq-parametric-profiles" id="autoeq-parametric-profiles">
<div class="autoeq-parametric-profiles" id="autoeq-parametric-profiles" style="display: none">
<div class="autoeq-saved-header">
<div class="autoeq-saved-header-left">
<span class="autoeq-saved-title">SAVED PROFILES</span>
@ -4617,6 +4620,7 @@
id="parametric-profile-name"
class="autoeq-profile-name-input"
placeholder="Profile name..."
aria-label="Parametric EQ profile name"
maxlength="50"
/>
<button

View file

@ -1500,7 +1500,9 @@ export class LosslessAPI {
a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"')
);
}
} catch (e) {}
} catch {
// Atmos codec probe — intentionally swallowed; canPlayAtmos stays false
}
const paramsArray = [];
@ -1888,7 +1890,7 @@ export class LosslessAPI {
quality,
onProgress,
options.signal,
lookup.info?.audioQuality ?? null
postProcessingQuality
);
}

View file

@ -728,7 +728,8 @@ class AudioContextManager {
this.isEQEnabled = equalizerSettings.isEnabled();
this.bandCount = equalizerSettings.getBandCount();
this.freqRange = equalizerSettings.getFreqRange();
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
const customFreqs = equalizerSettings.getCustomFrequencies(this.bandCount);
this.frequencies = customFreqs || 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);
@ -827,12 +828,13 @@ class AudioContextManager {
}
// Persist normalized band descriptors to settings store
equalizerSettings.setCustomFrequencies(this.frequencies);
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`];
// Generate export text using the actual applied preamp value
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
sortedBands.forEach((band, index) => {
if (index >= count) return;
const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';

View file

@ -23,7 +23,10 @@ function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
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 t = band.type[0];
// WebAudio ignores Q for shelf filters; use 1/√2 (slope = 1) to match
const effectiveQ = t === 'l' || t === 'h' ? Math.SQRT1_2 : band.q;
const s = Math.sin(w) / (2 * effectiveQ);
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
const c = Math.cos(w);
let b0 = 0,
@ -33,8 +36,6 @@ function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
a1 = 0,
a2 = 0;
const t = band.type[0];
if (t === 'p') {
b0 = 1 + s * A;
b1 = -2 * c;
@ -98,19 +99,37 @@ function interpolate(freq, data) {
/**
* 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
* With one argument: returns the midrange average of that curve (for graph centering).
* With two arguments: evaluates both curves on the measurement frequency grid
* to avoid sampling-density bias, returning (avgTarget - avgMeasurement).
* @param {Array<{freq: number, gain: number}>} measurement - Measurement/data curve
* @param {Array<{freq: number, gain: number}>} [target] - Optional target curve
* @returns {number} Midrange average, or alignment offset when target is provided
*/
function getNormalizationOffset(data) {
let sum = 0,
function getNormalizationOffset(measurement, target) {
if (!target) {
let sum = 0,
count = 0;
for (const p of measurement) {
if (p.freq >= 250 && p.freq <= 2500) {
sum += p.gain;
count++;
}
}
return count > 0 ? sum / count : interpolate(1000, measurement);
}
let sumTarget = 0,
sumMeasurement = 0,
count = 0;
for (const p of data) {
for (const p of measurement) {
if (p.freq >= 250 && p.freq <= 2500) {
sum += p.gain;
sumTarget += interpolate(p.freq, target);
sumMeasurement += p.gain;
count++;
}
}
return count > 0 ? sum / count : interpolate(1000, data);
if (count > 0) return sumTarget / count - sumMeasurement / count;
return interpolate(1000, target) - interpolate(1000, measurement);
}
/**
@ -135,7 +154,7 @@ function runAutoEqAlgorithm(
sampleRate = DEFAULT_SR
) {
if (minFreq > maxFreq) return [];
const off = getNormalizationOffset(target) - getNormalizationOffset(measurement);
const off = getNormalizationOffset(measurement, target);
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);

View file

@ -211,15 +211,15 @@ async function fetchAutoEqIndex() {
} 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);
}
try {
const cached = await db.getSetting(CACHE_KEY);
if (cached?.data) return cached.data;
} catch {
/* ignore */
}
return FALLBACK_INDEX;
}
}

View file

@ -696,10 +696,38 @@ export class Equalizer {
});
}
// Extract and apply gains
// Extract and apply gains, types, and Qs
const gains = sliced.map((f) => f.gain);
this.setAllGains(gains);
// Apply filter types (PK/LS/HS -> peaking/lowshelf/highshelf)
const typeMap = { PK: 'peaking', LS: 'lowshelf', HS: 'highshelf', LSC: 'lowshelf', HSC: 'highshelf' };
const types = sliced.map((f) => typeMap[f.type] || 'peaking');
this.currentTypes = types;
if (this.filters.length === types.length) {
types.forEach((type, i) => {
if (this.filters[i]) this.filters[i].type = type;
});
}
equalizerSettings.setBandTypes(types);
// Apply Q values
const qs = sliced.map((f) => f.q);
this.currentQs = qs;
if (this.filters.length === qs.length) {
qs.forEach((q, i) => {
if (this.filters[i]) this.filters[i].Q.value = q;
});
}
equalizerSettings.setBandQs(qs);
// Persist custom frequencies and update freqRange
equalizerSettings.setCustomFrequencies(newFreqs);
const minFreq = Math.min(...newFreqs);
const maxFreq = Math.max(...newFreqs);
this.freqRange = { min: minFreq, max: maxFreq };
equalizerSettings.setFreqRange(minFreq, maxFreq);
return true;
} catch (e) {
console.warn('[Equalizer] Failed to import settings:', e);

View file

@ -177,7 +177,9 @@ export class Player {
if (this.video.readyState >= 2 && (this.audio.readyState > 0 || this.audio.src)) {
this.audio.currentTime = this.video.currentTime;
}
} catch (err) {}
} catch {
// Video-to-audio time sync may fail if readyState is stale
}
}
const syncedEvent = new Event(eventName, { bubbles: e.bubbles, cancelable: e.cancelable });
@ -1032,12 +1034,12 @@ export class Player {
try {
await this.playTrackFromQueue(startTime, recursiveCount, true);
return;
} catch (retryError) {
} catch {
// LOSSLESS fallback also failed — fall through to error handling below
} finally {
this.quality = originalQuality;
this.isFallbackRetry = false;
this.isFallbackInProgress = false;
return;
}
}
@ -1746,7 +1748,9 @@ export class Player {
a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"')
);
}
} catch (e) {}
} catch {
// Atmos codec detection may fail on some browsers
}
let isAtmosPlaying = isTrackAtmos && deviceSupportsAtmos;
const q = this.quality || localStorage.getItem('adaptive-playback-quality') || 'auto';
@ -1814,7 +1818,7 @@ export class Player {
// Re-enable ABR so it can dynamically downgrade within that new codec family if needed
this.shakaPlayer.configure({ abr: { enabled: true } });
}
} catch (e) {
} catch {
// fail silently on abr checks
}
}

View file

@ -1571,7 +1571,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
// Draw Original measurement (normalized + shifted)
if (graphMeasurement) {
const normOff = targetData
? getNormalizationOffset(targetData) - getNormalizationOffset(graphMeasurement)
? getNormalizationOffset(graphMeasurement, targetData)
: 0;
const normalized = graphMeasurement.map((p) => ({ freq: p.freq, gain: p.gain + normOff + graphShift }));
drawCurve(normalized, originalColor, 1.5);
@ -1746,7 +1746,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
}
const targetEntry = tList.find((t) => t.id === tId);
const targetData = targetEntry?.data;
const normOff = targetData ? getNormalizationOffset(targetData) - getNormalizationOffset(measurement) : 0;
const normOff = targetData ? getNormalizationOffset(measurement, targetData) : 0;
const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000;
autoeqCorrectedCurve = measurement.map((p) => {
@ -3113,6 +3113,8 @@ export async function initializeSettings(scrobbler, player, api, ui) {
// Graph always visible in all modes
if (graphSection) graphSection.style.display = '';
// Only show shared AutoEq button in AutoEQ mode
if (autoeqRunBtn) autoeqRunBtn.style.display = mode === 'autoeq' ? '' : 'none';
// Hide all mode-specific sections first
if (controlsSection) controlsSection.style.display = 'none';

View file

@ -1012,6 +1012,7 @@ export const equalizerSettings = {
FREQ_MIN_KEY: 'equalizer-freq-min',
FREQ_MAX_KEY: 'equalizer-freq-max',
PREAMP_KEY: 'equalizer-preamp',
CUSTOM_FREQUENCIES_KEY: 'equalizer-custom-frequencies',
DEFAULT_BAND_COUNT: 16,
MIN_BANDS: 3,
MAX_BANDS: 32,
@ -1280,6 +1281,40 @@ export const equalizerSettings = {
}
},
getCustomFrequencies(bandCount) {
const count = bandCount || this.getBandCount();
try {
const stored = localStorage.getItem(this.CUSTOM_FREQUENCIES_KEY);
if (stored) {
const freqs = JSON.parse(stored);
if (Array.isArray(freqs) && freqs.length === count) {
return freqs;
}
}
} catch {
/* ignore */
}
return null;
},
setCustomFrequencies(frequencies) {
try {
if (Array.isArray(frequencies) && frequencies.length >= this.MIN_BANDS && frequencies.length <= this.MAX_BANDS) {
localStorage.setItem(this.CUSTOM_FREQUENCIES_KEY, JSON.stringify(frequencies));
}
} catch (e) {
console.warn('[EQ] Failed to save custom frequencies:', e);
}
},
clearCustomFrequencies() {
try {
localStorage.removeItem(this.CUSTOM_FREQUENCIES_KEY);
} catch {
/* ignore */
}
},
getBandTypes(bandCount) {
const count = bandCount || this.getBandCount();
try {
@ -2731,7 +2766,7 @@ export const contentBlockingSettings = {
blockArtist(artist) {
if (!artist || !artist.id) return;
const blocked = this.getBlockedArtists();
if (!blocked.some((a) => a.id === artist.id)) {
if (!blocked.some((a) => String(a.id) === String(artist.id))) {
blocked.push({
id: artist.id,
name: artist.name || 'Unknown Artist',
@ -2742,7 +2777,7 @@ export const contentBlockingSettings = {
},
unblockArtist(artistId) {
const blocked = this.getBlockedArtists().filter((a) => a.id !== artistId);
const blocked = this.getBlockedArtists().filter((a) => String(a.id) !== String(artistId));
this.setBlockedArtists(blocked);
},

View file

@ -7907,8 +7907,8 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
}
.legend-target .legend-dot {
background: rgb(255 255 255 / 0.5);
border: 1px dashed rgb(255 255 255 / 0.3);
background: color-mix(in srgb, var(--foreground) 50%, transparent);
border: 1px dashed color-mix(in srgb, var(--foreground) 30%, transparent);
}
.legend-corrected .legend-dot {
@ -8304,6 +8304,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
color: var(--muted-foreground);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: all var(--transition-fast);
font-size: 1rem;
}
@ -8313,8 +8314,10 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
color: var(--destructive-foreground);
}
.autoeq-profile-card:hover .autoeq-profile-delete {
.autoeq-profile-card:hover .autoeq-profile-delete,
.autoeq-profile-card:focus-within .autoeq-profile-delete {
opacity: 1;
pointer-events: auto;
}
/* --- Database Browser --- */