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
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||||
ref: ${{ github.head_ref || github.ref }}
|
ref: ${{ github.head_ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
|
|
|
||||||
944
index.html
944
index.html
|
|
@ -3871,32 +3871,6 @@
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
|
|
@ -3995,9 +3969,9 @@
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Equalizer</span>
|
<span class="label">AutoEQ</span>
|
||||||
<span class="description"
|
<span class="description"
|
||||||
>16-band parametric equalizer for fine audio control</span
|
>Precision headphone correction & parametric equalizer</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
|
|
@ -4007,11 +3981,607 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="equalizer-container" id="equalizer-container" style="display: none">
|
<div class="equalizer-container" id="equalizer-container" style="display: none">
|
||||||
<div class="equalizer-header">
|
<!-- Mode Toggle + How To -->
|
||||||
<div class="equalizer-preset-row">
|
<div class="autoeq-mode-row">
|
||||||
<label for="equalizer-preset-select">Preset</label>
|
<div class="autoeq-mode-toggle">
|
||||||
<select id="equalizer-preset-select">
|
<button class="autoeq-mode-btn active" data-mode="autoeq">AutoEQ</button>
|
||||||
<optgroup label="Built-in Presets">
|
<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="flat">Flat</option>
|
||||||
<option value="bass_boost">Bass Boost</option>
|
<option value="bass_boost">Bass Boost</option>
|
||||||
<option value="bass_reducer">Bass Reducer</option>
|
<option value="bass_reducer">Bass Reducer</option>
|
||||||
|
|
@ -4025,199 +4595,101 @@
|
||||||
<option value="jazz">Jazz</option>
|
<option value="jazz">Jazz</option>
|
||||||
<option value="electronic">Electronic</option>
|
<option value="electronic">Electronic</option>
|
||||||
<option value="hip_hop">Hip-Hop</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="acoustic">Acoustic</option>
|
||||||
<option value="podcast">Podcast / Speech</option>
|
<option value="podcast">Speech</option>
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Custom Presets" id="custom-presets-optgroup">
|
|
||||||
<!-- Custom presets will be populated by JavaScript -->
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
</select>
|
||||||
<label for="eq-band-count">Bands</label>
|
</div>
|
||||||
<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">
|
<!-- Saved Profiles for Parametric EQ -->
|
||||||
<div class="custom-preset-input-row">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="custom-preset-name"
|
id="parametric-profile-name"
|
||||||
placeholder="Preset name (e.g., Home, Car, Work)"
|
class="autoeq-profile-name-input"
|
||||||
|
placeholder="Profile name..."
|
||||||
maxlength="50"
|
maxlength="50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
id="save-custom-preset-btn"
|
id="parametric-save-btn"
|
||||||
class="btn-primary"
|
class="btn-primary autoeq-save-btn"
|
||||||
title="Save current EQ as custom preset"
|
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<button
|
||||||
id="delete-custom-preset-btn"
|
id="parametric-import-btn"
|
||||||
class="btn-secondary delete-preset-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"
|
style="display: none"
|
||||||
title="Delete selected custom preset"
|
/>
|
||||||
>
|
<div class="autoeq-bands-list" id="autoeq-bands-list">
|
||||||
<use svg="!lucide/trash.svg" size="16" />
|
<!-- Dynamically generated per-band controls -->
|
||||||
Delete Preset
|
|
||||||
</button>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="equalizer-bands-wrapper">
|
<!-- Hidden file inputs -->
|
||||||
<canvas id="eq-response-canvas" class="eq-response-canvas"></canvas>
|
<input
|
||||||
<div class="equalizer-bands" id="equalizer-bands">
|
type="file"
|
||||||
<!-- Bands will be dynamically generated by JavaScript -->
|
id="autoeq-import-measurement-file"
|
||||||
</div>
|
accept=".txt,.csv"
|
||||||
</div>
|
style="display: none"
|
||||||
|
/>
|
||||||
<div class="equalizer-scale">
|
|
||||||
<span>+30 dB</span>
|
|
||||||
<span>0 dB</span>
|
|
||||||
<span>-30 dB</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4650,64 +5122,6 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<p
|
<p
|
||||||
id="settings-commit-info"
|
id="settings-commit-info"
|
||||||
|
|
|
||||||
|
|
@ -1883,7 +1883,13 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isVideo) {
|
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
|
// Add metadata if track information is provided
|
||||||
|
|
|
||||||
|
|
@ -239,9 +239,10 @@ class AudioContextManager {
|
||||||
// Create biquad filters for each frequency band
|
// Create biquad filters for each frequency band
|
||||||
this.filters = this.frequencies.map((freq, index) => {
|
this.filters = this.frequencies.map((freq, index) => {
|
||||||
const filter = this.audioContext.createBiquadFilter();
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
filter.type = 'peaking';
|
filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||||
filter.frequency.value = freq;
|
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;
|
filter.gain.value = this.currentGains[index] || 0;
|
||||||
return filter;
|
return filter;
|
||||||
});
|
});
|
||||||
|
|
@ -312,10 +313,10 @@ class AudioContextManager {
|
||||||
try {
|
try {
|
||||||
this.audioContext = new AudioContext(highResOptions);
|
this.audioContext = new AudioContext(highResOptions);
|
||||||
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
|
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
|
||||||
} catch (e) {
|
} catch {
|
||||||
try {
|
try {
|
||||||
this.audioContext = new AudioContext({ latencyHint: 'playback' });
|
this.audioContext = new AudioContext({ latencyHint: 'playback' });
|
||||||
} catch (e2) {
|
} catch {
|
||||||
this.audioContext = new AudioContext();
|
this.audioContext = new AudioContext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -358,7 +359,9 @@ class AudioContextManager {
|
||||||
if (this.source) {
|
if (this.source) {
|
||||||
try {
|
try {
|
||||||
this.source.disconnect();
|
this.source.disconnect();
|
||||||
} catch (e) {}
|
} catch {
|
||||||
|
// node may already be disconnected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.audio = audioElement;
|
this.audio = audioElement;
|
||||||
|
|
@ -386,7 +389,9 @@ class AudioContextManager {
|
||||||
// Disconnect everything first
|
// Disconnect everything first
|
||||||
try {
|
try {
|
||||||
this.source.disconnect();
|
this.source.disconnect();
|
||||||
} catch (e) {}
|
} catch {
|
||||||
|
// node may already be disconnected
|
||||||
|
}
|
||||||
this.outputNode.disconnect();
|
this.outputNode.disconnect();
|
||||||
if (this.volumeNode) {
|
if (this.volumeNode) {
|
||||||
this.volumeNode.disconnect();
|
this.volumeNode.disconnect();
|
||||||
|
|
@ -405,16 +410,23 @@ class AudioContextManager {
|
||||||
|
|
||||||
// Apply mono audio if enabled
|
// Apply mono audio if enabled
|
||||||
if (this.isMonoAudioEnabled && this.monoMergerNode) {
|
if (this.isMonoAudioEnabled && this.monoMergerNode) {
|
||||||
// Create a gain node to mix channels before the merger
|
// Reuse persistent gain node to avoid leaking AudioNodes
|
||||||
const monoGain = this.audioContext.createGain();
|
if (!this.monoGainNode) {
|
||||||
monoGain.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
|
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
|
// Connect source to mono gain
|
||||||
this.source.connect(monoGain);
|
this.source.connect(this.monoGainNode);
|
||||||
|
|
||||||
// Connect mono gain to both inputs of the merger
|
// Connect mono gain to both inputs of the merger
|
||||||
monoGain.connect(this.monoMergerNode, 0, 0);
|
this.monoGainNode.connect(this.monoMergerNode, 0, 0);
|
||||||
monoGain.connect(this.monoMergerNode, 0, 1);
|
this.monoGainNode.connect(this.monoMergerNode, 0, 1);
|
||||||
|
|
||||||
lastNode = this.monoMergerNode;
|
lastNode = this.monoMergerNode;
|
||||||
console.log('[AudioContext] Mono audio enabled');
|
console.log('[AudioContext] Mono audio enabled');
|
||||||
|
|
@ -573,6 +585,57 @@ class AudioContextManager {
|
||||||
return equalizerSettings.getRange();
|
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
|
* Clamp gain to valid range
|
||||||
*/
|
*/
|
||||||
|
|
@ -667,6 +730,8 @@ class AudioContextManager {
|
||||||
this.freqRange = equalizerSettings.getFreqRange();
|
this.freqRange = equalizerSettings.getFreqRange();
|
||||||
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
||||||
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
||||||
|
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
|
||||||
|
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
|
||||||
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
||||||
this.preamp = equalizerSettings.getPreamp();
|
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
|
* Export equalizer settings to text format
|
||||||
* @returns {string} Exported settings in text format
|
* @returns {string} Exported settings in text format
|
||||||
|
|
@ -709,8 +855,13 @@ class AudioContextManager {
|
||||||
|
|
||||||
this.frequencies.forEach((freq, index) => {
|
this.frequencies.forEach((freq, index) => {
|
||||||
const gain = this.currentGains[index] || 0;
|
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;
|
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');
|
return lines.join('\n');
|
||||||
|
|
@ -760,24 +911,42 @@ class AudioContextManager {
|
||||||
this.setPreamp(preamp);
|
this.setPreamp(preamp);
|
||||||
|
|
||||||
// If different number of bands, adjust
|
// If different number of bands, adjust
|
||||||
if (filters.length !== this.bandCount) {
|
|
||||||
const newCount = Math.max(
|
const newCount = Math.max(
|
||||||
equalizerSettings.MIN_BANDS,
|
equalizerSettings.MIN_BANDS,
|
||||||
Math.min(equalizerSettings.MAX_BANDS, filters.length)
|
Math.min(equalizerSettings.MAX_BANDS, filters.length)
|
||||||
);
|
);
|
||||||
|
if (newCount !== this.bandCount) {
|
||||||
this.setBandCount(newCount);
|
this.setBandCount(newCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract gains from filters
|
// Apply per-band frequencies, types, Qs, and gains from import
|
||||||
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
|
const sliced = filters.slice(0, this.bandCount);
|
||||||
this.setAllGains(gains);
|
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
|
// Rebuild EQ chain to apply new frequencies, types, and Qs
|
||||||
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
|
if (this.isInitialized && this.audioContext) {
|
||||||
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
|
this._destroyEQ();
|
||||||
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
|
this._createEQ();
|
||||||
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist all band settings
|
||||||
|
equalizerSettings.setGains(this.currentGains);
|
||||||
|
equalizerSettings.setBandTypes(this.currentTypes);
|
||||||
|
equalizerSettings.setBandQs(this.currentQs);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[AudioContext] Failed to import EQ settings:', 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) => {
|
this.frequencies.forEach((freq, index) => {
|
||||||
const gain = this.currentGains[index] || 0;
|
const gain = this.currentGains[index] || 0;
|
||||||
|
const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index);
|
||||||
const filterNum = index + 1;
|
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');
|
return lines.join('\n');
|
||||||
|
|
@ -680,15 +681,24 @@ export class Equalizer {
|
||||||
this.setBandCount(newCount);
|
this.setBandCount(newCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract gains from filters
|
// Apply imported filter frequencies directly instead of regenerating
|
||||||
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
|
const sliced = filters.slice(0, this.bandCount);
|
||||||
this.setAllGains(gains);
|
const newFreqs = sliced.map((f) => f.freq);
|
||||||
|
this.frequencies = newFreqs;
|
||||||
|
this.frequencyLabels = generateFrequencyLabels(newFreqs);
|
||||||
|
|
||||||
// Store filter frequencies if different
|
// Update filter frequencies on the actual biquad nodes
|
||||||
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
|
if (this.filters.length === newFreqs.length) {
|
||||||
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
|
newFreqs.forEach((freq, i) => {
|
||||||
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
|
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;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
30
js/player.js
30
js/player.js
|
|
@ -16,7 +16,6 @@ import {
|
||||||
exponentialVolumeSettings,
|
exponentialVolumeSettings,
|
||||||
audioEffectsSettings,
|
audioEffectsSettings,
|
||||||
radioSettings,
|
radioSettings,
|
||||||
playbackSettings,
|
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { audioContextManager } from './audio-context.js';
|
import { audioContextManager } from './audio-context.js';
|
||||||
import { isIos, isSafari } from './platform-detection.js';
|
import { isIos, isSafari } from './platform-detection.js';
|
||||||
|
|
@ -49,7 +48,6 @@ export class Player {
|
||||||
this.repeatMode = REPEAT_MODE.OFF;
|
this.repeatMode = REPEAT_MODE.OFF;
|
||||||
this.preloadCache = new Map();
|
this.preloadCache = new Map();
|
||||||
this.preloadAbortController = null;
|
this.preloadAbortController = null;
|
||||||
this._lastPreloadTime = null;
|
|
||||||
this.currentTrack = null;
|
this.currentTrack = null;
|
||||||
this.currentRgValues = null;
|
this.currentRgValues = null;
|
||||||
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
|
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
|
||||||
|
|
@ -108,6 +106,7 @@ export class Player {
|
||||||
bufferingGoal: 30,
|
bufferingGoal: 30,
|
||||||
rebufferingGoal: 2,
|
rebufferingGoal: 2,
|
||||||
bufferBehind: 30,
|
bufferBehind: 30,
|
||||||
|
jumpLargeGaps: true,
|
||||||
},
|
},
|
||||||
abr: {
|
abr: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -151,6 +150,7 @@ export class Player {
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
const el = this.activeElement;
|
const el = this.activeElement;
|
||||||
if (document.visibilityState === 'visible' && !el.paused) {
|
if (document.visibilityState === 'visible' && !el.paused) {
|
||||||
|
// Ensure audio context is resumed when user returns to the app
|
||||||
if (!audioContextManager.isReady()) {
|
if (!audioContextManager.isReady()) {
|
||||||
audioContextManager.init(el);
|
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();
|
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) {
|
async setupHlsVideo(video, result, fallbackImg) {
|
||||||
const url = result.videoUrl || result.hlsUrl || result;
|
const url = result.videoUrl || result.hlsUrl || result;
|
||||||
const Hls = (await import('hls.js')).default;
|
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 = {
|
export const equalizerSettings = {
|
||||||
ENABLED_KEY: 'equalizer-enabled',
|
ENABLED_KEY: 'equalizer-enabled',
|
||||||
GAINS_KEY: 'equalizer-gains',
|
GAINS_KEY: 'equalizer-gains',
|
||||||
|
BAND_TYPES_KEY: 'equalizer-band-types',
|
||||||
|
BAND_QS_KEY: 'equalizer-band-qs',
|
||||||
PRESET_KEY: 'equalizer-preset',
|
PRESET_KEY: 'equalizer-preset',
|
||||||
CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
|
CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
|
||||||
BAND_COUNT_KEY: 'equalizer-band-count',
|
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
|
* Interpolate gains array to match target band count
|
||||||
*/
|
*/
|
||||||
|
|
@ -1440,6 +1468,130 @@ export const equalizerSettings = {
|
||||||
return false;
|
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 = {
|
export const monoAudioSettings = {
|
||||||
|
|
@ -2573,7 +2725,7 @@ export const contentBlockingSettings = {
|
||||||
|
|
||||||
isArtistBlocked(artistId) {
|
isArtistBlocked(artistId) {
|
||||||
if (!artistId) return false;
|
if (!artistId) return false;
|
||||||
return this.getBlockedArtists().some((a) => a.id == artistId);
|
return this.getBlockedArtists().some((a) => String(a.id) === String(artistId));
|
||||||
},
|
},
|
||||||
|
|
||||||
blockArtist(artist) {
|
blockArtist(artist) {
|
||||||
|
|
@ -2590,7 +2742,7 @@ export const contentBlockingSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
unblockArtist(artistId) {
|
unblockArtist(artistId) {
|
||||||
const blocked = this.getBlockedArtists().filter((a) => a.id != artistId);
|
const blocked = this.getBlockedArtists().filter((a) => a.id !== artistId);
|
||||||
this.setBlockedArtists(blocked);
|
this.setBlockedArtists(blocked);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -2610,13 +2762,13 @@ export const contentBlockingSettings = {
|
||||||
|
|
||||||
isTrackBlocked(trackId) {
|
isTrackBlocked(trackId) {
|
||||||
if (!trackId) return false;
|
if (!trackId) return false;
|
||||||
return this.getBlockedTracks().some((t) => t.id == trackId);
|
return this.getBlockedTracks().some((t) => t.id === trackId);
|
||||||
},
|
},
|
||||||
|
|
||||||
blockTrack(track) {
|
blockTrack(track) {
|
||||||
if (!track || !track.id) return;
|
if (!track || !track.id) return;
|
||||||
const blocked = this.getBlockedTracks();
|
const blocked = this.getBlockedTracks();
|
||||||
if (!blocked.some((t) => t.id == track.id)) {
|
if (!blocked.some((t) => t.id === track.id)) {
|
||||||
blocked.push({
|
blocked.push({
|
||||||
id: track.id,
|
id: track.id,
|
||||||
title: track.title || 'Unknown Track',
|
title: track.title || 'Unknown Track',
|
||||||
|
|
@ -2628,7 +2780,7 @@ export const contentBlockingSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
unblockTrack(trackId) {
|
unblockTrack(trackId) {
|
||||||
const blocked = this.getBlockedTracks().filter((t) => t.id != trackId);
|
const blocked = this.getBlockedTracks().filter((t) => t.id !== trackId);
|
||||||
this.setBlockedTracks(blocked);
|
this.setBlockedTracks(blocked);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -2648,13 +2800,13 @@ export const contentBlockingSettings = {
|
||||||
|
|
||||||
isAlbumBlocked(albumId) {
|
isAlbumBlocked(albumId) {
|
||||||
if (!albumId) return false;
|
if (!albumId) return false;
|
||||||
return this.getBlockedAlbums().some((a) => a.id == albumId);
|
return this.getBlockedAlbums().some((a) => a.id === albumId);
|
||||||
},
|
},
|
||||||
|
|
||||||
blockAlbum(album) {
|
blockAlbum(album) {
|
||||||
if (!album || !album.id) return;
|
if (!album || !album.id) return;
|
||||||
const blocked = this.getBlockedAlbums();
|
const blocked = this.getBlockedAlbums();
|
||||||
if (!blocked.some((a) => a.id == album.id)) {
|
if (!blocked.some((a) => a.id === album.id)) {
|
||||||
blocked.push({
|
blocked.push({
|
||||||
id: album.id,
|
id: album.id,
|
||||||
title: album.title || 'Unknown Album',
|
title: album.title || 'Unknown Album',
|
||||||
|
|
@ -2666,7 +2818,7 @@ export const contentBlockingSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
unblockAlbum(albumId) {
|
unblockAlbum(albumId) {
|
||||||
const blocked = this.getBlockedAlbums().filter((a) => a.id != albumId);
|
const blocked = this.getBlockedAlbums().filter((a) => a.id !== albumId);
|
||||||
this.setBlockedAlbums(blocked);
|
this.setBlockedAlbums(blocked);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
52
js/ui.js
52
js/ui.js
|
|
@ -26,7 +26,6 @@ import {
|
||||||
fontSettings,
|
fontSettings,
|
||||||
contentBlockingSettings,
|
contentBlockingSettings,
|
||||||
settingsUiState,
|
settingsUiState,
|
||||||
playbackSettings,
|
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { getVibrantColorFromImage } from './vibrant-color.js';
|
import { getVibrantColorFromImage } from './vibrant-color.js';
|
||||||
|
|
@ -151,9 +150,6 @@ export class UIRenderer {
|
||||||
this.lastRecommendedTracks = [];
|
this.lastRecommendedTracks = [];
|
||||||
this.currentArtistId = null;
|
this.currentArtistId = null;
|
||||||
|
|
||||||
this._handleTiltMove = this._handleTiltMove.bind(this);
|
|
||||||
this._handleTiltLeave = this._handleTiltLeave.bind(this);
|
|
||||||
|
|
||||||
// Listen for dynamic color reset events
|
// Listen for dynamic color reset events
|
||||||
window.addEventListener('reset-dynamic-color', () => {
|
window.addEventListener('reset-dynamic-color', () => {
|
||||||
this.resetVibrantColor();
|
this.resetVibrantColor();
|
||||||
|
|
@ -1231,14 +1227,6 @@ export class UIRenderer {
|
||||||
|
|
||||||
overlay.style.display = 'flex';
|
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 () => {
|
const startVisualizer = async () => {
|
||||||
if (!visualizerSettings.isEnabled()) {
|
if (!visualizerSettings.isEnabled()) {
|
||||||
if (this.visualizer) this.visualizer.stop();
|
if (this.visualizer) this.visualizer.stop();
|
||||||
|
|
@ -1332,46 +1320,6 @@ export class UIRenderer {
|
||||||
clearTimeout(this.uiToggleMouseTimer);
|
clearTimeout(this.uiToggleMouseTimer);
|
||||||
this.uiToggleMouseTimer = null;
|
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) {
|
setupUIToggleButton(overlay) {
|
||||||
|
|
|
||||||
1693
styles.css
1693
styles.css
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue