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
|
||||
|
|
|
|||
944
index.html
944
index.html
|
|
@ -3871,32 +3871,6 @@
|
|||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Fullscreen Cover Tilt</span>
|
||||
<span class="description"
|
||||
>3D tilt effect on album cover in fullscreen view</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="fullscreen-tilt-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Preload Next Track</span>
|
||||
<span class="description">Seconds before track ends to start loading next</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
id="preload-time-input"
|
||||
min="5"
|
||||
max="60"
|
||||
value="15"
|
||||
style="width: 60px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
|
|
@ -3995,9 +3969,9 @@
|
|||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Equalizer</span>
|
||||
<span class="label">AutoEQ</span>
|
||||
<span class="description"
|
||||
>16-band parametric equalizer for fine audio control</span
|
||||
>Precision headphone correction & parametric equalizer</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
|
|
@ -4007,11 +3981,607 @@
|
|||
</div>
|
||||
|
||||
<div class="equalizer-container" id="equalizer-container" style="display: none">
|
||||
<div class="equalizer-header">
|
||||
<div class="equalizer-preset-row">
|
||||
<label for="equalizer-preset-select">Preset</label>
|
||||
<select id="equalizer-preset-select">
|
||||
<optgroup label="Built-in Presets">
|
||||
<!-- Mode Toggle + How To -->
|
||||
<div class="autoeq-mode-row">
|
||||
<div class="autoeq-mode-toggle">
|
||||
<button class="autoeq-mode-btn active" data-mode="autoeq">AutoEQ</button>
|
||||
<button class="autoeq-mode-btn" data-mode="parametric">
|
||||
Parametric EQ
|
||||
</button>
|
||||
<button class="autoeq-mode-btn" data-mode="speaker">Speaker EQ</button>
|
||||
</div>
|
||||
<button class="eq-howto-btn" id="eq-howto-btn" title="How to use">?</button>
|
||||
</div>
|
||||
|
||||
<!-- Tutorial Panel -->
|
||||
<div class="eq-howto-panel" id="eq-howto-panel" style="display: none">
|
||||
<button class="eq-howto-close" id="eq-howto-close" aria-label="Close tutorial">
|
||||
×
|
||||
</button>
|
||||
|
||||
<div class="eq-howto-tab autoeq" id="eq-howto-autoeq">
|
||||
<h4>AutoEQ — Headphone Correction</h4>
|
||||
<ol>
|
||||
<li>
|
||||
<b>Select your headphone</b> from the dropdown or search the
|
||||
database below.
|
||||
</li>
|
||||
<li>
|
||||
<b>Pick a target curve</b> — Harman is the most popular. You
|
||||
can also import a custom target.
|
||||
</li>
|
||||
<li>
|
||||
Adjust <b>filter bands</b> (more = finer correction, 10 is a good
|
||||
default).
|
||||
</li>
|
||||
<li>
|
||||
Click <b>AutoEQ</b> — the algorithm generates parametric
|
||||
filters that shape your headphone's response toward the target.
|
||||
</li>
|
||||
<li>
|
||||
The <b>pink corrected curve</b> on the graph shows the predicted
|
||||
result.
|
||||
</li>
|
||||
<li>
|
||||
<b>Double-click an empty area</b> on the graph to add a new filter
|
||||
band at that frequency. <b>Double-click an existing node</b> to
|
||||
remove it.
|
||||
</li>
|
||||
<li>
|
||||
<b>Save</b> the profile so you can switch between headphones
|
||||
instantly.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="eq-howto-tip">
|
||||
Tip: Use "Auto Preamp Compensation" to prevent clipping from positive EQ
|
||||
gains.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="eq-howto-tab parametric"
|
||||
id="eq-howto-parametric"
|
||||
style="display: none"
|
||||
>
|
||||
<h4>Parametric EQ — Manual Control</h4>
|
||||
<ol>
|
||||
<li>
|
||||
Each band is a <b>peaking filter</b> with frequency, gain, and Q
|
||||
(width).
|
||||
</li>
|
||||
<li>
|
||||
<b>Drag nodes</b> on the graph to adjust frequency and gain
|
||||
visually.
|
||||
</li>
|
||||
<li>
|
||||
<b>Double-click an empty area</b> on the graph to add a new band at
|
||||
that exact frequency and gain.
|
||||
<b>Double-click an existing node</b> to delete it.
|
||||
</li>
|
||||
<li>
|
||||
Use <b>+ Add Band / - Remove Band</b> to change the number of
|
||||
filters.
|
||||
</li>
|
||||
<li>
|
||||
Pick a <b>preset</b> (Bass Boost, Vocal, etc.) as a starting point,
|
||||
then fine-tune.
|
||||
</li>
|
||||
<li>
|
||||
<b>Import/Export</b> settings in EqualizerAPO format for use in
|
||||
other apps.
|
||||
</li>
|
||||
<li>
|
||||
<b>Save</b> profiles with custom names to keep your favorite EQ
|
||||
curves.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="eq-howto-tip">
|
||||
Tip: Lower Q = wider curve. Higher Q = narrower surgical cut.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="eq-howto-tab speaker" id="eq-howto-speaker" style="display: none">
|
||||
<h4>Speaker EQ — Room Correction</h4>
|
||||
<ol>
|
||||
<li>Select your <b>speaker config</b> (2.0, 5.1, or 7.1).</li>
|
||||
<li>
|
||||
Click a <b>channel tab</b> (FL, FR, etc.) to work on one speaker at
|
||||
a time.
|
||||
</li>
|
||||
<li>
|
||||
<b>Measure</b>: click the mic button — pink noise plays for 5
|
||||
seconds while your microphone captures the room response. Or
|
||||
<b>import</b> a measurement file from REW or similar.
|
||||
</li>
|
||||
<li>
|
||||
Pick a <b>target</b> — Harman In-Room is recommended for
|
||||
speakers.
|
||||
</li>
|
||||
<li>
|
||||
Set <b>Bass Limit</b> (don't EQ below this) and
|
||||
<b>Room Limit</b> (don't EQ above this) — the colored lines on
|
||||
the graph show the active range.
|
||||
</li>
|
||||
<li>
|
||||
Click <b>AutoEQ</b> to generate correction filters for that channel.
|
||||
</li>
|
||||
<li>
|
||||
<b>Double-click an empty area</b> on the graph to add a filter band
|
||||
at that frequency. <b>Double-click an existing node</b> to remove
|
||||
it.
|
||||
</li>
|
||||
<li>
|
||||
Repeat for each channel, then <b>Export JSON</b> with all channels.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="eq-howto-tip">
|
||||
Tip: Place your mic at the listening position. Measure each speaker
|
||||
separately for best results.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frequency Response Graph -->
|
||||
<div class="autoeq-graph-section">
|
||||
<div class="autoeq-graph-header">
|
||||
<span class="autoeq-graph-title">Frequency Response</span>
|
||||
<div class="autoeq-graph-legend">
|
||||
<span class="legend-item legend-original"
|
||||
><span class="legend-dot"></span>Original</span
|
||||
>
|
||||
<span class="legend-item legend-target"
|
||||
><span class="legend-dot"></span>Target (Primary)</span
|
||||
>
|
||||
<span class="legend-item legend-corrected"
|
||||
><span class="legend-dot"></span>Corrected</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="autoeq-graph-wrapper" id="autoeq-graph-wrapper">
|
||||
<canvas id="autoeq-response-canvas" class="autoeq-response-canvas"></canvas>
|
||||
</div>
|
||||
<div class="autoeq-auto-preamp">
|
||||
<div class="autoeq-preamp-row">
|
||||
<span class="autoeq-preamp-label">Preamp</span>
|
||||
<input
|
||||
type="range"
|
||||
id="eq-preamp-slider"
|
||||
class="autoeq-preamp-slider"
|
||||
min="-20"
|
||||
max="20"
|
||||
step="0.1"
|
||||
value="0"
|
||||
/>
|
||||
<span class="autoeq-preamp-value" id="autoeq-preamp-value">0 dB</span>
|
||||
</div>
|
||||
<label class="autoeq-auto-preamp-label" for="autoeq-auto-preamp-toggle">
|
||||
Auto Preamp Compensation
|
||||
</label>
|
||||
<label class="toggle-switch toggle-switch-sm">
|
||||
<input type="checkbox" id="autoeq-auto-preamp-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Browser -->
|
||||
<div class="autoeq-database-section" id="autoeq-database-section">
|
||||
<div class="autoeq-database-header">
|
||||
<div>
|
||||
<h4 class="autoeq-database-title">Database</h4>
|
||||
<span class="autoeq-database-subtitle">AutoEq Repo</span>
|
||||
</div>
|
||||
<span class="autoeq-database-count" id="autoeq-database-count"></span>
|
||||
</div>
|
||||
<div class="autoeq-database-search">
|
||||
<use svg="!lucide/search.svg" size="16" />
|
||||
<input
|
||||
type="text"
|
||||
id="autoeq-headphone-search"
|
||||
placeholder="Search model (e.g. HD 600)..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="autoeq-database-content">
|
||||
<div class="autoeq-database-list" id="autoeq-database-list">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
<div class="autoeq-database-alpha-index" id="autoeq-alpha-index">
|
||||
<!-- A-Z generated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div class="autoeq-select-wrapper">
|
||||
<select id="autoeq-headphone-select">
|
||||
<option value="">Select a headphone...</option>
|
||||
</select>
|
||||
<button
|
||||
id="autoeq-import-measurement-btn"
|
||||
class="autoeq-settings-btn"
|
||||
title="Import measurement file"
|
||||
aria-label="Import measurement file"
|
||||
>
|
||||
<use svg="!lucide/file-input.svg" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="autoeq-control-group">
|
||||
<label class="autoeq-control-label"
|
||||
>TARGET
|
||||
<span
|
||||
style="
|
||||
font-weight: 400;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
"
|
||||
>· import custom ↑</span
|
||||
></label
|
||||
>
|
||||
<div class="autoeq-select-wrapper">
|
||||
<select id="autoeq-target-select">
|
||||
<option value="harman_oe_2018">Harman Over-Ear 2018</option>
|
||||
<option value="harman_ie_2019">Harman In-Ear 2019</option>
|
||||
<option value="seap">SEAP</option>
|
||||
<option value="seap_bass">SEAP Bass Boost</option>
|
||||
<option value="diffuse_field">Diffuse Field</option>
|
||||
<option value="knowles">Knowles</option>
|
||||
<option value="moondrop">Moondrop VDSF</option>
|
||||
<option value="hifi_endgame">HiFi Endgame 2026</option>
|
||||
<option value="peqdb_ultra">PEQdB Ultra</option>
|
||||
<option value="flat">Flat (Calibration)</option>
|
||||
</select>
|
||||
<button
|
||||
id="autoeq-import-target-btn"
|
||||
class="autoeq-settings-btn"
|
||||
title="Import target curve"
|
||||
aria-label="Import target curve"
|
||||
>
|
||||
<use svg="!lucide/file-up.svg" size="16" />
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
id="autoeq-import-target-file"
|
||||
accept=".txt,.csv"
|
||||
style="display: none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="autoeq-controls-row">
|
||||
<div class="autoeq-control-mini">
|
||||
<label class="autoeq-control-label">FILTER BANDS</label>
|
||||
<select id="autoeq-band-count">
|
||||
<option value="5">5</option>
|
||||
<option value="10" selected>10</option>
|
||||
<option value="15">15</option>
|
||||
<option value="20">20</option>
|
||||
<option value="32">32</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="autoeq-control-mini">
|
||||
<label class="autoeq-control-label">MAX HZ</label>
|
||||
<select id="autoeq-max-freq">
|
||||
<option value="6000">6k</option>
|
||||
<option value="8000">8k</option>
|
||||
<option value="10000">10k</option>
|
||||
<option value="16000" selected>16k</option>
|
||||
<option value="20000">20k</option>
|
||||
<option value="22000">22k</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="autoeq-control-mini">
|
||||
<label class="autoeq-control-label">SAMPLE RATE</label>
|
||||
<select id="autoeq-sample-rate">
|
||||
<option value="44100">44.1k</option>
|
||||
<option value="48000" selected>48k</option>
|
||||
<option value="96000">96k</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="autoeq-actions-row">
|
||||
<button
|
||||
id="autoeq-download-btn"
|
||||
class="autoeq-download-btn"
|
||||
title="Export EQ settings"
|
||||
aria-label="Export EQ settings"
|
||||
>
|
||||
<use svg="!lucide/download.svg" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<span id="autoeq-status" class="autoeq-status"></span>
|
||||
</div>
|
||||
|
||||
<!-- Saved Profiles -->
|
||||
<div class="autoeq-saved-section" id="autoeq-saved-section">
|
||||
<div class="autoeq-saved-header" id="autoeq-saved-toggle">
|
||||
<div class="autoeq-saved-header-left">
|
||||
<span class="autoeq-saved-title">SAVED PROFILES</span>
|
||||
<span class="autoeq-saved-count" id="autoeq-saved-count">0</span>
|
||||
</div>
|
||||
<div class="autoeq-saved-header-right">
|
||||
<input
|
||||
type="text"
|
||||
id="autoeq-profile-name"
|
||||
class="autoeq-profile-name-input"
|
||||
placeholder="Profile name..."
|
||||
maxlength="50"
|
||||
/>
|
||||
<button id="autoeq-save-btn" class="btn-primary autoeq-save-btn">
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class="autoeq-collapse-btn"
|
||||
id="autoeq-saved-collapse"
|
||||
aria-label="Collapse EQ section"
|
||||
aria-expanded="true"
|
||||
>
|
||||
<use svg="!lucide/chevron-up.svg" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="autoeq-saved-grid" id="autoeq-saved-grid">
|
||||
<!-- Dynamically populated profile cards -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speaker EQ Section -->
|
||||
<div class="speaker-eq-section" id="speaker-eq-section" style="display: none">
|
||||
<!-- Config + Channel Tabs -->
|
||||
<div class="speaker-eq-header">
|
||||
<div class="speaker-eq-config-row">
|
||||
<select id="speaker-config-select" class="speaker-config-select">
|
||||
<option value="2.0">2.0 Stereo</option>
|
||||
<option value="5.1">5.1 Surround</option>
|
||||
<option value="7.1">7.1 Surround</option>
|
||||
</select>
|
||||
<button
|
||||
id="speaker-export-btn"
|
||||
class="btn-secondary"
|
||||
title="Export all channels as JSON"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
id="speaker-import-btn"
|
||||
class="btn-secondary"
|
||||
title="Import EQ settings from JSON"
|
||||
>
|
||||
Import JSON
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
id="speaker-import-file"
|
||||
accept=".json"
|
||||
style="display: none"
|
||||
/>
|
||||
</div>
|
||||
<div class="speaker-channel-tabs" id="speaker-channel-tabs">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-Channel Controls -->
|
||||
<div class="speaker-eq-controls">
|
||||
<div class="speaker-eq-measurement-row">
|
||||
<div class="autoeq-control-group" style="flex: 1">
|
||||
<label class="autoeq-control-label">MEASUREMENT</label>
|
||||
<div class="autoeq-select-wrapper">
|
||||
<span
|
||||
id="speaker-measurement-status"
|
||||
class="speaker-measurement-status"
|
||||
>No measurement</span
|
||||
>
|
||||
<button
|
||||
id="speaker-measure-btn"
|
||||
class="autoeq-settings-btn speaker-measure-btn"
|
||||
title="Measure room with pink noise"
|
||||
aria-label="Measure room with pink noise"
|
||||
>
|
||||
<use svg="!lucide/mic.svg" size="16" />
|
||||
</button>
|
||||
<button
|
||||
id="speaker-import-measurement-btn"
|
||||
class="autoeq-settings-btn"
|
||||
title="Import measurement file"
|
||||
aria-label="Import measurement file"
|
||||
>
|
||||
<use svg="!lucide/file-up.svg" size="16" />
|
||||
</button>
|
||||
<button
|
||||
id="speaker-clear-measurement-btn"
|
||||
class="autoeq-settings-btn"
|
||||
title="Clear measurement"
|
||||
aria-label="Clear measurement"
|
||||
style="display: none"
|
||||
>
|
||||
<use svg="!lucide/x.svg" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="speaker-import-measurement-file"
|
||||
accept=".txt,.csv"
|
||||
style="display: none"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="autoeq-control-group"
|
||||
style="min-width: 150px; flex: 0 1 auto"
|
||||
>
|
||||
<label class="autoeq-control-label">TARGET</label>
|
||||
<div class="autoeq-select-wrapper">
|
||||
<select id="speaker-target-select">
|
||||
<option value="harman_room">Harman In-Room (2013)</option>
|
||||
<option value="seap_bass">SEAP Bass (Room)</option>
|
||||
<option value="flat">Flat</option>
|
||||
</select>
|
||||
<button
|
||||
id="speaker-import-target-btn"
|
||||
class="autoeq-settings-btn"
|
||||
title="Import target curve"
|
||||
aria-label="Import target curve"
|
||||
>
|
||||
<use svg="!lucide/file-up.svg" size="16" />
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
id="speaker-import-target-file"
|
||||
accept=".txt,.csv"
|
||||
style="display: none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="speaker-eq-params-row">
|
||||
<div class="autoeq-control-mini">
|
||||
<label class="autoeq-control-label">BANDS</label>
|
||||
<select id="speaker-band-count">
|
||||
<option value="5">5</option>
|
||||
<option value="10" selected>10</option>
|
||||
<option value="15">15</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="autoeq-control-mini speaker-eq-slider-control">
|
||||
<label class="autoeq-control-label bass">BASS LIMIT</label>
|
||||
<div class="speaker-eq-slider-row">
|
||||
<input
|
||||
type="range"
|
||||
id="speaker-bass-cutoff"
|
||||
min="20"
|
||||
max="100"
|
||||
step="5"
|
||||
value="40"
|
||||
class="autoeq-preamp-slider"
|
||||
/>
|
||||
<span
|
||||
id="speaker-bass-cutoff-value"
|
||||
class="speaker-eq-slider-value bass"
|
||||
>40 Hz</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="autoeq-control-mini speaker-eq-slider-control">
|
||||
<label class="autoeq-control-label room">ROOM LIMIT</label>
|
||||
<div class="speaker-eq-slider-row">
|
||||
<input
|
||||
type="range"
|
||||
id="speaker-room-limit"
|
||||
min="100"
|
||||
max="1000"
|
||||
step="10"
|
||||
value="500"
|
||||
class="autoeq-preamp-slider"
|
||||
/>
|
||||
<span
|
||||
id="speaker-room-limit-value"
|
||||
class="speaker-eq-slider-value room"
|
||||
>500 Hz</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="autoeq-control-mini speaker-eq-slider-control">
|
||||
<label class="autoeq-control-label">PREAMP</label>
|
||||
<div class="speaker-eq-slider-row">
|
||||
<input
|
||||
type="range"
|
||||
id="speaker-preamp-slider"
|
||||
min="-20"
|
||||
max="20"
|
||||
step="0.1"
|
||||
value="0"
|
||||
class="autoeq-preamp-slider"
|
||||
/>
|
||||
<span id="speaker-preamp-value" class="speaker-eq-slider-value"
|
||||
>0 dB</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="speaker-eq-actions-row">
|
||||
<button id="speaker-autoeq-btn" class="autoeq-run-btn" disabled>
|
||||
AutoEQ
|
||||
</button>
|
||||
<button
|
||||
id="speaker-autoeq-all-btn"
|
||||
class="autoeq-run-btn speaker-all-btn"
|
||||
title="Run AutoEQ on all channels that have measurements"
|
||||
>
|
||||
AutoEQ All
|
||||
</button>
|
||||
<button
|
||||
id="speaker-measure-all-btn"
|
||||
class="autoeq-run-btn speaker-all-btn"
|
||||
title="Measure once and apply averaged result to all channels"
|
||||
>
|
||||
Measure All
|
||||
</button>
|
||||
<span id="speaker-eq-status" class="autoeq-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speaker Saved Profiles -->
|
||||
<div class="speaker-saved-section" id="speaker-saved-section" style="display: none">
|
||||
<div class="autoeq-saved-header" id="speaker-saved-toggle">
|
||||
<div class="autoeq-saved-header-left">
|
||||
<span class="autoeq-saved-title">SPEAKER PROFILES</span>
|
||||
<span class="autoeq-saved-count" id="speaker-saved-count">0</span>
|
||||
</div>
|
||||
<div class="autoeq-saved-header-right">
|
||||
<input
|
||||
type="text"
|
||||
id="speaker-profile-name"
|
||||
class="autoeq-profile-name-input"
|
||||
placeholder="Profile name..."
|
||||
maxlength="50"
|
||||
/>
|
||||
<button id="speaker-save-btn" class="btn-primary autoeq-save-btn">
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class="autoeq-collapse-btn"
|
||||
id="speaker-saved-collapse"
|
||||
aria-label="Collapse EQ section"
|
||||
aria-expanded="true"
|
||||
>
|
||||
<use svg="!lucide/chevron-up.svg" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="autoeq-saved-grid" id="speaker-saved-grid">
|
||||
<!-- Dynamically populated speaker profile cards -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parametric EQ Filters (collapsible) -->
|
||||
<div class="autoeq-filters-section" id="autoeq-filters-section">
|
||||
<div class="autoeq-filters-header" id="autoeq-filters-toggle">
|
||||
<span>PARAMETRIC EQ FILTERS</span>
|
||||
<button
|
||||
class="autoeq-collapse-btn"
|
||||
id="autoeq-filters-collapse"
|
||||
aria-label="Collapse EQ filters"
|
||||
aria-expanded="true"
|
||||
>
|
||||
<use svg="!lucide/chevron-up.svg" size="18" />
|
||||
</button>
|
||||
</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-control-group">
|
||||
<label class="autoeq-control-label">PRESET</label>
|
||||
<select id="parametric-preset-select">
|
||||
<option value="">Custom</option>
|
||||
<option value="flat">Flat</option>
|
||||
<option value="bass_boost">Bass Boost</option>
|
||||
<option value="bass_reducer">Bass Reducer</option>
|
||||
|
|
@ -4025,199 +4595,101 @@
|
|||
<option value="jazz">Jazz</option>
|
||||
<option value="electronic">Electronic</option>
|
||||
<option value="hip_hop">Hip-Hop</option>
|
||||
<option value="r_and_b">R&B</option>
|
||||
<option value="r_and_b">R&B</option>
|
||||
<option value="acoustic">Acoustic</option>
|
||||
<option value="podcast">Podcast / Speech</option>
|
||||
</optgroup>
|
||||
<optgroup label="Custom Presets" id="custom-presets-optgroup">
|
||||
<!-- Custom presets will be populated by JavaScript -->
|
||||
</optgroup>
|
||||
<option value="podcast">Speech</option>
|
||||
</select>
|
||||
<label for="eq-band-count">Bands</label>
|
||||
<input
|
||||
type="number"
|
||||
id="eq-band-count"
|
||||
class="eq-band-count-input"
|
||||
min="3"
|
||||
max="32"
|
||||
value="16"
|
||||
title="Number of EQ bands (3-32)"
|
||||
/>
|
||||
<button
|
||||
id="equalizer-reset-btn"
|
||||
class="btn-secondary"
|
||||
title="Reset to Flat"
|
||||
>
|
||||
<use svg="!lucide/rotate-ccw.svg" size="16" />
|
||||
</button>
|
||||
<button
|
||||
id="eq-export-btn"
|
||||
class="btn-secondary"
|
||||
title="Export EQ settings to text"
|
||||
style="font-size: 0.75rem; padding: 0.25rem 0.5rem"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
id="eq-import-btn"
|
||||
class="btn-secondary"
|
||||
title="Import EQ settings from text or file"
|
||||
style="font-size: 0.75rem; padding: 0.25rem 0.5rem"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
id="eq-import-file"
|
||||
accept=".txt"
|
||||
style="display: none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-preset-controls">
|
||||
<div class="custom-preset-input-row">
|
||||
<!-- Saved Profiles for Parametric EQ -->
|
||||
<div class="autoeq-parametric-profiles" id="autoeq-parametric-profiles">
|
||||
<div class="autoeq-saved-header">
|
||||
<div class="autoeq-saved-header-left">
|
||||
<span class="autoeq-saved-title">SAVED PROFILES</span>
|
||||
<span class="autoeq-saved-count" id="parametric-saved-count"
|
||||
>0</span
|
||||
>
|
||||
</div>
|
||||
<div class="autoeq-saved-header-right">
|
||||
<input
|
||||
type="text"
|
||||
id="custom-preset-name"
|
||||
placeholder="Preset name (e.g., Home, Car, Work)"
|
||||
id="parametric-profile-name"
|
||||
class="autoeq-profile-name-input"
|
||||
placeholder="Profile name..."
|
||||
maxlength="50"
|
||||
/>
|
||||
<button
|
||||
id="save-custom-preset-btn"
|
||||
class="btn-primary"
|
||||
title="Save current EQ as custom preset"
|
||||
id="parametric-save-btn"
|
||||
class="btn-primary autoeq-save-btn"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="autoeq-saved-grid" id="parametric-saved-grid">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import/Export + Band Controls -->
|
||||
<div class="autoeq-filters-actions">
|
||||
<button
|
||||
id="delete-custom-preset-btn"
|
||||
class="btn-secondary delete-preset-btn"
|
||||
id="parametric-import-btn"
|
||||
class="btn-secondary"
|
||||
title="Import EQ from text file"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
id="parametric-export-btn"
|
||||
class="btn-secondary"
|
||||
title="Export EQ to text file"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<span style="flex: 1"></span>
|
||||
<button
|
||||
id="autoeq-add-band-btn"
|
||||
class="btn-secondary"
|
||||
title="Add a new EQ band"
|
||||
>
|
||||
+ Add Band
|
||||
</button>
|
||||
<button
|
||||
id="autoeq-remove-band-btn"
|
||||
class="btn-secondary"
|
||||
title="Remove last EQ band"
|
||||
>
|
||||
- Remove Band
|
||||
</button>
|
||||
<button
|
||||
id="autoeq-reset-bands-btn"
|
||||
class="btn-secondary"
|
||||
title="Reset all bands to flat"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="parametric-import-file"
|
||||
accept=".txt"
|
||||
style="display: none"
|
||||
title="Delete selected custom preset"
|
||||
>
|
||||
<use svg="!lucide/trash.svg" size="16" />
|
||||
Delete Preset
|
||||
</button>
|
||||
/>
|
||||
<div class="autoeq-bands-list" id="autoeq-bands-list">
|
||||
<!-- Dynamically generated per-band controls -->
|
||||
</div>
|
||||
|
||||
<div class="eq-range-controls">
|
||||
<label>DB Range:</label>
|
||||
<input
|
||||
type="number"
|
||||
id="eq-range-min"
|
||||
class="eq-range-input"
|
||||
min="-60"
|
||||
max="0"
|
||||
value="-30"
|
||||
title="Minimum gain in dB"
|
||||
/>
|
||||
<span>to</span>
|
||||
<input
|
||||
type="number"
|
||||
id="eq-range-max"
|
||||
class="eq-range-input"
|
||||
min="0"
|
||||
max="60"
|
||||
value="30"
|
||||
title="Maximum gain in dB"
|
||||
/>
|
||||
<span>dB</span>
|
||||
<button
|
||||
id="apply-eq-range-btn"
|
||||
class="btn-secondary"
|
||||
title="Apply new range to all bands"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
id="reset-eq-range-btn"
|
||||
class="btn-secondary"
|
||||
title="Reset to default (-30 to +30 dB)"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="eq-freq-controls">
|
||||
<label>Freq Range:</label>
|
||||
<input
|
||||
type="number"
|
||||
id="eq-freq-min"
|
||||
class="eq-freq-input"
|
||||
min="10"
|
||||
max="20000"
|
||||
value="20"
|
||||
title="Minimum frequency in Hz"
|
||||
/>
|
||||
<span>Hz to</span>
|
||||
<input
|
||||
type="number"
|
||||
id="eq-freq-max"
|
||||
class="eq-freq-input"
|
||||
min="20"
|
||||
max="96000"
|
||||
value="20000"
|
||||
title="Maximum frequency in Hz"
|
||||
/>
|
||||
<span>Hz</span>
|
||||
<button
|
||||
id="apply-eq-freq-btn"
|
||||
class="btn-secondary"
|
||||
title="Apply new frequency range"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
id="reset-eq-freq-btn"
|
||||
class="btn-secondary"
|
||||
title="Reset to default (20 Hz to 20 kHz)"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="eq-preamp-controls"
|
||||
style="display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem"
|
||||
>
|
||||
<label style="font-size: 0.75rem; opacity: 0.8">Preamp:</label>
|
||||
<input
|
||||
type="range"
|
||||
id="eq-preamp-slider"
|
||||
min="-20"
|
||||
max="20"
|
||||
step="0.1"
|
||||
value="0"
|
||||
style="flex: 1; max-width: 120px"
|
||||
title="Preamp gain in dB"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
id="eq-preamp-input"
|
||||
min="-20"
|
||||
max="20"
|
||||
step="0.1"
|
||||
value="0"
|
||||
style="width: 60px; padding: 0.25rem; font-size: 0.75rem"
|
||||
title="Preamp value in dB"
|
||||
/>
|
||||
<span style="font-size: 0.75rem">dB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="equalizer-bands-wrapper">
|
||||
<canvas id="eq-response-canvas" class="eq-response-canvas"></canvas>
|
||||
<div class="equalizer-bands" id="equalizer-bands">
|
||||
<!-- Bands will be dynamically generated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="equalizer-scale">
|
||||
<span>+30 dB</span>
|
||||
<span>0 dB</span>
|
||||
<span>-30 dB</span>
|
||||
</div>
|
||||
<!-- Hidden file inputs -->
|
||||
<input
|
||||
type="file"
|
||||
id="autoeq-import-measurement-file"
|
||||
accept=".txt,.csv"
|
||||
style="display: none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4650,64 +5122,6 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="blocked-content-list"
|
||||
style="
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
"
|
||||
>
|
||||
<div
|
||||
id="blocked-empty-message"
|
||||
style="text-align: center; color: var(--muted-foreground); padding: 1rem"
|
||||
>
|
||||
No content blocked yet.
|
||||
</div>
|
||||
|
||||
<div id="blocked-artists-section" style="margin-bottom: 1.5rem; display: none">
|
||||
<h4
|
||||
style="
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--foreground);
|
||||
"
|
||||
>
|
||||
Blocked Artists
|
||||
</h4>
|
||||
<ul id="blocked-artists-list" class="blocked-items-list"></ul>
|
||||
</div>
|
||||
|
||||
<div id="blocked-albums-section" style="margin-bottom: 1.5rem; display: none">
|
||||
<h4
|
||||
style="
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--foreground);
|
||||
"
|
||||
>
|
||||
Blocked Albums
|
||||
</h4>
|
||||
<ul id="blocked-albums-list" class="blocked-items-list"></ul>
|
||||
</div>
|
||||
|
||||
<div id="blocked-tracks-section" style="margin-bottom: 1.5rem; display: none">
|
||||
<h4
|
||||
style="
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--foreground);
|
||||
"
|
||||
>
|
||||
Blocked Tracks
|
||||
</h4>
|
||||
<ul id="blocked-tracks-list" class="blocked-items-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
id="settings-commit-info"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
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,15 +681,24 @@ 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) {
|
||||
|
|
|
|||
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;
|
||||
|
|
|
|||
4108
js/settings.js
4108
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) {
|
||||
|
|
|
|||
1689
styles.css
1689
styles.css
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue