Merge branch 'main' into feature/android-background-audio
This commit is contained in:
commit
0bd2f29bce
22 changed files with 3493 additions and 269 deletions
|
|
@ -49,7 +49,7 @@ public class AudioPlaybackService extends Service {
|
||||||
|
|
||||||
acquireWakeLock();
|
acquireWakeLock();
|
||||||
|
|
||||||
// If the system kills this service, don't restart it automatically —
|
// If the system kills this service, don't restart it automatically -
|
||||||
// MainActivity will re-start it when audio resumes.
|
// MainActivity will re-start it when audio resumes.
|
||||||
return START_NOT_STICKY;
|
return START_NOT_STICKY;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
bun.lock
4
bun.lock
|
|
@ -19,7 +19,7 @@
|
||||||
"@svta/common-media-library": "^0.18.1",
|
"@svta/common-media-library": "^0.18.1",
|
||||||
"@types/wicg-file-system-access": "^2023.10.7",
|
"@types/wicg-file-system-access": "^2023.10.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
||||||
"@uimaxbai/am-lyrics": "^1.1.8",
|
"@uimaxbai/am-lyrics": "^1.2.1",
|
||||||
"@vitest/web-worker": "^4.1.2",
|
"@vitest/web-worker": "^4.1.2",
|
||||||
"appwrite": "^23.0.0",
|
"appwrite": "^23.0.0",
|
||||||
"butterchurn": "^2.6.7",
|
"butterchurn": "^2.6.7",
|
||||||
|
|
@ -678,7 +678,7 @@
|
||||||
|
|
||||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="],
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="],
|
||||||
|
|
||||||
"@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.1.8", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-VcbrlB2cOmkOjElmivf2SZujDmj8UAUaBkXyIfJ8dYq/Iv4H3PxmQY/s9VaRfF6UTnCgfix8ZPll1T1MA8eS4A=="],
|
"@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.2.1", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-DbzIeQS3bNAiQ+T35EYnFgCSEn6caTefyhtycb4DJr2+iLf8bi8DRP8Dd2cwY5bxAl3ZG6MKdM+Vma3fnN2ruw=="],
|
||||||
|
|
||||||
"@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="],
|
"@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="],
|
||||||
|
|
||||||
|
|
|
||||||
312
index.html
312
index.html
|
|
@ -173,6 +173,24 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="eq-node-context-menu">
|
||||||
|
<ul>
|
||||||
|
<li data-action="eq-channel-stereo" class="eq-ctx-channel">Stereo</li>
|
||||||
|
<li data-action="eq-channel-mid" class="eq-ctx-channel">Mid</li>
|
||||||
|
<li data-action="eq-channel-side" class="eq-ctx-channel">Side</li>
|
||||||
|
<li class="separator"></li>
|
||||||
|
<li data-action="eq-type-lowshelf" class="eq-ctx-type">Low Shelf</li>
|
||||||
|
<li data-action="eq-type-peaking" class="eq-ctx-type">Peaking</li>
|
||||||
|
<li data-action="eq-type-highshelf" class="eq-ctx-type">High Shelf</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="eq-empty-context-menu">
|
||||||
|
<ul>
|
||||||
|
<li data-action="eq-add-node">Add Node</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="side-panel" class="side-panel">
|
<div id="side-panel" class="side-panel">
|
||||||
<div id="side-panel-resizer" class="side-panel-resizer"></div>
|
<div id="side-panel-resizer" class="side-panel-resizer"></div>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
|
|
@ -2585,6 +2603,7 @@
|
||||||
|
|
||||||
<div id="page-artist" class="page">
|
<div id="page-artist" class="page">
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
|
<div id="artist-detail-banner-container" class="detail-header-banner"></div>
|
||||||
<img
|
<img
|
||||||
id="artist-detail-image"
|
id="artist-detail-image"
|
||||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||||
|
|
@ -3374,6 +3393,16 @@
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Artist Banners</span>
|
||||||
|
<span class="description">Display video banners on artist pages</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="artist-banners-toggle" checked />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Compact Albums</span>
|
<span class="label">Compact Albums</span>
|
||||||
|
|
@ -4143,9 +4172,108 @@
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">AutoEQ</span>
|
<span class="label">Binaural / Spatial DSP</span>
|
||||||
<span class="description"
|
<span class="description"
|
||||||
>Precision headphone correction & parametric equalizer</span
|
>Multichannel HRTF rendering for Atmos & 3D Audio, crossfeed for
|
||||||
|
stereo</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="binaural-dsp-toggle" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-dsp-container" id="binaural-dsp-container" style="display: none">
|
||||||
|
<div class="binaural-status" id="binaural-status">
|
||||||
|
<span class="binaural-mode-label">Mode: Stereo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Auto-enable for Spatial Audio</span>
|
||||||
|
<span class="description"
|
||||||
|
>Automatically activate when Atmos or 3D content is detected</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="binaural-auto-spatial-toggle" checked />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Crossfeed</span>
|
||||||
|
<span class="description">Simulate speaker presentation on headphones</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="binaural-crossfeed-toggle" checked />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting" id="crossfeed-level-row">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Crossfeed Level</span>
|
||||||
|
</div>
|
||||||
|
<select id="binaural-crossfeed-level">
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium" selected>Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">HRTF Preset</span>
|
||||||
|
<span class="description"
|
||||||
|
>Virtual speaker angle for multichannel rendering</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<select id="binaural-hrtf-preset">
|
||||||
|
<option value="intimate">Intimate (±22°)</option>
|
||||||
|
<option value="studio" selected>Studio (±30°)</option>
|
||||||
|
<option value="wide">Wide (±45°)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Stereo Width</span>
|
||||||
|
<span class="description"
|
||||||
|
>Adjust spatial width (0 = mono, 1 = neutral, 2 = wide)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="binaural-widening-toggle" checked />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binaural-sub-setting" id="widening-slider-row">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Width Amount</span>
|
||||||
|
<span class="binaural-width-value" id="binaural-width-value">1.0</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="binaural-widening-slider"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.05"
|
||||||
|
value="1.0"
|
||||||
|
class="binaural-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">EQ Studio</span>
|
||||||
|
<span class="description"
|
||||||
|
>Multi-mode equalizer with AutoEQ, M/S processing & room
|
||||||
|
correction</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
|
|
@ -4159,10 +4287,10 @@
|
||||||
<div class="autoeq-mode-row">
|
<div class="autoeq-mode-row">
|
||||||
<div class="autoeq-mode-toggle">
|
<div class="autoeq-mode-toggle">
|
||||||
<button class="autoeq-mode-btn" data-mode="legacy">Legacy EQ</button>
|
<button class="autoeq-mode-btn" data-mode="legacy">Legacy EQ</button>
|
||||||
<button class="autoeq-mode-btn active" data-mode="autoeq">AutoEQ</button>
|
|
||||||
<button class="autoeq-mode-btn" data-mode="parametric">
|
<button class="autoeq-mode-btn" data-mode="parametric">
|
||||||
Parametric EQ
|
Parametric EQ
|
||||||
</button>
|
</button>
|
||||||
|
<button class="autoeq-mode-btn active" data-mode="autoeq">AutoEQ</button>
|
||||||
<button class="autoeq-mode-btn" data-mode="speaker">Speaker EQ</button>
|
<button class="autoeq-mode-btn" data-mode="speaker">Speaker EQ</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="eq-howto-btn" id="eq-howto-btn" title="How to use">?</button>
|
<button class="eq-howto-btn" id="eq-howto-btn" title="How to use">?</button>
|
||||||
|
|
@ -4174,6 +4302,48 @@
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="eq-howto-tab legacy" id="eq-howto-legacy" style="display: none">
|
||||||
|
<h4>Legacy EQ - Graphic Equalizer</h4>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
Set the <b>number of bands</b> (3-32) and
|
||||||
|
<b>frequency range</b> (Min/Max Hz) at the top to customize the
|
||||||
|
equalizer layout.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Drag the sliders</b> to boost or cut each frequency band. Bands
|
||||||
|
are spaced logarithmically across your range.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Pick a <b>preset</b> (Bass Boost, Rock, Vocal, etc.) as a starting
|
||||||
|
point - presets auto-scale to your band count.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Adjust the <b>preamp</b> slider to raise or lower the overall level
|
||||||
|
- reduce it if you hear distortion from large boosts.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Save</b> your own custom presets with a name so you can recall
|
||||||
|
them later.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Export</b> saves the EQ in EqualizerAPO text format.
|
||||||
|
<b>Export APO</b> saves a GraphicEQ config line you can paste
|
||||||
|
directly into Equalizer APO's config.txt.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Import</b> loads EQ settings from EqualizerAPO text files or
|
||||||
|
simple frequency/gain CSV files - points are mapped to your current
|
||||||
|
bands automatically.
|
||||||
|
</li>
|
||||||
|
<li>Click <b>Reset</b> to flatten all bands back to 0 dB.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="eq-howto-tip">
|
||||||
|
Tip: Cut problem frequencies rather than boosting others - it sounds
|
||||||
|
cleaner and avoids clipping.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="eq-howto-tab autoeq" id="eq-howto-autoeq">
|
<div class="eq-howto-tab autoeq" id="eq-howto-autoeq">
|
||||||
<h4>AutoEQ - Headphone Correction</h4>
|
<h4>AutoEQ - Headphone Correction</h4>
|
||||||
<ol>
|
<ol>
|
||||||
|
|
@ -4198,9 +4368,16 @@
|
||||||
result.
|
result.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>Double-click an empty area</b> on the graph to add a new filter
|
<b>Drag nodes</b> to adjust frequency and gain.
|
||||||
band at that frequency. <b>Double-click an existing node</b> to
|
<b>Scroll on a node</b> to adjust Q (bandwidth).
|
||||||
remove it.
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Right-click a node</b> to change its filter type (Peaking, Low
|
||||||
|
Shelf, High Shelf) or channel mode (Stereo, Mid, Side).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Right-click empty space</b> or <b>double-click</b> to add a node.
|
||||||
|
<b>Double-click a node</b> to remove it.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>Save</b> the profile so you can switch between headphones
|
<b>Save</b> the profile so you can switch between headphones
|
||||||
|
|
@ -4221,26 +4398,25 @@
|
||||||
<h4>Parametric EQ - Manual Control</h4>
|
<h4>Parametric EQ - Manual Control</h4>
|
||||||
<ol>
|
<ol>
|
||||||
<li>
|
<li>
|
||||||
Each band supports <b>peaking, low-shelf, and high-shelf</b> filter
|
Each band supports <b>Peaking, Low Shelf, and High Shelf</b> filter
|
||||||
types with frequency, gain, and Q (width).
|
types with frequency, gain, and Q (bandwidth).
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>Drag nodes</b> on the graph to adjust frequency and gain
|
<b>Drag nodes</b> on the graph to adjust frequency and gain.
|
||||||
visually.
|
<b>Scroll on a node</b> to adjust Q.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>Double-click an empty area</b> on the graph to add a new band at
|
<b>Right-click a node</b> to change its filter type or set its
|
||||||
that exact frequency and gain.
|
channel mode to <b>Stereo</b>, <b>Mid</b>, or <b>Side</b>.
|
||||||
<b>Double-click an existing node</b> to delete it.
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Right-click empty space</b> or <b>double-click</b> to add a node
|
||||||
|
at that position. <b>Double-click a node</b> to delete it.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Use <b>+ Add Band / - Remove Band</b> to change the number of
|
Use <b>+ Add Band / - Remove Band</b> to change the number of
|
||||||
filters.
|
filters.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
Pick a <b>preset</b> (Bass Boost, Vocal, etc.) as a starting point,
|
|
||||||
then fine-tune.
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<b>Import/Export</b> settings in EqualizerAPO format for use in
|
<b>Import/Export</b> settings in EqualizerAPO format for use in
|
||||||
other apps.
|
other apps.
|
||||||
|
|
@ -4251,7 +4427,14 @@
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p class="eq-howto-tip">
|
<p class="eq-howto-tip">
|
||||||
Tip: Lower Q = wider curve. Higher Q = narrower surgical cut.
|
Tip: Lower Q = wider curve, higher Q = narrower surgical cut.
|
||||||
|
</p>
|
||||||
|
<p class="eq-howto-tip">
|
||||||
|
Mid/Side tips: Set a band to <b>Mid</b> to EQ only the center image
|
||||||
|
(vocals, bass, kick). Set it to <b>Side</b> to EQ only the stereo width
|
||||||
|
(reverb, ambience, panned instruments). Try cutting low-end on Side
|
||||||
|
below 200 Hz for tighter, mono-compatible bass - or boost presence on
|
||||||
|
Mid around 2-5 kHz to bring vocals forward without touching the sides.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -4280,9 +4463,12 @@
|
||||||
Click <b>AutoEQ</b> to generate correction filters for that channel.
|
Click <b>AutoEQ</b> to generate correction filters for that channel.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>Double-click an empty area</b> on the graph to add a filter band
|
<b>Drag nodes</b> to fine-tune. <b>Scroll on a node</b> to adjust Q.
|
||||||
at that frequency. <b>Double-click an existing node</b> to remove
|
<b>Right-click a node</b> to change type or channel mode.
|
||||||
it.
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Right-click empty space</b> or <b>double-click</b> to add a node.
|
||||||
|
<b>Double-click a node</b> to remove it.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Repeat for each channel, then <b>Export JSON</b> with all channels.
|
Repeat for each channel, then <b>Export JSON</b> with all channels.
|
||||||
|
|
@ -4297,6 +4483,35 @@
|
||||||
|
|
||||||
<!-- Legacy 16-Band Graphic EQ (visible in legacy mode) -->
|
<!-- Legacy 16-Band Graphic EQ (visible in legacy mode) -->
|
||||||
<div class="graphic-eq-section" id="graphic-eq-section" style="display: none">
|
<div class="graphic-eq-section" id="graphic-eq-section" style="display: none">
|
||||||
|
<div class="graphic-eq-config-row">
|
||||||
|
<label>Bands</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="legacy-geq-band-count"
|
||||||
|
min="3"
|
||||||
|
max="32"
|
||||||
|
value="16"
|
||||||
|
class="geq-config-input"
|
||||||
|
/>
|
||||||
|
<label>Min Hz</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="legacy-geq-freq-min"
|
||||||
|
min="10"
|
||||||
|
max="96000"
|
||||||
|
value="25"
|
||||||
|
class="geq-config-input"
|
||||||
|
/>
|
||||||
|
<label>Max Hz</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="legacy-geq-freq-max"
|
||||||
|
min="10"
|
||||||
|
max="96000"
|
||||||
|
value="20000"
|
||||||
|
class="geq-config-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="graphic-eq-preset-row">
|
<div class="graphic-eq-preset-row">
|
||||||
<label for="legacy-graphic-eq-preset-select" class="graphic-eq-preset-label"
|
<label for="legacy-graphic-eq-preset-select" class="graphic-eq-preset-label"
|
||||||
>Preset</label
|
>Preset</label
|
||||||
|
|
@ -4323,6 +4538,21 @@
|
||||||
<option value="acoustic">Acoustic</option>
|
<option value="acoustic">Acoustic</option>
|
||||||
<option value="podcast">Speech</option>
|
<option value="podcast">Speech</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-save-preset-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Save current EQ as a preset"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-delete-preset-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Delete selected custom preset"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="graphic-eq-bands" id="legacy-graphic-eq-bands">
|
<div class="graphic-eq-bands" id="legacy-graphic-eq-bands">
|
||||||
<!-- 16 vertical sliders generated by JS -->
|
<!-- 16 vertical sliders generated by JS -->
|
||||||
|
|
@ -4352,6 +4582,33 @@
|
||||||
<button id="legacy-graphic-eq-reset-btn" class="btn-secondary">
|
<button id="legacy-graphic-eq-reset-btn" class="btn-secondary">
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-import-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Import EQ from text file (frequency/gain pairs, Q values ignored)"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-export-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Export EQ to text file"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="legacy-geq-export-csv-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Export EQ as Equalizer APO GraphicEQ config line"
|
||||||
|
>
|
||||||
|
Export APO
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="legacy-geq-import-file"
|
||||||
|
accept=".txt,.csv"
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -4866,6 +5123,19 @@
|
||||||
<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">Speech</option>
|
<option value="podcast">Speech</option>
|
||||||
|
<option disabled>── Shelf ──</option>
|
||||||
|
<option value="shelf_warm">Warm</option>
|
||||||
|
<option value="shelf_bright">Bright & Airy</option>
|
||||||
|
<option value="shelf_hifi">Hi-Fi</option>
|
||||||
|
<option value="shelf_dark">Dark & Smooth</option>
|
||||||
|
<option value="shelf_radio">Radio Ready</option>
|
||||||
|
<option disabled>── Mid/Side ──</option>
|
||||||
|
<option value="ms_vocal_clarity">M/S Vocal Clarity</option>
|
||||||
|
<option value="ms_wide_stereo">M/S Wide Stereo</option>
|
||||||
|
<option value="ms_mono_bass">M/S Mono Bass</option>
|
||||||
|
<option value="ms_master_polish">M/S Master Polish</option>
|
||||||
|
<option value="ms_rock_master">M/S Rock Master</option>
|
||||||
|
<option value="ms_hiphop">M/S Hip-Hop</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
switch type {
|
switch type {
|
||||||
case .began:
|
case .began:
|
||||||
// Interruption began — system pauses audio automatically
|
// Interruption began - system pauses audio automatically
|
||||||
break
|
break
|
||||||
case .ended:
|
case .ended:
|
||||||
// Interruption ended — reactivate session so playback can resume
|
// Interruption ended - reactivate session so playback can resume
|
||||||
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||||
if options.contains(.shouldResume) {
|
if options.contains(.shouldResume) {
|
||||||
|
|
@ -75,7 +75,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
if reason == .oldDeviceUnavailable {
|
if reason == .oldDeviceUnavailable {
|
||||||
// Headphones/Bluetooth disconnected — reactivate session to keep background alive
|
// Headphones/Bluetooth disconnected - reactivate session to keep background alive
|
||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
20
js/HiFi.ts
20
js/HiFi.ts
|
|
@ -108,7 +108,7 @@ export interface TidalArtistProfile {
|
||||||
picture: string | null;
|
picture: string | null;
|
||||||
/** Fallback album cover UUID used when no artist picture exists, or `null`. */
|
/** Fallback album cover UUID used when no artist picture exists, or `null`. */
|
||||||
selectedAlbumCoverFallback: string | null;
|
selectedAlbumCoverFallback: string | null;
|
||||||
/** Popularity score (0–100). */
|
/** Popularity score (0-100). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** List of credited roles for this artist. */
|
/** List of credited roles for this artist. */
|
||||||
artistRoles: TidalArtistRole[];
|
artistRoles: TidalArtistRole[];
|
||||||
|
|
@ -150,7 +150,7 @@ export interface TidalTrackAlbumRef {
|
||||||
* Full track object as returned by the `/info` route and embedded in albums, playlists, and mixes.
|
* Full track object as returned by the `/info` route and embedded in albums, playlists, and mixes.
|
||||||
*
|
*
|
||||||
* @remarks
|
* @remarks
|
||||||
* Fields `bpm`, `key`, and `keyScale` are nullable — they are absent for some tracks.
|
* Fields `bpm`, `key`, and `keyScale` are nullable - they are absent for some tracks.
|
||||||
* `version` is present in the payload but may be `null`.
|
* `version` is present in the payload but may be `null`.
|
||||||
*/
|
*/
|
||||||
export interface TidalTrack {
|
export interface TidalTrack {
|
||||||
|
|
@ -162,7 +162,7 @@ export interface TidalTrack {
|
||||||
duration: number;
|
duration: number;
|
||||||
/** Track replay-gain value in dB. */
|
/** Track replay-gain value in dB. */
|
||||||
replayGain: number;
|
replayGain: number;
|
||||||
/** Track peak amplitude (0–1). */
|
/** Track peak amplitude (0-1). */
|
||||||
peak: number;
|
peak: number;
|
||||||
/** Whether the track is available for streaming. */
|
/** Whether the track is available for streaming. */
|
||||||
allowStreaming: boolean;
|
allowStreaming: boolean;
|
||||||
|
|
@ -186,7 +186,7 @@ export interface TidalTrack {
|
||||||
volumeNumber: number;
|
volumeNumber: number;
|
||||||
/** Version suffix (e.g. `"Remastered"`), or `null`. */
|
/** Version suffix (e.g. `"Remastered"`), or `null`. */
|
||||||
version: string | null;
|
version: string | null;
|
||||||
/** Popularity score (0–100). */
|
/** Popularity score (0-100). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** Copyright notice. */
|
/** Copyright notice. */
|
||||||
copyright: string;
|
copyright: string;
|
||||||
|
|
@ -299,7 +299,7 @@ export interface TidalAlbum {
|
||||||
explicit: boolean;
|
explicit: boolean;
|
||||||
/** UPC barcode. */
|
/** UPC barcode. */
|
||||||
upc: string;
|
upc: string;
|
||||||
/** Popularity score (0–100). */
|
/** Popularity score (0-100). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** Highest available audio quality. */
|
/** Highest available audio quality. */
|
||||||
audioQuality: string;
|
audioQuality: string;
|
||||||
|
|
@ -339,7 +339,7 @@ export interface TidalVideoItem {
|
||||||
volumeNumber: number;
|
volumeNumber: number;
|
||||||
/** Track number on the disc. */
|
/** Track number on the disc. */
|
||||||
trackNumber: number;
|
trackNumber: number;
|
||||||
/** Popularity score (0–100). */
|
/** Popularity score (0-100). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** Double-precision popularity score (present in topvideos). */
|
/** Double-precision popularity score (present in topvideos). */
|
||||||
doublePopularity?: number;
|
doublePopularity?: number;
|
||||||
|
|
@ -452,7 +452,7 @@ export interface TidalSimilarAlbum {
|
||||||
releaseDate: string;
|
releaseDate: string;
|
||||||
/** Copyright information. */
|
/** Copyright information. */
|
||||||
copyright: { text: string };
|
copyright: { text: string };
|
||||||
/** Popularity score (0–1 float). */
|
/** Popularity score (0-1 float). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** Access type, e.g. `"PUBLIC"`. */
|
/** Access type, e.g. `"PUBLIC"`. */
|
||||||
accessType: string;
|
accessType: string;
|
||||||
|
|
@ -533,7 +533,7 @@ export interface SimilarArtist {
|
||||||
url: string;
|
url: string;
|
||||||
/** Relation type, e.g. `"SIMILAR_ARTIST"`. */
|
/** Relation type, e.g. `"SIMILAR_ARTIST"`. */
|
||||||
relationType: string;
|
relationType: string;
|
||||||
/** Popularity score (0–1 float). */
|
/** Popularity score (0-1 float). */
|
||||||
popularity: number;
|
popularity: number;
|
||||||
/** External link entries (e.g. TIDAL sharing URL). */
|
/** External link entries (e.g. TIDAL sharing URL). */
|
||||||
externalLinks: Array<{ href: string; meta: { type: string } }>;
|
externalLinks: Array<{ href: string; meta: { type: string } }>;
|
||||||
|
|
@ -911,7 +911,7 @@ export interface TopVideosResponse extends VersionedResponse {
|
||||||
export interface TidalAudioNormData {
|
export interface TidalAudioNormData {
|
||||||
/** Replay gain value in dB. */
|
/** Replay gain value in dB. */
|
||||||
replayGain: number;
|
replayGain: number;
|
||||||
/** Peak amplitude (0–1). */
|
/** Peak amplitude (0-1). */
|
||||||
peakAmplitude: number;
|
peakAmplitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -961,7 +961,7 @@ export interface TrackManifestAttributes {
|
||||||
export interface TrackManifestResource {
|
export interface TrackManifestResource {
|
||||||
/** Resource identifier (track ID as a string). */
|
/** Resource identifier (track ID as a string). */
|
||||||
id: string;
|
id: string;
|
||||||
/** JSON:API resource type — always `"trackManifests"`. */
|
/** JSON:API resource type - always `"trackManifests"`. */
|
||||||
type: string;
|
type: string;
|
||||||
/** Manifest attributes. */
|
/** Manifest attributes. */
|
||||||
attributes: TrackManifestAttributes;
|
attributes: TrackManifestAttributes;
|
||||||
|
|
|
||||||
|
|
@ -513,7 +513,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
* visited the local tab yet).
|
* visited the local tab yet).
|
||||||
*/
|
*/
|
||||||
async function scanLocalMediaFolder(onlyIfAlreadyScanned = false) {
|
async function scanLocalMediaFolder(onlyIfAlreadyScanned = false) {
|
||||||
// Skip the scan if the user has never visited the local tab – they'll
|
// Skip the scan if the user has never visited the local tab - they'll
|
||||||
// get a fresh scan when they navigate there for the first time.
|
// get a fresh scan when they navigate there for the first time.
|
||||||
if (onlyIfAlreadyScanned && !window.localFilesCache) return;
|
if (onlyIfAlreadyScanned && !window.localFilesCache) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
// Supports 3-32 parametric EQ bands
|
// Supports 3-32 parametric EQ bands
|
||||||
|
|
||||||
import { isIos } from './platform-detection.js';
|
import { isIos } from './platform-detection.js';
|
||||||
import { equalizerSettings, monoAudioSettings } from './storage.js';
|
import { equalizerSettings, monoAudioSettings, binauralDspSettings } from './storage.js';
|
||||||
|
import { BinauralDSP } from './binaural-dsp.js';
|
||||||
|
|
||||||
// Generate frequency array for given number of bands using logarithmic spacing
|
// Generate frequency array for given number of bands using logarithmic spacing
|
||||||
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
||||||
|
|
@ -11,6 +12,12 @@ function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
||||||
const safeMin = Math.max(10, minFreq);
|
const safeMin = Math.max(10, minFreq);
|
||||||
const safeMax = Math.min(96000, maxFreq);
|
const safeMax = Math.min(96000, maxFreq);
|
||||||
|
|
||||||
|
if (bandCount <= 1) {
|
||||||
|
// Single band: use geometric mean of range
|
||||||
|
frequencies.push(Math.round(Math.sqrt(safeMin * safeMax)));
|
||||||
|
return frequencies;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < bandCount; i++) {
|
for (let i = 0; i < bandCount; i++) {
|
||||||
// Logarithmic interpolation
|
// Logarithmic interpolation
|
||||||
const t = i / (bandCount - 1);
|
const t = i / (bandCount - 1);
|
||||||
|
|
@ -102,6 +109,28 @@ class AudioContextManager {
|
||||||
this.isMonoAudioEnabled = false;
|
this.isMonoAudioEnabled = false;
|
||||||
this.monoMergerNode = null;
|
this.monoMergerNode = null;
|
||||||
this.audio = null;
|
this.audio = null;
|
||||||
|
|
||||||
|
// M/S (Mid/Side) processing state
|
||||||
|
this.msEnabled = false;
|
||||||
|
this.msSplitter = null;
|
||||||
|
this.msEncoderMidL = null;
|
||||||
|
this.msEncoderMidR = null;
|
||||||
|
this.msEncoderSideL = null;
|
||||||
|
this.msEncoderSideR = null;
|
||||||
|
this.msMidInput = null;
|
||||||
|
this.msSideInput = null;
|
||||||
|
this.midFilters = [];
|
||||||
|
this.sideFilters = [];
|
||||||
|
this.midOutputNode = null;
|
||||||
|
this.sideOutputNode = null;
|
||||||
|
this.msDecoderMidToL = null;
|
||||||
|
this.msDecoderSideToL = null;
|
||||||
|
this.msDecoderMidToR = null;
|
||||||
|
this.msDecoderSideToR = null;
|
||||||
|
this.msLMix = null;
|
||||||
|
this.msRMix = null;
|
||||||
|
this.msMerger = null;
|
||||||
|
this.msOutputNode = null;
|
||||||
this.currentVolume = 1.0;
|
this.currentVolume = 1.0;
|
||||||
|
|
||||||
// Band configuration
|
// Band configuration
|
||||||
|
|
@ -109,17 +138,24 @@ 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 = new Array(this.bandCount).fill(0);
|
this.currentGains = new Array(this.bandCount).fill(0);
|
||||||
|
this.currentChannels = new Array(this.bandCount).fill('stereo');
|
||||||
|
|
||||||
|
// Binaural DSP state
|
||||||
|
this.binauralDsp = null;
|
||||||
|
this.isBinauralEnabled = binauralDspSettings.isEnabled();
|
||||||
|
|
||||||
// Callbacks for audio graph changes (for visualizers like Butterchurn)
|
// Callbacks for audio graph changes (for visualizers like Butterchurn)
|
||||||
this._graphChangeCallbacks = [];
|
this._graphChangeCallbacks = [];
|
||||||
|
|
||||||
// --- Graphic EQ (16-band, separate chain) ---
|
// --- Graphic EQ (configurable bands, separate chain) ---
|
||||||
this.geqFilters = [];
|
this.geqFilters = [];
|
||||||
this.geqPreampNode = null;
|
this.geqPreampNode = null;
|
||||||
this.geqOutputNode = null;
|
this.geqOutputNode = null;
|
||||||
this.isGraphicEQEnabled = equalizerSettings.isGraphicEqEnabled();
|
this.isGraphicEQEnabled = equalizerSettings.isGraphicEqEnabled();
|
||||||
this.geqFrequencies = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000];
|
this.geqBandCount = equalizerSettings.getGraphicEqBandCount();
|
||||||
this.geqGains = equalizerSettings.getGraphicEqGains();
|
this.geqFreqRange = equalizerSettings.getGraphicEqFreqRange();
|
||||||
|
this.geqFrequencies = generateFrequencies(this.geqBandCount, this.geqFreqRange.min, this.geqFreqRange.max);
|
||||||
|
this.geqGains = equalizerSettings.getGraphicEqGains(this.geqBandCount);
|
||||||
this.geqPreamp = equalizerSettings.getGraphicEqPreamp();
|
this.geqPreamp = equalizerSettings.getGraphicEqPreamp();
|
||||||
|
|
||||||
// Load saved settings
|
// Load saved settings
|
||||||
|
|
@ -145,15 +181,16 @@ class AudioContextManager {
|
||||||
this.frequencies = generateFrequencies(newCount, this.freqRange.min, this.freqRange.max);
|
this.frequencies = generateFrequencies(newCount, this.freqRange.min, this.freqRange.max);
|
||||||
|
|
||||||
// Interpolate current gains to new band count
|
// Interpolate current gains to new band count
|
||||||
const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount);
|
const newGains = equalizerSettings.interpolateGains(this.currentGains, newCount);
|
||||||
this.currentGains = newGains;
|
this.currentGains = newGains;
|
||||||
equalizerSettings.setGains(newGains);
|
equalizerSettings.setGains(newGains);
|
||||||
|
|
||||||
// Reinitialize EQ if already initialized
|
// Reinitialize EQ if already initialized
|
||||||
if (this.isInitialized && this.audioContext) {
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyMSFilters();
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
// Reconnect the audio graph without interrupting playback
|
if (this.msEnabled) this._createMSFilters();
|
||||||
this._connectGraph();
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,9 +225,10 @@ class AudioContextManager {
|
||||||
|
|
||||||
// Reinitialize EQ if already initialized
|
// Reinitialize EQ if already initialized
|
||||||
if (this.isInitialized && this.audioContext) {
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyMSFilters();
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
// Reconnect the audio graph without interrupting playback
|
if (this.msEnabled) this._createMSFilters();
|
||||||
this._connectGraph();
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,6 +268,131 @@ class AudioContextManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create M/S matrix nodes (encoder, decoder, merger).
|
||||||
|
* These are cheap static nodes created once in init().
|
||||||
|
*/
|
||||||
|
_createMSNodes() {
|
||||||
|
if (!this.audioContext) return;
|
||||||
|
|
||||||
|
this.msSplitter = this.audioContext.createChannelSplitter(2);
|
||||||
|
|
||||||
|
// Encoder: L/R → M/S
|
||||||
|
this.msEncoderMidL = this.audioContext.createGain();
|
||||||
|
this.msEncoderMidL.gain.value = 0.5;
|
||||||
|
this.msEncoderMidR = this.audioContext.createGain();
|
||||||
|
this.msEncoderMidR.gain.value = 0.5;
|
||||||
|
this.msEncoderSideL = this.audioContext.createGain();
|
||||||
|
this.msEncoderSideL.gain.value = 0.5;
|
||||||
|
this.msEncoderSideR = this.audioContext.createGain();
|
||||||
|
this.msEncoderSideR.gain.value = -0.5;
|
||||||
|
|
||||||
|
// Mono mixing points for M and S signals
|
||||||
|
this.msMidInput = this.audioContext.createGain();
|
||||||
|
this.msMidInput.channelCount = 1;
|
||||||
|
this.msMidInput.channelCountMode = 'explicit';
|
||||||
|
this.msSideInput = this.audioContext.createGain();
|
||||||
|
this.msSideInput.channelCount = 1;
|
||||||
|
this.msSideInput.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Chain output nodes
|
||||||
|
this.midOutputNode = this.audioContext.createGain();
|
||||||
|
this.sideOutputNode = this.audioContext.createGain();
|
||||||
|
|
||||||
|
// Decoder: M/S → L/R
|
||||||
|
this.msDecoderMidToL = this.audioContext.createGain();
|
||||||
|
this.msDecoderMidToL.gain.value = 1.0;
|
||||||
|
this.msDecoderSideToL = this.audioContext.createGain();
|
||||||
|
this.msDecoderSideToL.gain.value = 1.0;
|
||||||
|
this.msDecoderMidToR = this.audioContext.createGain();
|
||||||
|
this.msDecoderMidToR.gain.value = 1.0;
|
||||||
|
this.msDecoderSideToR = this.audioContext.createGain();
|
||||||
|
this.msDecoderSideToR.gain.value = -1.0;
|
||||||
|
|
||||||
|
// L/R recombination points (mono)
|
||||||
|
this.msLMix = this.audioContext.createGain();
|
||||||
|
this.msLMix.channelCount = 1;
|
||||||
|
this.msLMix.channelCountMode = 'explicit';
|
||||||
|
this.msRMix = this.audioContext.createGain();
|
||||||
|
this.msRMix.channelCount = 1;
|
||||||
|
this.msRMix.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this.msMerger = this.audioContext.createChannelMerger(2);
|
||||||
|
this.msOutputNode = this.audioContext.createGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create parallel M/S filter chains based on current band settings.
|
||||||
|
* Mid filters process the center image, Side filters process stereo width.
|
||||||
|
*/
|
||||||
|
_createMSFilters() {
|
||||||
|
if (!this.audioContext) return;
|
||||||
|
|
||||||
|
this.midFilters = this.frequencies.map((freq, i) => {
|
||||||
|
const type = (this.currentTypes && this.currentTypes[i]) || 'peaking';
|
||||||
|
const q = this.currentQs && this.currentQs[i] > 0 ? this.currentQs[i] : this._calculateQ(i);
|
||||||
|
const ch = (this.currentChannels && this.currentChannels[i]) || 'stereo';
|
||||||
|
const gain = ch === 'side' ? 0 : this.currentGains[i] || 0;
|
||||||
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
|
filter.type = type;
|
||||||
|
filter.frequency.value = freq;
|
||||||
|
filter.Q.value = q;
|
||||||
|
filter.gain.value = gain;
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sideFilters = this.frequencies.map((freq, i) => {
|
||||||
|
const type = (this.currentTypes && this.currentTypes[i]) || 'peaking';
|
||||||
|
const q = this.currentQs && this.currentQs[i] > 0 ? this.currentQs[i] : this._calculateQ(i);
|
||||||
|
const ch = (this.currentChannels && this.currentChannels[i]) || 'stereo';
|
||||||
|
const gain = ch === 'mid' ? 0 : this.currentGains[i] || 0;
|
||||||
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
|
filter.type = type;
|
||||||
|
filter.frequency.value = freq;
|
||||||
|
filter.Q.value = q;
|
||||||
|
filter.gain.value = gain;
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy M/S parallel filter chains
|
||||||
|
*/
|
||||||
|
_destroyMSFilters() {
|
||||||
|
const sd = (node) => {
|
||||||
|
try {
|
||||||
|
node?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.midFilters.forEach(sd);
|
||||||
|
this.sideFilters.forEach(sd);
|
||||||
|
this.midFilters = [];
|
||||||
|
this.sideFilters = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing filter chain in place.
|
||||||
|
* @param {Array} chain - Filter array to update (this.filters, this.midFilters, or this.sideFilters)
|
||||||
|
* @param {Array} freqs - New frequencies
|
||||||
|
* @param {Array} types - New filter types
|
||||||
|
* @param {Array} qs - New Q values
|
||||||
|
* @param {Array} gains - New gain values
|
||||||
|
* @param {number} now - Current audio context time
|
||||||
|
*/
|
||||||
|
_updateFilterChain(chain, freqs, types, qs, gains, now) {
|
||||||
|
chain.forEach((filter, i) => {
|
||||||
|
const type = types[i] || 'peaking';
|
||||||
|
const q = qs[i] > 0 ? qs[i] : this._calculateQ(i);
|
||||||
|
const gain = gains[i];
|
||||||
|
filter.type = type;
|
||||||
|
filter.frequency.setTargetAtTime(freqs[i], now, 0.005);
|
||||||
|
filter.gain.setTargetAtTime(gain, now, 0.005);
|
||||||
|
filter.Q.setTargetAtTime(q, now, 0.005);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create EQ filters
|
* Create EQ filters
|
||||||
*/
|
*/
|
||||||
|
|
@ -245,14 +408,16 @@ class AudioContextManager {
|
||||||
const gainValue = Math.pow(10, preampValue / 20);
|
const gainValue = Math.pow(10, preampValue / 20);
|
||||||
this.preampNode.gain.value = gainValue;
|
this.preampNode.gain.value = gainValue;
|
||||||
|
|
||||||
// Create biquad filters for each frequency band
|
// Create filters for each frequency band
|
||||||
this.filters = this.frequencies.map((freq, index) => {
|
this.filters = this.frequencies.map((freq, index) => {
|
||||||
|
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||||
|
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
||||||
|
const gain = this.currentGains[index] || 0;
|
||||||
const filter = this.audioContext.createBiquadFilter();
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
filter.type = type;
|
||||||
filter.frequency.value = freq;
|
filter.frequency.value = freq;
|
||||||
filter.Q.value =
|
filter.Q.value = q;
|
||||||
this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
filter.gain.value = gain;
|
||||||
filter.gain.value = this.currentGains[index] || 0;
|
|
||||||
return filter;
|
return filter;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -326,16 +491,34 @@ class AudioContextManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.sources.has(audioElement)) {
|
if (!this.sources.has(audioElement)) {
|
||||||
this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement));
|
const src = this.audioContext.createMediaElementSource(audioElement);
|
||||||
|
this.sources.set(audioElement, src);
|
||||||
}
|
}
|
||||||
this.source = this.sources.get(audioElement);
|
this.source = this.sources.get(audioElement);
|
||||||
|
|
||||||
|
// Enable multichannel passthrough for Atmos/spatial content
|
||||||
|
try {
|
||||||
|
this.audioContext.destination.channelCount = Math.min(this.audioContext.destination.maxChannelCount, 8);
|
||||||
|
this.audioContext.destination.channelCountMode = 'explicit';
|
||||||
|
this.audioContext.destination.channelInterpretation = 'discrete';
|
||||||
|
} catch {
|
||||||
|
// Some browsers may not support changing destination channel count
|
||||||
|
}
|
||||||
|
|
||||||
this.analyser = this.audioContext.createAnalyser();
|
this.analyser = this.audioContext.createAnalyser();
|
||||||
this.analyser.fftSize = 1024;
|
this.analyser.fftSize = 1024;
|
||||||
this.analyser.smoothingTimeConstant = 0.7;
|
this.analyser.smoothingTimeConstant = 0.7;
|
||||||
|
|
||||||
|
// Create binaural DSP processor
|
||||||
|
this.binauralDsp = new BinauralDSP(this.audioContext);
|
||||||
|
void this._loadBinauralSettings();
|
||||||
|
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
this._createGraphicEQ();
|
this._createGraphicEQ();
|
||||||
|
this._createMSNodes();
|
||||||
|
if (this.msEnabled) {
|
||||||
|
this._createMSFilters();
|
||||||
|
}
|
||||||
|
|
||||||
this.outputNode = this.audioContext.createGain();
|
this.outputNode = this.audioContext.createGain();
|
||||||
this.outputNode.gain.value = 1;
|
this.outputNode.gain.value = 1;
|
||||||
|
|
@ -447,9 +630,36 @@ class AudioContextManager {
|
||||||
safeDisconnect(this.source);
|
safeDisconnect(this.source);
|
||||||
safeDisconnect(this.monoGainNode);
|
safeDisconnect(this.monoGainNode);
|
||||||
safeDisconnect(this.monoMergerNode);
|
safeDisconnect(this.monoMergerNode);
|
||||||
|
// Binaural DSP disconnects internally
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
const { input, output } = this.binauralDsp.getNodes();
|
||||||
|
safeDisconnect(input);
|
||||||
|
safeDisconnect(output);
|
||||||
|
}
|
||||||
safeDisconnect(this.preampNode);
|
safeDisconnect(this.preampNode);
|
||||||
this.filters.forEach(safeDisconnect);
|
this.filters.forEach(safeDisconnect);
|
||||||
safeDisconnect(this.outputNode);
|
safeDisconnect(this.outputNode);
|
||||||
|
// M/S nodes
|
||||||
|
safeDisconnect(this.msSplitter);
|
||||||
|
safeDisconnect(this.msEncoderMidL);
|
||||||
|
safeDisconnect(this.msEncoderMidR);
|
||||||
|
safeDisconnect(this.msEncoderSideL);
|
||||||
|
safeDisconnect(this.msEncoderSideR);
|
||||||
|
safeDisconnect(this.msMidInput);
|
||||||
|
safeDisconnect(this.msSideInput);
|
||||||
|
this.midFilters.forEach(safeDisconnect);
|
||||||
|
this.sideFilters.forEach(safeDisconnect);
|
||||||
|
safeDisconnect(this.midOutputNode);
|
||||||
|
safeDisconnect(this.sideOutputNode);
|
||||||
|
safeDisconnect(this.msDecoderMidToL);
|
||||||
|
safeDisconnect(this.msDecoderSideToL);
|
||||||
|
safeDisconnect(this.msDecoderMidToR);
|
||||||
|
safeDisconnect(this.msDecoderSideToR);
|
||||||
|
safeDisconnect(this.msLMix);
|
||||||
|
safeDisconnect(this.msRMix);
|
||||||
|
safeDisconnect(this.msMerger);
|
||||||
|
safeDisconnect(this.msOutputNode);
|
||||||
|
// Graphic EQ + tail
|
||||||
safeDisconnect(this.geqPreampNode);
|
safeDisconnect(this.geqPreampNode);
|
||||||
this.geqFilters.forEach(safeDisconnect);
|
this.geqFilters.forEach(safeDisconnect);
|
||||||
safeDisconnect(this.geqOutputNode);
|
safeDisconnect(this.geqOutputNode);
|
||||||
|
|
@ -466,18 +676,77 @@ class AudioContextManager {
|
||||||
lastNode = this.monoMergerNode;
|
lastNode = this.monoMergerNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert binaural DSP before EQ
|
||||||
|
if (this.isBinauralEnabled && this.binauralDsp) {
|
||||||
|
const { input, output } = this.binauralDsp.getNodes();
|
||||||
|
lastNode.connect(input);
|
||||||
|
this.binauralDsp.reconnect();
|
||||||
|
lastNode = output;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isEQEnabled && this.filters.length > 0) {
|
if (this.isEQEnabled && this.filters.length > 0) {
|
||||||
for (let i = 0; i < this.filters.length - 1; i++) {
|
const useMS = this.msEnabled && this.midFilters.length > 0 && this.sideFilters.length > 0;
|
||||||
this.filters[i].connect(this.filters[i + 1]);
|
|
||||||
}
|
// Connect preamp
|
||||||
if (this.preampNode) {
|
if (this.preampNode) {
|
||||||
lastNode.connect(this.preampNode);
|
lastNode.connect(this.preampNode);
|
||||||
this.preampNode.connect(this.filters[0]);
|
lastNode = this.preampNode;
|
||||||
} else {
|
}
|
||||||
lastNode.connect(this.filters[0]);
|
|
||||||
|
if (useMS) {
|
||||||
|
// === M/S processing path ===
|
||||||
|
// Encode L/R → M/S
|
||||||
|
lastNode.connect(this.msSplitter);
|
||||||
|
|
||||||
|
this.msSplitter.connect(this.msEncoderMidL, 0); // L → Mid
|
||||||
|
this.msSplitter.connect(this.msEncoderMidR, 1); // R → Mid
|
||||||
|
this.msEncoderMidL.connect(this.msMidInput);
|
||||||
|
this.msEncoderMidR.connect(this.msMidInput); // Mid = (L+R)*0.5
|
||||||
|
|
||||||
|
this.msSplitter.connect(this.msEncoderSideL, 0); // L → Side
|
||||||
|
this.msSplitter.connect(this.msEncoderSideR, 1); // R → Side (-0.5)
|
||||||
|
this.msEncoderSideL.connect(this.msSideInput);
|
||||||
|
this.msEncoderSideR.connect(this.msSideInput); // Side = (L-R)*0.5
|
||||||
|
|
||||||
|
// Mid filter chain
|
||||||
|
this.msMidInput.connect(this.midFilters[0]);
|
||||||
|
for (let i = 0; i < this.midFilters.length - 1; i++) {
|
||||||
|
this.midFilters[i].connect(this.midFilters[i + 1]);
|
||||||
|
}
|
||||||
|
this.midFilters[this.midFilters.length - 1].connect(this.midOutputNode);
|
||||||
|
|
||||||
|
// Side filter chain
|
||||||
|
this.msSideInput.connect(this.sideFilters[0]);
|
||||||
|
for (let i = 0; i < this.sideFilters.length - 1; i++) {
|
||||||
|
this.sideFilters[i].connect(this.sideFilters[i + 1]);
|
||||||
|
}
|
||||||
|
this.sideFilters[this.sideFilters.length - 1].connect(this.sideOutputNode);
|
||||||
|
|
||||||
|
// Decode M/S → L/R
|
||||||
|
this.midOutputNode.connect(this.msDecoderMidToL);
|
||||||
|
this.sideOutputNode.connect(this.msDecoderSideToL);
|
||||||
|
this.msDecoderMidToL.connect(this.msLMix);
|
||||||
|
this.msDecoderSideToL.connect(this.msLMix); // L = Mid + Side
|
||||||
|
|
||||||
|
this.midOutputNode.connect(this.msDecoderMidToR);
|
||||||
|
this.sideOutputNode.connect(this.msDecoderSideToR);
|
||||||
|
this.msDecoderMidToR.connect(this.msRMix);
|
||||||
|
this.msDecoderSideToR.connect(this.msRMix); // R = Mid - Side
|
||||||
|
|
||||||
|
this.msLMix.connect(this.msMerger, 0, 0);
|
||||||
|
this.msRMix.connect(this.msMerger, 0, 1);
|
||||||
|
this.msMerger.connect(this.msOutputNode);
|
||||||
|
|
||||||
|
connectTail(this.msOutputNode);
|
||||||
|
} else {
|
||||||
|
// === Normal stereo path ===
|
||||||
|
lastNode.connect(this.filters[0]);
|
||||||
|
for (let i = 0; i < this.filters.length - 1; i++) {
|
||||||
|
this.filters[i].connect(this.filters[i + 1]);
|
||||||
|
}
|
||||||
|
this.filters[this.filters.length - 1].connect(this.outputNode);
|
||||||
|
connectTail(this.outputNode);
|
||||||
}
|
}
|
||||||
this.filters[this.filters.length - 1].connect(this.outputNode);
|
|
||||||
connectTail(this.outputNode);
|
|
||||||
} else {
|
} else {
|
||||||
connectTail(lastNode);
|
connectTail(lastNode);
|
||||||
}
|
}
|
||||||
|
|
@ -602,6 +871,126 @@ class AudioContextManager {
|
||||||
return this.isInitialized && this.isMonoAudioEnabled;
|
return this.isInitialized && this.isMonoAudioEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Binaural DSP controls
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle binaural DSP on/off
|
||||||
|
*/
|
||||||
|
async toggleBinaural(enabled) {
|
||||||
|
this.isBinauralEnabled = enabled;
|
||||||
|
binauralDspSettings.setEnabled(enabled);
|
||||||
|
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
await this.binauralDsp.setEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isInitialized) {
|
||||||
|
this._connectGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.isBinauralEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if binaural DSP is active
|
||||||
|
*/
|
||||||
|
isBinauralActive() {
|
||||||
|
return this.isInitialized && this.isBinauralEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set crossfeed enabled state
|
||||||
|
*/
|
||||||
|
async setBinauralCrossfeedEnabled(enabled) {
|
||||||
|
binauralDspSettings.setCrossfeedEnabled(enabled);
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
await this.binauralDsp.setCrossfeedEnabled(enabled);
|
||||||
|
if (this.isInitialized) this._connectGraph();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set crossfeed level
|
||||||
|
* @param {'low'|'medium'|'high'} level
|
||||||
|
*/
|
||||||
|
setBinauralCrossfeedLevel(level) {
|
||||||
|
binauralDspSettings.setCrossfeedLevel(level);
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
this.binauralDsp.setCrossfeedLevel(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set HRTF preset
|
||||||
|
* @param {'intimate'|'studio'|'wide'} preset
|
||||||
|
*/
|
||||||
|
async setBinauralHrtfPreset(preset) {
|
||||||
|
binauralDspSettings.setHrtfPreset(preset);
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
await this.binauralDsp.setHrtfPreset(preset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stereo widening enabled state
|
||||||
|
*/
|
||||||
|
async setBinauralWideningEnabled(enabled) {
|
||||||
|
binauralDspSettings.setWideningEnabled(enabled);
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
await this.binauralDsp.setWideningEnabled(enabled);
|
||||||
|
if (this.isInitialized) this._connectGraph();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stereo widening amount
|
||||||
|
* @param {number} amount - 0.0 to 2.0 (1.0 = neutral)
|
||||||
|
*/
|
||||||
|
setBinauralWidening(amount) {
|
||||||
|
binauralDspSettings.setWideningAmount(amount);
|
||||||
|
if (this.binauralDsp) {
|
||||||
|
this.binauralDsp.setWideningAmount(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify binaural DSP of channel count change (for multichannel detection)
|
||||||
|
* @param {number} channelCount
|
||||||
|
*/
|
||||||
|
async notifyBinauralChannelCount(channelCount) {
|
||||||
|
if (this.binauralDsp && this.isBinauralEnabled) {
|
||||||
|
await this.binauralDsp.detectAndConfigure(channelCount);
|
||||||
|
if (this.isInitialized) this._connectGraph();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get binaural DSP status
|
||||||
|
*/
|
||||||
|
getBinauralStatus() {
|
||||||
|
return this.binauralDsp ? this.binauralDsp.getStatus() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load binaural settings from storage and apply to DSP
|
||||||
|
*/
|
||||||
|
async _loadBinauralSettings() {
|
||||||
|
if (!this.binauralDsp) return;
|
||||||
|
|
||||||
|
this.isBinauralEnabled = binauralDspSettings.isEnabled();
|
||||||
|
this.binauralDsp.crossfeedEnabled = binauralDspSettings.getCrossfeedEnabled();
|
||||||
|
this.binauralDsp.crossfeedLevel = binauralDspSettings.getCrossfeedLevel();
|
||||||
|
this.binauralDsp.hrtfPreset = binauralDspSettings.getHrtfPreset();
|
||||||
|
this.binauralDsp.wideningEnabled = binauralDspSettings.getWideningEnabled();
|
||||||
|
this.binauralDsp.wideningAmount = binauralDspSettings.getWideningAmount();
|
||||||
|
|
||||||
|
if (this.isBinauralEnabled) {
|
||||||
|
await this.binauralDsp.setEnabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current gain range
|
* Get current gain range
|
||||||
*/
|
*/
|
||||||
|
|
@ -694,7 +1083,7 @@ class AudioContextManager {
|
||||||
// Ensure gains array matches current band count
|
// Ensure gains array matches current band count
|
||||||
let adjustedGains = gains;
|
let adjustedGains = gains;
|
||||||
if (gains.length !== this.bandCount) {
|
if (gains.length !== this.bandCount) {
|
||||||
adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount);
|
adjustedGains = equalizerSettings.interpolateGains(gains, this.bandCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = this.audioContext?.currentTime || 0;
|
const now = this.audioContext?.currentTime || 0;
|
||||||
|
|
@ -757,8 +1146,11 @@ class AudioContextManager {
|
||||||
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
||||||
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
|
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
|
||||||
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
|
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
|
||||||
|
this.currentChannels = equalizerSettings.getBandChannels(this.bandCount);
|
||||||
|
this.msEnabled = this.currentChannels.some((ch) => ch === 'mid' || ch === 'side');
|
||||||
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
||||||
this.preamp = equalizerSettings.getPreamp();
|
this.preamp = equalizerSettings.getPreamp();
|
||||||
|
this.isBinauralEnabled = binauralDspSettings.isEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -796,6 +1188,7 @@ class AudioContextManager {
|
||||||
if (!bands || bands.length === 0) return '';
|
if (!bands || bands.length === 0) return '';
|
||||||
|
|
||||||
const enabledBands = bands.filter((b) => b.enabled);
|
const enabledBands = bands.filter((b) => b.enabled);
|
||||||
|
if (enabledBands.length === 0) return '';
|
||||||
const count = Math.max(equalizerSettings.MIN_BANDS, Math.min(equalizerSettings.MAX_BANDS, enabledBands.length));
|
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
|
// Calculate preamp: negative of cumulative peak gain across all bands to prevent clipping
|
||||||
|
|
@ -820,39 +1213,77 @@ class AudioContextManager {
|
||||||
// Sort bands by frequency so index order is deterministic
|
// Sort bands by frequency so index order is deterministic
|
||||||
const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq);
|
const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq);
|
||||||
|
|
||||||
// Build normalized band descriptor arrays
|
// Build normalized band descriptor arrays, pad if fewer enabled bands than minimum
|
||||||
const newFrequencies = sortedBands
|
const maxFreq = (this.audioContext?.sampleRate ?? 48000) / 2 - 1;
|
||||||
.slice(0, count)
|
const slicedBands = sortedBands.slice(0, count);
|
||||||
.map((b) => Math.round(Math.min(b.freq, (this.audioContext?.sampleRate ?? 48000) / 2 - 1)));
|
const newFrequencies = slicedBands.map((b) => Math.round(Math.min(b.freq, maxFreq)));
|
||||||
const newTypes = sortedBands.slice(0, count).map((b) => b.type || 'peaking');
|
const newTypes = slicedBands.map((b) => b.type || 'peaking');
|
||||||
const newQs = sortedBands.slice(0, count).map((b) => b.q);
|
const newQs = slicedBands.map((b) => b.q);
|
||||||
const newGains = sortedBands.slice(0, count).map((b) => this._clampGain(b.gain));
|
const newGains = slicedBands.map((b) => this._clampGain(b.gain));
|
||||||
|
const newChannels = slicedBands.map((b) => b.channel || 'stereo');
|
||||||
|
while (newFrequencies.length < count) {
|
||||||
|
const lastFreq = newFrequencies[newFrequencies.length - 1] || 1000;
|
||||||
|
newFrequencies.push(Math.round(Math.min(lastFreq * 2, maxFreq)));
|
||||||
|
newTypes.push('peaking');
|
||||||
|
newQs.push(1.0);
|
||||||
|
newGains.push(0);
|
||||||
|
newChannels.push('stereo');
|
||||||
|
}
|
||||||
|
|
||||||
// Update band count via class setter to trigger equalizer-band-count-changed event
|
// Update band count via class setter to trigger equalizer-band-count-changed event
|
||||||
if (count !== this.bandCount) {
|
if (count !== this.bandCount) {
|
||||||
this.setBandCount(count);
|
this.setBandCount(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override frequencies, types, and Qs with band-specific values
|
// Override frequencies, types, Qs, and channels with band-specific values
|
||||||
this.frequencies = newFrequencies;
|
this.frequencies = newFrequencies;
|
||||||
this.currentTypes = newTypes;
|
this.currentTypes = newTypes;
|
||||||
this.currentQs = newQs;
|
this.currentQs = newQs;
|
||||||
this.currentGains = newGains;
|
this.currentGains = newGains;
|
||||||
|
this.currentChannels = newChannels;
|
||||||
|
|
||||||
|
// Determine if M/S processing is needed
|
||||||
|
const needsMS = newChannels.some((ch) => ch === 'mid' || ch === 'side');
|
||||||
|
const msChanged = needsMS !== this.msEnabled;
|
||||||
|
this.msEnabled = needsMS;
|
||||||
|
|
||||||
if (this.isInitialized && this.audioContext) {
|
if (this.isInitialized && this.audioContext) {
|
||||||
// If filter count matches, update params in-place (no graph rebuild)
|
const needsRebuild =
|
||||||
if (this.filters.length === count) {
|
msChanged || this.filters.length !== count || (needsMS && this.midFilters.length !== count);
|
||||||
const now = this.audioContext.currentTime;
|
|
||||||
this.filters.forEach((filter, i) => {
|
if (needsRebuild) {
|
||||||
filter.type = newTypes[i] || 'peaking';
|
// M/S state changed or band count changed - full rebuild
|
||||||
filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005);
|
this._destroyMSFilters();
|
||||||
filter.gain.setTargetAtTime(newGains[i], now, 0.005);
|
|
||||||
filter.Q.setTargetAtTime(newQs[i] > 0 ? newQs[i] : this._calculateQ(i), now, 0.005);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Band count changed — must rebuild
|
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
|
if (needsMS) {
|
||||||
|
this._createMSFilters();
|
||||||
|
}
|
||||||
|
this._connectGraph();
|
||||||
|
} else if (needsMS) {
|
||||||
|
// M/S active - update both parallel chains in-place
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Update main filters (not connected in M/S mode, kept in sync for stereo fallback)
|
||||||
|
this._updateFilterChain(this.filters, newFrequencies, newTypes, newQs, newGains, now);
|
||||||
|
|
||||||
|
// Update mid filters (gain = 0 for side-only bands)
|
||||||
|
const midGains = newGains.map((g, i) => (newChannels[i] === 'side' ? 0 : g));
|
||||||
|
this._updateFilterChain(this.midFilters, newFrequencies, newTypes, newQs, midGains, now);
|
||||||
|
|
||||||
|
// Update side filters (gain = 0 for mid-only bands)
|
||||||
|
const sideGains = newGains.map((g, i) => (newChannels[i] === 'mid' ? 0 : g));
|
||||||
|
this._updateFilterChain(this.sideFilters, newFrequencies, newTypes, newQs, sideGains, now);
|
||||||
|
} else if (this.filters.length === count) {
|
||||||
|
// Normal stereo - update in-place
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
this._updateFilterChain(this.filters, newFrequencies, newTypes, newQs, newGains, now);
|
||||||
|
} else {
|
||||||
|
// Band count changed - must rebuild
|
||||||
|
this._destroyMSFilters();
|
||||||
|
this._destroyEQ();
|
||||||
|
this._createEQ();
|
||||||
|
if (this.msEnabled) this._createMSFilters();
|
||||||
this._connectGraph();
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -867,12 +1298,13 @@ class AudioContextManager {
|
||||||
equalizerSettings.setGains(this.currentGains);
|
equalizerSettings.setGains(this.currentGains);
|
||||||
equalizerSettings.setBandTypes(this.currentTypes);
|
equalizerSettings.setBandTypes(this.currentTypes);
|
||||||
equalizerSettings.setBandQs(this.currentQs);
|
equalizerSettings.setBandQs(this.currentQs);
|
||||||
|
equalizerSettings.setBandChannels(this.currentChannels);
|
||||||
|
|
||||||
// Generate export text using the actual applied preamp value
|
// Generate export text using the actual applied preamp value
|
||||||
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
|
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
|
||||||
sortedBands.forEach((band, index) => {
|
sortedBands.forEach((band, index) => {
|
||||||
if (index >= count) return;
|
if (index >= count) return;
|
||||||
const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';
|
const filterType = band.type === 'lowshelf' ? 'LSC' : band.type === 'highshelf' ? 'HSC' : 'PK';
|
||||||
lines.push(
|
lines.push(
|
||||||
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
|
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
|
||||||
);
|
);
|
||||||
|
|
@ -893,7 +1325,7 @@ 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 type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||||
const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK';
|
const filterType = type === 'lowshelf' ? 'LSC' : type === 'highshelf' ? 'HSC' : 'PK';
|
||||||
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
||||||
const filterNum = index + 1;
|
const filterNum = index + 1;
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|
@ -928,13 +1360,13 @@ class AudioContextManager {
|
||||||
|
|
||||||
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
|
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
|
||||||
const filterMatch = line.match(
|
const filterMatch = line.match(
|
||||||
/^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB\s+Q\s+(\d+\.?\d*)/i
|
/^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB(?:\s+Q\s+(\d+\.?\d*))?/i
|
||||||
);
|
);
|
||||||
if (filterMatch) {
|
if (filterMatch) {
|
||||||
const type = filterMatch[1].toUpperCase();
|
const type = filterMatch[1].toUpperCase();
|
||||||
const freq = parseInt(filterMatch[2], 10);
|
const freq = parseInt(filterMatch[2], 10);
|
||||||
const gain = parseFloat(filterMatch[3]);
|
const gain = parseFloat(filterMatch[3]);
|
||||||
const q = parseFloat(filterMatch[4]);
|
const q = filterMatch[4] ? parseFloat(filterMatch[4]) : Math.SQRT1_2;
|
||||||
filters.push({ type, freq, gain, q });
|
filters.push({ type, freq, gain, q });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -967,22 +1399,49 @@ class AudioContextManager {
|
||||||
HSC: 'highshelf',
|
HSC: 'highshelf',
|
||||||
HSF: 'highshelf',
|
HSF: 'highshelf',
|
||||||
};
|
};
|
||||||
this.frequencies = sliced.map((f) => f.freq);
|
|
||||||
this.currentTypes = sliced.map((f) => typeMap[f.type] || 'peaking');
|
// Pad arrays to bandCount if import has fewer filters than minimum
|
||||||
this.currentQs = sliced.map((f) => f.q);
|
const padCount = this.bandCount - sliced.length;
|
||||||
this.currentGains = sliced.map((f) => this._clampGain(f.gain));
|
const freqs = sliced.map((f) => f.freq);
|
||||||
|
const types = sliced.map((f) => typeMap[f.type] || 'peaking');
|
||||||
|
const qs = sliced.map((f) => f.q);
|
||||||
|
const gains = sliced.map((f) => this._clampGain(f.gain));
|
||||||
|
if (padCount > 0) {
|
||||||
|
const lastFreq = freqs[freqs.length - 1] || 1000;
|
||||||
|
const maxFreq = (this.audioContext?.sampleRate ?? 48000) / 2 - 1;
|
||||||
|
for (let p = 0; p < padCount; p++) {
|
||||||
|
const padFreq = Math.min(lastFreq * Math.pow(2, p + 1), maxFreq);
|
||||||
|
freqs.push(Math.round(padFreq));
|
||||||
|
types.push('peaking');
|
||||||
|
qs.push(this._calculateQ(freqs.length - 1));
|
||||||
|
gains.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.frequencies = freqs;
|
||||||
|
this.currentTypes = types;
|
||||||
|
this.currentQs = qs;
|
||||||
|
this.currentGains = gains;
|
||||||
|
|
||||||
|
// Reset M/S channel assignments - imported config has no channel info
|
||||||
|
this.currentChannels = new Array(this.bandCount).fill('stereo');
|
||||||
|
this.msEnabled = false;
|
||||||
|
|
||||||
// Rebuild EQ chain to apply new frequencies, types, and Qs
|
// Rebuild EQ chain to apply new frequencies, types, and Qs
|
||||||
if (this.isInitialized && this.audioContext) {
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyMSFilters();
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
|
if (this.msEnabled) this._createMSFilters();
|
||||||
this._connectGraph();
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist all band settings
|
// Persist all band settings including custom frequencies
|
||||||
|
equalizerSettings.setCustomFrequencies(this.frequencies);
|
||||||
equalizerSettings.setGains(this.currentGains);
|
equalizerSettings.setGains(this.currentGains);
|
||||||
equalizerSettings.setBandTypes(this.currentTypes);
|
equalizerSettings.setBandTypes(this.currentTypes);
|
||||||
equalizerSettings.setBandQs(this.currentQs);
|
equalizerSettings.setBandQs(this.currentQs);
|
||||||
|
equalizerSettings.setBandChannels(this.currentChannels);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -1004,11 +1463,12 @@ class AudioContextManager {
|
||||||
this.geqOutputNode = this.audioContext.createGain();
|
this.geqOutputNode = this.audioContext.createGain();
|
||||||
this.geqOutputNode.gain.value = 1;
|
this.geqOutputNode.gain.value = 1;
|
||||||
|
|
||||||
|
const geqQ = 2.5 * Math.sqrt(16 / this.geqBandCount);
|
||||||
this.geqFilters = this.geqFrequencies.map((freq, i) => {
|
this.geqFilters = this.geqFrequencies.map((freq, i) => {
|
||||||
const filter = this.audioContext.createBiquadFilter();
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
filter.type = 'peaking';
|
filter.type = 'peaking';
|
||||||
filter.frequency.value = freq;
|
filter.frequency.value = freq;
|
||||||
filter.Q.value = 2.5; // constant Q for 16-band
|
filter.Q.value = geqQ;
|
||||||
filter.gain.value = this.geqGains[i] || 0;
|
filter.gain.value = this.geqGains[i] || 0;
|
||||||
return filter;
|
return filter;
|
||||||
});
|
});
|
||||||
|
|
@ -1050,7 +1510,7 @@ class AudioContextManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setGraphicEqBandGain(bandIndex, gainDb) {
|
setGraphicEqBandGain(bandIndex, gainDb) {
|
||||||
if (bandIndex < 0 || bandIndex >= 16) return;
|
if (bandIndex < 0 || bandIndex >= this.geqBandCount) return;
|
||||||
this.geqGains[bandIndex] = Math.max(-30, Math.min(30, gainDb));
|
this.geqGains[bandIndex] = Math.max(-30, Math.min(30, gainDb));
|
||||||
if (this.geqFilters[bandIndex] && this.audioContext) {
|
if (this.geqFilters[bandIndex] && this.audioContext) {
|
||||||
const now = this.audioContext.currentTime;
|
const now = this.audioContext.currentTime;
|
||||||
|
|
@ -1063,7 +1523,7 @@ class AudioContextManager {
|
||||||
if (!Array.isArray(gains)) return;
|
if (!Array.isArray(gains)) return;
|
||||||
const now = this.audioContext?.currentTime || 0;
|
const now = this.audioContext?.currentTime || 0;
|
||||||
gains.forEach((g, i) => {
|
gains.forEach((g, i) => {
|
||||||
if (i >= 16) return;
|
if (i >= this.geqBandCount) return;
|
||||||
this.geqGains[i] = Math.max(-30, Math.min(30, g));
|
this.geqGains[i] = Math.max(-30, Math.min(30, g));
|
||||||
if (this.geqFilters[i]) {
|
if (this.geqFilters[i]) {
|
||||||
this.geqFilters[i].gain.setTargetAtTime(this.geqGains[i], now, 0.01);
|
this.geqFilters[i].gain.setTargetAtTime(this.geqGains[i], now, 0.01);
|
||||||
|
|
@ -1072,6 +1532,51 @@ class AudioContextManager {
|
||||||
equalizerSettings.setGraphicEqGains([...this.geqGains]);
|
equalizerSettings.setGraphicEqGains([...this.geqGains]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setGraphicEqBandCount(count) {
|
||||||
|
const newCount = Math.max(3, Math.min(32, parseInt(count, 10) || 16));
|
||||||
|
if (newCount === this.geqBandCount) return;
|
||||||
|
|
||||||
|
const oldGains = this.geqGains;
|
||||||
|
this.geqBandCount = newCount;
|
||||||
|
this.geqFrequencies = generateFrequencies(newCount, this.geqFreqRange.min, this.geqFreqRange.max);
|
||||||
|
this.geqGains = equalizerSettings.interpolateGains(oldGains, newCount);
|
||||||
|
|
||||||
|
equalizerSettings.setGraphicEqBandCount(newCount);
|
||||||
|
equalizerSettings.setGraphicEqGains(this.geqGains);
|
||||||
|
|
||||||
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyGraphicEQ();
|
||||||
|
this._createGraphicEQ();
|
||||||
|
this._connectGraph();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setGraphicEqFreqRange(minFreq, maxFreq) {
|
||||||
|
const newMin = Math.max(10, Math.min(96000, parseInt(minFreq, 10) || 25));
|
||||||
|
const newMax = Math.max(10, Math.min(96000, parseInt(maxFreq, 10) || 20000));
|
||||||
|
if (newMin >= newMax) return;
|
||||||
|
if (newMin === this.geqFreqRange.min && newMax === this.geqFreqRange.max) return;
|
||||||
|
|
||||||
|
this.geqFreqRange = { min: newMin, max: newMax };
|
||||||
|
this.geqFrequencies = generateFrequencies(this.geqBandCount, newMin, newMax);
|
||||||
|
|
||||||
|
equalizerSettings.setGraphicEqFreqRange(newMin, newMax);
|
||||||
|
|
||||||
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyGraphicEQ();
|
||||||
|
this._createGraphicEQ();
|
||||||
|
this._connectGraph();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getGraphicEqFrequencies() {
|
||||||
|
return this.geqFrequencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGraphicEqBandCount() {
|
||||||
|
return this.geqBandCount;
|
||||||
|
}
|
||||||
|
|
||||||
setGraphicEqPreamp(db) {
|
setGraphicEqPreamp(db) {
|
||||||
this.geqPreamp = Math.max(-20, Math.min(20, parseFloat(db) || 0));
|
this.geqPreamp = Math.max(-20, Math.min(20, parseFloat(db) || 0));
|
||||||
if (this.geqPreampNode && this.audioContext) {
|
if (this.geqPreampNode && this.audioContext) {
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@ function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
|
||||||
const w = (2 * PI * band.freq) / sr;
|
const w = (2 * PI * band.freq) / sr;
|
||||||
const p = (2 * PI * f) / sr;
|
const p = (2 * PI * f) / sr;
|
||||||
const t = band.type[0];
|
const t = band.type[0];
|
||||||
// WebAudio ignores Q for shelf filters; use 1/√2 (slope = 1) to match
|
const effectiveQ = band.q;
|
||||||
const effectiveQ = t === 'l' || t === 'h' ? Math.SQRT1_2 : band.q;
|
|
||||||
const s = Math.sin(w) / (2 * effectiveQ);
|
const s = Math.sin(w) / (2 * effectiveQ);
|
||||||
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
|
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
|
||||||
const c = Math.cos(w);
|
const c = Math.cos(w);
|
||||||
|
|
@ -244,7 +243,7 @@ function runAutoEqAlgorithm(
|
||||||
if (peakFreq > 5000 && q > 3.0) q = 3.0;
|
if (peakFreq > 5000 && q > 3.0) q = 3.0;
|
||||||
if (gain > 0 && q > 2.0) q = 2.0;
|
if (gain > 0 && q > 2.0) q = 2.0;
|
||||||
|
|
||||||
const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true };
|
const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true, channel: 'stereo' };
|
||||||
|
|
||||||
// Check cumulative gain at the peak frequency across all existing bands + this one
|
// Check cumulative gain at the peak frequency across all existing bands + this one
|
||||||
let cumulativeGain = gain;
|
let cumulativeGain = gain;
|
||||||
|
|
|
||||||
746
js/binaural-dsp.js
Normal file
746
js/binaural-dsp.js
Normal file
|
|
@ -0,0 +1,746 @@
|
||||||
|
// js/binaural-dsp.js
|
||||||
|
// Binaural DSP engine: multichannel HRTF rendering, crossfeed, and stereo widening.
|
||||||
|
// Placed before EQ in the audio chain.
|
||||||
|
|
||||||
|
import { generateHRTFSet, HRTF_PRESETS, CHANNEL_ANGLES_51 } from './hrtf-generator.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crossfeed presets (Bauer bs2b-style)
|
||||||
|
*/
|
||||||
|
const CROSSFEED_PRESETS = {
|
||||||
|
low: { cutoff: 500, crossGainDb: -6, delayMs: 0.2 },
|
||||||
|
medium: { cutoff: 700, crossGainDb: -4.5, delayMs: 0.3 },
|
||||||
|
high: { cutoff: 1000, crossGainDb: -3, delayMs: 0.4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BinauralDSP {
|
||||||
|
/**
|
||||||
|
* @param {AudioContext} audioContext
|
||||||
|
*/
|
||||||
|
constructor(audioContext) {
|
||||||
|
this.ctx = audioContext;
|
||||||
|
this.enabled = false;
|
||||||
|
this.mode = 'stereo'; // 'stereo' | 'multichannel'
|
||||||
|
this.channelCount = 2;
|
||||||
|
|
||||||
|
// Sub-feature states
|
||||||
|
this.crossfeedEnabled = true;
|
||||||
|
this.crossfeedLevel = 'medium';
|
||||||
|
this.hrtfPreset = 'studio';
|
||||||
|
this.wideningEnabled = true;
|
||||||
|
this.wideningAmount = 1.0;
|
||||||
|
|
||||||
|
// Graph nodes (created lazily)
|
||||||
|
this.inputNode = this.ctx.createGain();
|
||||||
|
this.outputNode = this.ctx.createGain();
|
||||||
|
this.bypassNode = this.ctx.createGain(); // direct path when disabled
|
||||||
|
|
||||||
|
// Crossfeed nodes
|
||||||
|
this._cfSplitter = null;
|
||||||
|
this._cfMerger = null;
|
||||||
|
this._cfDirectL = null;
|
||||||
|
this._cfDirectR = null;
|
||||||
|
this._cfCrossLR = null; // L → R cross path
|
||||||
|
this._cfCrossRL = null; // R → L cross path
|
||||||
|
this._cfFilterLR = null;
|
||||||
|
this._cfFilterRL = null;
|
||||||
|
this._cfDelayLR = null;
|
||||||
|
this._cfDelayRL = null;
|
||||||
|
this._cfOutputNode = null;
|
||||||
|
|
||||||
|
// Multichannel HRTF nodes
|
||||||
|
this._mcSplitter = null;
|
||||||
|
this._mcMerger = null;
|
||||||
|
this._mcConvolversL = []; // per-channel left-ear convolvers
|
||||||
|
this._mcConvolversR = []; // per-channel right-ear convolvers
|
||||||
|
this._mcLfeGain = null;
|
||||||
|
this._mcOutputNode = null;
|
||||||
|
this._hrtfBuffers = null; // Map from generateHRTFSet
|
||||||
|
|
||||||
|
// Stereo widener nodes
|
||||||
|
this._wSplitter = null;
|
||||||
|
this._wMerger = null;
|
||||||
|
this._wMidL = null;
|
||||||
|
this._wMidR = null;
|
||||||
|
this._wSideL = null;
|
||||||
|
this._wSideR = null;
|
||||||
|
this._wMidGain = null;
|
||||||
|
this._wSideGain = null;
|
||||||
|
this._wMidMix = null;
|
||||||
|
this._wSideMix = null;
|
||||||
|
this._wDecoderMidToL = null;
|
||||||
|
this._wDecoderSideToL = null;
|
||||||
|
this._wDecoderMidToR = null;
|
||||||
|
this._wDecoderSideToR = null;
|
||||||
|
this._wLMix = null;
|
||||||
|
this._wRMix = null;
|
||||||
|
this._wOutputMerger = null;
|
||||||
|
this._wOutputNode = null;
|
||||||
|
|
||||||
|
// Initialize the internal bypass connection
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the input/output nodes for graph insertion.
|
||||||
|
*/
|
||||||
|
getNodes() {
|
||||||
|
return { input: this.inputNode, output: this.outputNode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect internal graph (public API for external callers).
|
||||||
|
*/
|
||||||
|
reconnect() {
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect internal graph based on current state.
|
||||||
|
*/
|
||||||
|
_connectInternal() {
|
||||||
|
this._disconnectAll();
|
||||||
|
|
||||||
|
if (!this.enabled) {
|
||||||
|
// Bypass: input → output directly
|
||||||
|
this.inputNode.connect(this.outputNode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode === 'multichannel' && this._mcOutputNode) {
|
||||||
|
this._connectMultichannelPath();
|
||||||
|
} else {
|
||||||
|
this._connectStereoPath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect the stereo processing path: crossfeed → widener → output
|
||||||
|
*/
|
||||||
|
_connectStereoPath() {
|
||||||
|
let lastNode = this.inputNode;
|
||||||
|
|
||||||
|
if (this.crossfeedEnabled && this._cfOutputNode) {
|
||||||
|
lastNode.connect(this._cfSplitter);
|
||||||
|
|
||||||
|
// Direct paths
|
||||||
|
this._cfSplitter.connect(this._cfDirectL, 0);
|
||||||
|
this._cfSplitter.connect(this._cfDirectR, 1);
|
||||||
|
|
||||||
|
// Cross paths: L → R
|
||||||
|
this._cfSplitter.connect(this._cfFilterLR, 0);
|
||||||
|
this._cfFilterLR.connect(this._cfDelayLR);
|
||||||
|
this._cfDelayLR.connect(this._cfCrossLR);
|
||||||
|
|
||||||
|
// Cross paths: R → L
|
||||||
|
this._cfSplitter.connect(this._cfFilterRL, 1);
|
||||||
|
this._cfFilterRL.connect(this._cfDelayRL);
|
||||||
|
this._cfDelayRL.connect(this._cfCrossRL);
|
||||||
|
|
||||||
|
// Merge: L channel = directL + crossRL, R channel = directR + crossLR
|
||||||
|
this._cfDirectL.connect(this._cfMerger, 0, 0);
|
||||||
|
this._cfCrossRL.connect(this._cfMerger, 0, 0);
|
||||||
|
this._cfDirectR.connect(this._cfMerger, 0, 1);
|
||||||
|
this._cfCrossLR.connect(this._cfMerger, 0, 1);
|
||||||
|
|
||||||
|
this._cfMerger.connect(this._cfOutputNode);
|
||||||
|
lastNode = this._cfOutputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.wideningEnabled && this._wOutputNode) {
|
||||||
|
this._connectWidener(lastNode);
|
||||||
|
lastNode = this._wOutputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastNode.connect(this.outputNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect the multichannel HRTF rendering path: splitter → per-ch HRTF → merger → widener → output
|
||||||
|
*/
|
||||||
|
_connectMultichannelPath() {
|
||||||
|
// Input must pass multichannel through
|
||||||
|
this.inputNode.channelCount = this.channelCount;
|
||||||
|
this.inputNode.channelCountMode = 'max';
|
||||||
|
this.inputNode.channelInterpretation = 'discrete';
|
||||||
|
|
||||||
|
this.inputNode.connect(this._mcSplitter);
|
||||||
|
|
||||||
|
const numChannels = Math.min(this.channelCount, CHANNEL_ANGLES_51.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < numChannels; i++) {
|
||||||
|
const chInfo = CHANNEL_ANGLES_51[i];
|
||||||
|
|
||||||
|
if (chInfo.isLFE) {
|
||||||
|
// LFE: direct mix to both ears at reduced level
|
||||||
|
this._mcSplitter.connect(this._mcLfeGain, i);
|
||||||
|
this._mcLfeGain.connect(this._mcMerger, 0, 0);
|
||||||
|
this._mcLfeGain.connect(this._mcMerger, 0, 1);
|
||||||
|
} else {
|
||||||
|
// HRTF convolution: split to left and right ear convolvers
|
||||||
|
this._mcSplitter.connect(this._mcConvolversL[i], i);
|
||||||
|
this._mcSplitter.connect(this._mcConvolversR[i], i);
|
||||||
|
this._mcConvolversL[i].connect(this._mcMerger, 0, 0); // left ear
|
||||||
|
this._mcConvolversR[i].connect(this._mcMerger, 0, 1); // right ear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._mcMerger.connect(this._mcOutputNode);
|
||||||
|
let lastNode = this._mcOutputNode;
|
||||||
|
|
||||||
|
if (this.wideningEnabled && this._wOutputNode) {
|
||||||
|
this._connectWidener(lastNode);
|
||||||
|
lastNode = this._wOutputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastNode.connect(this.outputNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect the stereo widener from a source node.
|
||||||
|
*/
|
||||||
|
_connectWidener(sourceNode) {
|
||||||
|
sourceNode.connect(this._wSplitter);
|
||||||
|
|
||||||
|
// Encode L/R → M/S
|
||||||
|
this._wSplitter.connect(this._wMidL, 0);
|
||||||
|
this._wSplitter.connect(this._wMidR, 1);
|
||||||
|
this._wMidL.connect(this._wMidMix);
|
||||||
|
this._wMidR.connect(this._wMidMix);
|
||||||
|
|
||||||
|
this._wSplitter.connect(this._wSideL, 0);
|
||||||
|
this._wSplitter.connect(this._wSideR, 1);
|
||||||
|
this._wSideL.connect(this._wSideMix);
|
||||||
|
this._wSideR.connect(this._wSideMix);
|
||||||
|
|
||||||
|
// Apply width gains
|
||||||
|
this._wMidMix.connect(this._wMidGain);
|
||||||
|
this._wSideMix.connect(this._wSideGain);
|
||||||
|
|
||||||
|
// Decode M/S → L/R
|
||||||
|
this._wMidGain.connect(this._wDecoderMidToL);
|
||||||
|
this._wSideGain.connect(this._wDecoderSideToL);
|
||||||
|
this._wDecoderMidToL.connect(this._wLMix);
|
||||||
|
this._wDecoderSideToL.connect(this._wLMix);
|
||||||
|
|
||||||
|
this._wMidGain.connect(this._wDecoderMidToR);
|
||||||
|
this._wSideGain.connect(this._wDecoderSideToR);
|
||||||
|
this._wDecoderMidToR.connect(this._wRMix);
|
||||||
|
this._wDecoderSideToR.connect(this._wRMix);
|
||||||
|
|
||||||
|
// Merge L/R back to stereo
|
||||||
|
this._wLMix.connect(this._wOutputMerger, 0, 0);
|
||||||
|
this._wRMix.connect(this._wOutputMerger, 0, 1);
|
||||||
|
this._wOutputMerger.connect(this._wOutputNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect all internal nodes safely.
|
||||||
|
*/
|
||||||
|
_disconnectAll() {
|
||||||
|
const sd = (node) => {
|
||||||
|
try {
|
||||||
|
node?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sd(this.inputNode);
|
||||||
|
sd(this.bypassNode);
|
||||||
|
|
||||||
|
// Crossfeed
|
||||||
|
sd(this._cfSplitter);
|
||||||
|
sd(this._cfMerger);
|
||||||
|
sd(this._cfDirectL);
|
||||||
|
sd(this._cfDirectR);
|
||||||
|
sd(this._cfCrossLR);
|
||||||
|
sd(this._cfCrossRL);
|
||||||
|
sd(this._cfFilterLR);
|
||||||
|
sd(this._cfFilterRL);
|
||||||
|
sd(this._cfDelayLR);
|
||||||
|
sd(this._cfDelayRL);
|
||||||
|
sd(this._cfOutputNode);
|
||||||
|
|
||||||
|
// Multichannel
|
||||||
|
sd(this._mcSplitter);
|
||||||
|
sd(this._mcMerger);
|
||||||
|
sd(this._mcLfeGain);
|
||||||
|
this._mcConvolversL.forEach(sd);
|
||||||
|
this._mcConvolversR.forEach(sd);
|
||||||
|
sd(this._mcOutputNode);
|
||||||
|
|
||||||
|
// Widener
|
||||||
|
sd(this._wSplitter);
|
||||||
|
sd(this._wMerger);
|
||||||
|
sd(this._wMidL);
|
||||||
|
sd(this._wMidR);
|
||||||
|
sd(this._wSideL);
|
||||||
|
sd(this._wSideR);
|
||||||
|
sd(this._wMidGain);
|
||||||
|
sd(this._wSideGain);
|
||||||
|
sd(this._wMidMix);
|
||||||
|
sd(this._wSideMix);
|
||||||
|
sd(this._wDecoderMidToL);
|
||||||
|
sd(this._wDecoderSideToL);
|
||||||
|
sd(this._wDecoderMidToR);
|
||||||
|
sd(this._wDecoderSideToR);
|
||||||
|
sd(this._wLMix);
|
||||||
|
sd(this._wRMix);
|
||||||
|
sd(this._wOutputMerger);
|
||||||
|
sd(this._wOutputNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Crossfeed creation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
_createCrossfeedNodes() {
|
||||||
|
const preset = CROSSFEED_PRESETS[this.crossfeedLevel] || CROSSFEED_PRESETS.medium;
|
||||||
|
const crossGain = Math.pow(10, preset.crossGainDb / 20);
|
||||||
|
const directGain = 1.0 - crossGain * 0.5; // Slightly reduce direct to compensate
|
||||||
|
|
||||||
|
this._cfSplitter = this.ctx.createChannelSplitter(2);
|
||||||
|
this._cfMerger = this.ctx.createChannelMerger(2);
|
||||||
|
|
||||||
|
// Direct paths
|
||||||
|
this._cfDirectL = this.ctx.createGain();
|
||||||
|
this._cfDirectL.gain.value = directGain;
|
||||||
|
this._cfDirectL.channelCount = 1;
|
||||||
|
this._cfDirectL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._cfDirectR = this.ctx.createGain();
|
||||||
|
this._cfDirectR.gain.value = directGain;
|
||||||
|
this._cfDirectR.channelCount = 1;
|
||||||
|
this._cfDirectR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Cross paths: L → R
|
||||||
|
this._cfFilterLR = this.ctx.createBiquadFilter();
|
||||||
|
this._cfFilterLR.type = 'lowpass';
|
||||||
|
this._cfFilterLR.frequency.value = preset.cutoff;
|
||||||
|
this._cfFilterLR.Q.value = 0.707;
|
||||||
|
this._cfFilterLR.channelCount = 1;
|
||||||
|
this._cfFilterLR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._cfDelayLR = this.ctx.createDelay(0.01);
|
||||||
|
this._cfDelayLR.delayTime.value = preset.delayMs / 1000;
|
||||||
|
|
||||||
|
this._cfCrossLR = this.ctx.createGain();
|
||||||
|
this._cfCrossLR.gain.value = crossGain;
|
||||||
|
this._cfCrossLR.channelCount = 1;
|
||||||
|
this._cfCrossLR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Cross paths: R → L
|
||||||
|
this._cfFilterRL = this.ctx.createBiquadFilter();
|
||||||
|
this._cfFilterRL.type = 'lowpass';
|
||||||
|
this._cfFilterRL.frequency.value = preset.cutoff;
|
||||||
|
this._cfFilterRL.Q.value = 0.707;
|
||||||
|
this._cfFilterRL.channelCount = 1;
|
||||||
|
this._cfFilterRL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._cfDelayRL = this.ctx.createDelay(0.01);
|
||||||
|
this._cfDelayRL.delayTime.value = preset.delayMs / 1000;
|
||||||
|
|
||||||
|
this._cfCrossRL = this.ctx.createGain();
|
||||||
|
this._cfCrossRL.gain.value = crossGain;
|
||||||
|
this._cfCrossRL.channelCount = 1;
|
||||||
|
this._cfCrossRL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._cfOutputNode = this.ctx.createGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
_destroyCrossfeedNodes() {
|
||||||
|
const nodes = [
|
||||||
|
this._cfSplitter,
|
||||||
|
this._cfMerger,
|
||||||
|
this._cfDirectL,
|
||||||
|
this._cfDirectR,
|
||||||
|
this._cfCrossLR,
|
||||||
|
this._cfCrossRL,
|
||||||
|
this._cfFilterLR,
|
||||||
|
this._cfFilterRL,
|
||||||
|
this._cfDelayLR,
|
||||||
|
this._cfDelayRL,
|
||||||
|
this._cfOutputNode,
|
||||||
|
];
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
try {
|
||||||
|
n?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._cfSplitter = null;
|
||||||
|
this._cfMerger = null;
|
||||||
|
this._cfDirectL = null;
|
||||||
|
this._cfDirectR = null;
|
||||||
|
this._cfCrossLR = null;
|
||||||
|
this._cfCrossRL = null;
|
||||||
|
this._cfFilterLR = null;
|
||||||
|
this._cfFilterRL = null;
|
||||||
|
this._cfDelayLR = null;
|
||||||
|
this._cfDelayRL = null;
|
||||||
|
this._cfOutputNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Multichannel HRTF creation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async _createMultichannelNodes() {
|
||||||
|
const numChannels = Math.min(this.channelCount, CHANNEL_ANGLES_51.length);
|
||||||
|
|
||||||
|
this._mcSplitter = this.ctx.createChannelSplitter(numChannels);
|
||||||
|
this._mcMerger = this.ctx.createChannelMerger(2); // binaural output
|
||||||
|
|
||||||
|
this._mcLfeGain = this.ctx.createGain();
|
||||||
|
this._mcLfeGain.gain.value = 0.5;
|
||||||
|
this._mcLfeGain.channelCount = 1;
|
||||||
|
this._mcLfeGain.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Generate HRTF impulse responses
|
||||||
|
if (!this._hrtfBuffers || this._hrtfBuffers._preset !== this.hrtfPreset) {
|
||||||
|
this._hrtfBuffers = await generateHRTFSet(this.ctx, this.hrtfPreset);
|
||||||
|
this._hrtfBuffers._preset = this.hrtfPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._mcConvolversL = [];
|
||||||
|
this._mcConvolversR = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numChannels; i++) {
|
||||||
|
const chInfo = CHANNEL_ANGLES_51[i];
|
||||||
|
if (chInfo.isLFE) {
|
||||||
|
// Placeholder - LFE uses gain node instead
|
||||||
|
this._mcConvolversL.push(null);
|
||||||
|
this._mcConvolversR.push(null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hrtf = this._hrtfBuffers.get(i);
|
||||||
|
|
||||||
|
const convL = this.ctx.createConvolver();
|
||||||
|
convL.normalize = false;
|
||||||
|
convL.buffer = hrtf.left;
|
||||||
|
convL.channelCount = 1;
|
||||||
|
convL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
const convR = this.ctx.createConvolver();
|
||||||
|
convR.normalize = false;
|
||||||
|
convR.buffer = hrtf.right;
|
||||||
|
convR.channelCount = 1;
|
||||||
|
convR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._mcConvolversL.push(convL);
|
||||||
|
this._mcConvolversR.push(convR);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._mcOutputNode = this.ctx.createGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
_destroyMultichannelNodes() {
|
||||||
|
const sd = (n) => {
|
||||||
|
try {
|
||||||
|
n?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sd(this._mcSplitter);
|
||||||
|
sd(this._mcMerger);
|
||||||
|
sd(this._mcLfeGain);
|
||||||
|
this._mcConvolversL.forEach(sd);
|
||||||
|
this._mcConvolversR.forEach(sd);
|
||||||
|
sd(this._mcOutputNode);
|
||||||
|
|
||||||
|
this._mcSplitter = null;
|
||||||
|
this._mcMerger = null;
|
||||||
|
this._mcLfeGain = null;
|
||||||
|
this._mcConvolversL = [];
|
||||||
|
this._mcConvolversR = [];
|
||||||
|
this._mcOutputNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Stereo widener creation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
_createWidenerNodes() {
|
||||||
|
this._wSplitter = this.ctx.createChannelSplitter(2);
|
||||||
|
this._wOutputMerger = this.ctx.createChannelMerger(2);
|
||||||
|
|
||||||
|
// M/S encoder gains
|
||||||
|
this._wMidL = this.ctx.createGain();
|
||||||
|
this._wMidL.gain.value = 0.5;
|
||||||
|
this._wMidL.channelCount = 1;
|
||||||
|
this._wMidL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._wMidR = this.ctx.createGain();
|
||||||
|
this._wMidR.gain.value = 0.5;
|
||||||
|
this._wMidR.channelCount = 1;
|
||||||
|
this._wMidR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._wSideL = this.ctx.createGain();
|
||||||
|
this._wSideL.gain.value = 0.5;
|
||||||
|
this._wSideL.channelCount = 1;
|
||||||
|
this._wSideL.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._wSideR = this.ctx.createGain();
|
||||||
|
this._wSideR.gain.value = -0.5;
|
||||||
|
this._wSideR.channelCount = 1;
|
||||||
|
this._wSideR.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Mono mix points
|
||||||
|
this._wMidMix = this.ctx.createGain();
|
||||||
|
this._wMidMix.channelCount = 1;
|
||||||
|
this._wMidMix.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._wSideMix = this.ctx.createGain();
|
||||||
|
this._wSideMix.channelCount = 1;
|
||||||
|
this._wSideMix.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
// Width control: mid and side gains
|
||||||
|
this._wMidGain = this.ctx.createGain();
|
||||||
|
this._wMidGain.gain.value = this._calcMidGain();
|
||||||
|
this._wSideGain = this.ctx.createGain();
|
||||||
|
this._wSideGain.gain.value = this._calcSideGain();
|
||||||
|
|
||||||
|
// M/S decoder
|
||||||
|
this._wDecoderMidToL = this.ctx.createGain();
|
||||||
|
this._wDecoderMidToL.gain.value = 1.0;
|
||||||
|
this._wDecoderSideToL = this.ctx.createGain();
|
||||||
|
this._wDecoderSideToL.gain.value = 1.0;
|
||||||
|
this._wDecoderMidToR = this.ctx.createGain();
|
||||||
|
this._wDecoderMidToR.gain.value = 1.0;
|
||||||
|
this._wDecoderSideToR = this.ctx.createGain();
|
||||||
|
this._wDecoderSideToR.gain.value = -1.0;
|
||||||
|
|
||||||
|
// L/R recombination
|
||||||
|
this._wLMix = this.ctx.createGain();
|
||||||
|
this._wLMix.channelCount = 1;
|
||||||
|
this._wLMix.channelCountMode = 'explicit';
|
||||||
|
this._wRMix = this.ctx.createGain();
|
||||||
|
this._wRMix.channelCount = 1;
|
||||||
|
this._wRMix.channelCountMode = 'explicit';
|
||||||
|
|
||||||
|
this._wOutputNode = this.ctx.createGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
_destroyWidenerNodes() {
|
||||||
|
const nodes = [
|
||||||
|
this._wSplitter,
|
||||||
|
this._wOutputMerger,
|
||||||
|
this._wMidL,
|
||||||
|
this._wMidR,
|
||||||
|
this._wSideL,
|
||||||
|
this._wSideR,
|
||||||
|
this._wMidGain,
|
||||||
|
this._wSideGain,
|
||||||
|
this._wMidMix,
|
||||||
|
this._wSideMix,
|
||||||
|
this._wDecoderMidToL,
|
||||||
|
this._wDecoderSideToL,
|
||||||
|
this._wDecoderMidToR,
|
||||||
|
this._wDecoderSideToR,
|
||||||
|
this._wLMix,
|
||||||
|
this._wRMix,
|
||||||
|
this._wOutputNode,
|
||||||
|
];
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
try {
|
||||||
|
n?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._wSplitter = null;
|
||||||
|
this._wOutputMerger = null;
|
||||||
|
this._wMidL = null;
|
||||||
|
this._wMidR = null;
|
||||||
|
this._wSideL = null;
|
||||||
|
this._wSideR = null;
|
||||||
|
this._wMidGain = null;
|
||||||
|
this._wSideGain = null;
|
||||||
|
this._wMidMix = null;
|
||||||
|
this._wSideMix = null;
|
||||||
|
this._wDecoderMidToL = null;
|
||||||
|
this._wDecoderSideToL = null;
|
||||||
|
this._wDecoderMidToR = null;
|
||||||
|
this._wDecoderSideToR = null;
|
||||||
|
this._wLMix = null;
|
||||||
|
this._wRMix = null;
|
||||||
|
this._wOutputNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_calcMidGain() {
|
||||||
|
// At amount=1.0, mid=1.0; at amount=2.0, mid~0.6; at amount=0, mid=2.0
|
||||||
|
return 2.0 - this.wideningAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
_calcSideGain() {
|
||||||
|
return this.wideningAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Public API
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable the entire binaural DSP block.
|
||||||
|
*/
|
||||||
|
async setEnabled(enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
if (enabled) {
|
||||||
|
await this._ensureNodesCreated();
|
||||||
|
}
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect channel count and configure mode accordingly.
|
||||||
|
* Call this when source changes or track starts playing.
|
||||||
|
* @param {number} channelCount - Number of channels in the source
|
||||||
|
*/
|
||||||
|
async detectAndConfigure(channelCount) {
|
||||||
|
const prevMode = this.mode;
|
||||||
|
const prevChannels = this.channelCount;
|
||||||
|
this.channelCount = channelCount;
|
||||||
|
|
||||||
|
if (channelCount > 2) {
|
||||||
|
this.mode = 'multichannel';
|
||||||
|
} else {
|
||||||
|
this.mode = 'stereo';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.enabled && (this.mode !== prevMode || channelCount !== prevChannels)) {
|
||||||
|
await this._ensureNodesCreated();
|
||||||
|
this._connectInternal();
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('binaural-mode-changed', {
|
||||||
|
detail: { mode: this.mode, channels: this.channelCount },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set crossfeed level.
|
||||||
|
* @param {'low'|'medium'|'high'} level
|
||||||
|
*/
|
||||||
|
setCrossfeedLevel(level) {
|
||||||
|
if (!CROSSFEED_PRESETS[level]) return;
|
||||||
|
this.crossfeedLevel = level;
|
||||||
|
|
||||||
|
// Update existing crossfeed nodes if they exist
|
||||||
|
if (this._cfFilterLR) {
|
||||||
|
const preset = CROSSFEED_PRESETS[level];
|
||||||
|
const crossGain = Math.pow(10, preset.crossGainDb / 20);
|
||||||
|
const directGain = 1.0 - crossGain * 0.5;
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
|
||||||
|
this._cfFilterLR.frequency.setTargetAtTime(preset.cutoff, now, 0.005);
|
||||||
|
this._cfFilterRL.frequency.setTargetAtTime(preset.cutoff, now, 0.005);
|
||||||
|
this._cfDelayLR.delayTime.setTargetAtTime(preset.delayMs / 1000, now, 0.005);
|
||||||
|
this._cfDelayRL.delayTime.setTargetAtTime(preset.delayMs / 1000, now, 0.005);
|
||||||
|
this._cfCrossLR.gain.setTargetAtTime(crossGain, now, 0.005);
|
||||||
|
this._cfCrossRL.gain.setTargetAtTime(crossGain, now, 0.005);
|
||||||
|
this._cfDirectL.gain.setTargetAtTime(directGain, now, 0.005);
|
||||||
|
this._cfDirectR.gain.setTargetAtTime(directGain, now, 0.005);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable crossfeed sub-feature.
|
||||||
|
*/
|
||||||
|
async setCrossfeedEnabled(enabled) {
|
||||||
|
this.crossfeedEnabled = enabled;
|
||||||
|
if (this.enabled) {
|
||||||
|
await this._ensureNodesCreated();
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set HRTF preset (changes virtual speaker angles).
|
||||||
|
* @param {'intimate'|'studio'|'wide'} preset
|
||||||
|
*/
|
||||||
|
async setHrtfPreset(preset) {
|
||||||
|
if (!HRTF_PRESETS[preset]) return;
|
||||||
|
this.hrtfPreset = preset;
|
||||||
|
|
||||||
|
if (this.enabled && this.mode === 'multichannel') {
|
||||||
|
// Regenerate HRTF buffers with new angles
|
||||||
|
this._destroyMultichannelNodes();
|
||||||
|
await this._createMultichannelNodes();
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stereo widening amount.
|
||||||
|
* @param {number} amount - 0.0 (mono) to 2.0 (extra wide), 1.0 = neutral
|
||||||
|
*/
|
||||||
|
setWideningAmount(amount) {
|
||||||
|
this.wideningAmount = Math.max(0, Math.min(2, amount));
|
||||||
|
|
||||||
|
if (this._wMidGain && this._wSideGain) {
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
this._wMidGain.gain.setTargetAtTime(this._calcMidGain(), now, 0.005);
|
||||||
|
this._wSideGain.gain.setTargetAtTime(this._calcSideGain(), now, 0.005);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable stereo widening sub-feature.
|
||||||
|
*/
|
||||||
|
async setWideningEnabled(enabled) {
|
||||||
|
this.wideningEnabled = enabled;
|
||||||
|
if (this.enabled) {
|
||||||
|
await this._ensureNodesCreated();
|
||||||
|
this._connectInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure all required nodes are created for the current mode.
|
||||||
|
*/
|
||||||
|
async _ensureNodesCreated() {
|
||||||
|
// Always create widener and crossfeed nodes
|
||||||
|
if (!this._cfOutputNode && this.crossfeedEnabled) {
|
||||||
|
this._createCrossfeedNodes();
|
||||||
|
}
|
||||||
|
if (!this._wOutputNode && this.wideningEnabled) {
|
||||||
|
this._createWidenerNodes();
|
||||||
|
}
|
||||||
|
if (this.mode === 'multichannel' && !this._mcOutputNode) {
|
||||||
|
await this._createMultichannelNodes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current processing mode info.
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
enabled: this.enabled,
|
||||||
|
mode: this.mode,
|
||||||
|
channels: this.channelCount,
|
||||||
|
crossfeed: { enabled: this.crossfeedEnabled, level: this.crossfeedLevel },
|
||||||
|
hrtfPreset: this.hrtfPreset,
|
||||||
|
widening: { enabled: this.wideningEnabled, amount: this.wideningAmount },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy all nodes and clean up.
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this._disconnectAll();
|
||||||
|
this._destroyCrossfeedNodes();
|
||||||
|
this._destroyMultichannelNodes();
|
||||||
|
this._destroyWidenerNodes();
|
||||||
|
this._hrtfBuffers = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CROSSFEED_PRESETS, HRTF_PRESETS };
|
||||||
|
|
@ -499,7 +499,7 @@ async function bulkDownload({
|
||||||
* to the configured folder (Local Media Folder or saved Folder Picker handle),
|
* to the configured folder (Local Media Folder or saved Folder Picker handle),
|
||||||
* or `null` if the feature is not active / no folder is configured.
|
* or `null` if the feature is not active / no folder is configured.
|
||||||
*
|
*
|
||||||
* In contrast to {@link createBulkWriter}, this never prompts the user – it
|
* In contrast to {@link createBulkWriter}, this never prompts the user - it
|
||||||
* only succeeds when the folder is already known.
|
* only succeeds when the folder is already known.
|
||||||
*/
|
*/
|
||||||
async function createSingleTrackFolderWriter() {
|
async function createSingleTrackFolderWriter() {
|
||||||
|
|
@ -533,7 +533,7 @@ async function createSingleTrackFolderWriter() {
|
||||||
// fall through to picker
|
// fall through to picker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No usable saved handle – open the picker so the user can choose a folder.
|
// No usable saved handle - open the picker so the user can choose a folder.
|
||||||
try {
|
try {
|
||||||
const writer = await FolderPickerWriter.create();
|
const writer = await FolderPickerWriter.create();
|
||||||
if (rememberFolder) {
|
if (rememberFolder) {
|
||||||
|
|
@ -542,7 +542,7 @@ async function createSingleTrackFolderWriter() {
|
||||||
return writer;
|
return writer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
// User cancelled the picker – return null so we fall back to the
|
// User cancelled the picker - return null so we fall back to the
|
||||||
// normal browser download instead of erroring out.
|
// normal browser download instead of erroring out.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -578,7 +578,7 @@ async function createBulkWriter(folderName) {
|
||||||
// fall through to picker
|
// fall through to picker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No usable handle – prompt and persist
|
// No usable handle - prompt and persist
|
||||||
try {
|
try {
|
||||||
const writer = await FolderPickerWriter.create();
|
const writer = await FolderPickerWriter.create();
|
||||||
await db.saveSetting('local_folder_handle', writer.getDirHandle());
|
await db.saveSetting('local_folder_handle', writer.getDirHandle());
|
||||||
|
|
@ -590,7 +590,7 @@ async function createBulkWriter(folderName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Browser without File System Access API – fall through to ZIP
|
// Browser without File System Access API - fall through to ZIP
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Folder Picker method ─────────────────────────────────────────────────
|
// ── Folder Picker method ─────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ export class Equalizer {
|
||||||
this.frequencyLabels = generateFrequencyLabels(this.frequencies);
|
this.frequencyLabels = generateFrequencyLabels(this.frequencies);
|
||||||
|
|
||||||
// Interpolate current gains to new band count
|
// Interpolate current gains to new band count
|
||||||
const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount);
|
const newGains = equalizerSettings.interpolateGains(this.currentGains, newCount);
|
||||||
this.currentGains = newGains;
|
this.currentGains = newGains;
|
||||||
equalizerSettings.setGains(newGains);
|
equalizerSettings.setGains(newGains);
|
||||||
|
|
||||||
|
|
@ -455,7 +455,7 @@ export class Equalizer {
|
||||||
// Ensure gains array matches current band count
|
// Ensure gains array matches current band count
|
||||||
let adjustedGains = gains;
|
let adjustedGains = gains;
|
||||||
if (gains.length !== this.bandCount) {
|
if (gains.length !== this.bandCount) {
|
||||||
adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount);
|
adjustedGains = equalizerSettings.interpolateGains(gains, this.bandCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = this.audioContext?.currentTime || 0;
|
const now = this.audioContext?.currentTime || 0;
|
||||||
|
|
@ -621,9 +621,12 @@ 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 type = this.currentTypes[index] || 'peaking';
|
||||||
|
const typeMap = { peaking: 'PK', lowshelf: 'LSC', highshelf: 'HSC' };
|
||||||
|
const typeStr = typeMap[type] || 'PK';
|
||||||
|
const q = 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 ${q.toFixed(2)}`);
|
lines.push(`Filter ${filterNum}: ON ${typeStr} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
|
|
@ -653,13 +656,13 @@ export class Equalizer {
|
||||||
|
|
||||||
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
|
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
|
||||||
const filterMatch = line.match(
|
const filterMatch = line.match(
|
||||||
/^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB\s+Q\s+(\d+\.?\d*)/i
|
/^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB(?:\s+Q\s+(\d+\.?\d*))?/i
|
||||||
);
|
);
|
||||||
if (filterMatch) {
|
if (filterMatch) {
|
||||||
const type = filterMatch[1].toUpperCase();
|
const type = filterMatch[1].toUpperCase();
|
||||||
const freq = parseInt(filterMatch[2], 10);
|
const freq = parseInt(filterMatch[2], 10);
|
||||||
const gain = parseFloat(filterMatch[3]);
|
const gain = parseFloat(filterMatch[3]);
|
||||||
const q = parseFloat(filterMatch[4]);
|
const q = filterMatch[4] ? parseFloat(filterMatch[4]) : Math.SQRT1_2;
|
||||||
filters.push({ type, freq, gain, q });
|
filters.push({ type, freq, gain, q });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
207
js/hrtf-generator.js
Normal file
207
js/hrtf-generator.js
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
// js/hrtf-generator.js
|
||||||
|
// Procedural HRTF impulse response generation for binaural rendering.
|
||||||
|
// Synthesizes per-angle stereo IRs modeling ITD, ILD, and head shadow.
|
||||||
|
|
||||||
|
const HEAD_RADIUS = 0.0875; // meters (average human head radius)
|
||||||
|
const SPEED_OF_SOUND = 343; // m/s
|
||||||
|
const IR_LENGTH = 256; // samples
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the interaural time difference (ITD) for a given azimuth.
|
||||||
|
* Uses Woodworth's spherical head model.
|
||||||
|
* @param {number} azimuthRad - Azimuth in radians (0 = front, positive = right)
|
||||||
|
* @returns {number} ITD in seconds (positive = right ear leads)
|
||||||
|
*/
|
||||||
|
function calculateITD(azimuthRad) {
|
||||||
|
const absAz = Math.abs(azimuthRad);
|
||||||
|
if (absAz <= Math.PI / 2) {
|
||||||
|
return (HEAD_RADIUS / SPEED_OF_SOUND) * (absAz + Math.sin(absAz));
|
||||||
|
}
|
||||||
|
// Behind the head
|
||||||
|
return (HEAD_RADIUS / SPEED_OF_SOUND) * (Math.PI - absAz + Math.sin(absAz));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate frequency-dependent ILD (head shadow attenuation) for the far ear.
|
||||||
|
* Higher frequencies are attenuated more by the head.
|
||||||
|
* @param {number} frequency - Frequency in Hz
|
||||||
|
* @param {number} azimuthRad - Absolute azimuth in radians
|
||||||
|
* @returns {number} Attenuation factor (0-1) for the shadowed ear
|
||||||
|
*/
|
||||||
|
function calculateHeadShadow(frequency, azimuthRad) {
|
||||||
|
const absAz = Math.abs(azimuthRad);
|
||||||
|
if (absAz < 0.01) return 1.0; // Source in front, no shadow
|
||||||
|
|
||||||
|
// Head shadow increases with frequency and angle
|
||||||
|
// Based on simplified spherical head diffraction model
|
||||||
|
const ka = (2 * Math.PI * frequency * HEAD_RADIUS) / SPEED_OF_SOUND;
|
||||||
|
const shadowFactor = 1.0 / (1.0 + 0.5 * ka * Math.sin(absAz));
|
||||||
|
return Math.max(0.05, shadowFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single HRTF impulse response for a given azimuth angle.
|
||||||
|
* Returns a stereo AudioBuffer: channel 0 = left ear, channel 1 = right ear.
|
||||||
|
*
|
||||||
|
* @param {AudioContext} audioContext
|
||||||
|
* @param {number} azimuthDeg - Azimuth in degrees (-180 to 180, 0 = front, positive = right)
|
||||||
|
* @param {number} [elevationDeg=0] - Elevation in degrees (currently simplified)
|
||||||
|
* @returns {AudioBuffer} Stereo AudioBuffer with HRTF IR
|
||||||
|
*/
|
||||||
|
export function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) {
|
||||||
|
const sampleRate = audioContext.sampleRate;
|
||||||
|
const buffer = audioContext.createBuffer(2, IR_LENGTH, sampleRate);
|
||||||
|
|
||||||
|
const leftData = buffer.getChannelData(0);
|
||||||
|
const rightData = buffer.getChannelData(1);
|
||||||
|
|
||||||
|
const azimuthRad = (azimuthDeg * Math.PI) / 180;
|
||||||
|
const itd = calculateITD(azimuthRad);
|
||||||
|
const itdSamples = Math.round(itd * sampleRate);
|
||||||
|
|
||||||
|
// Determine which ear is ipsilateral (closer to source) and contralateral (farther)
|
||||||
|
const sourceOnRight = azimuthDeg > 0;
|
||||||
|
const ipsiData = sourceOnRight ? rightData : leftData;
|
||||||
|
const contraData = sourceOnRight ? leftData : rightData;
|
||||||
|
|
||||||
|
// Generate ipsilateral (near ear) IR - mostly a delayed impulse with slight coloring
|
||||||
|
// Ipsilateral ear (near source) receives sound first; contralateral ear is delayed by ITD
|
||||||
|
const ipsiDelay = 0;
|
||||||
|
const contraDelay = Math.abs(itdSamples);
|
||||||
|
|
||||||
|
// Create frequency-domain representation for head shadow
|
||||||
|
const fftSize = IR_LENGTH;
|
||||||
|
const halfFFT = fftSize / 2;
|
||||||
|
|
||||||
|
// Ipsilateral ear: near-flat response with slight high-frequency boost at extreme angles
|
||||||
|
for (let i = 0; i < fftSize; i++) {
|
||||||
|
const t = i / sampleRate;
|
||||||
|
let sum = 0;
|
||||||
|
for (let k = 1; k <= halfFFT; k++) {
|
||||||
|
const freq = (k * sampleRate) / fftSize;
|
||||||
|
const absAz = Math.abs(azimuthRad);
|
||||||
|
|
||||||
|
// Ipsilateral ear gets a slight boost at high frequencies for angles > 30°
|
||||||
|
let ipsiGain = 1.0;
|
||||||
|
if (absAz > 0.5 && freq > 2000) {
|
||||||
|
ipsiGain = 1.0 + 0.15 * Math.min(1, (freq - 2000) / 8000) * Math.sin(absAz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinna notch around 8-10kHz (elevation dependent)
|
||||||
|
const elevRad = (elevationDeg * Math.PI) / 180;
|
||||||
|
const notchFreq = 8000 + elevationDeg * 50; // Shifts with elevation
|
||||||
|
const notchWidth = 2000;
|
||||||
|
const notchDepth = 0.15 * Math.abs(Math.sin(elevRad + 0.3));
|
||||||
|
const notchFactor = 1.0 - notchDepth * Math.exp(-Math.pow((freq - notchFreq) / notchWidth, 2));
|
||||||
|
|
||||||
|
const phase = 2 * Math.PI * freq * (t - ipsiDelay / sampleRate);
|
||||||
|
sum += ((ipsiGain * notchFactor) / halfFFT) * Math.cos(phase);
|
||||||
|
}
|
||||||
|
ipsiData[i] = sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contralateral ear: apply head shadow (frequency-dependent attenuation)
|
||||||
|
for (let i = 0; i < fftSize; i++) {
|
||||||
|
const t = i / sampleRate;
|
||||||
|
let sum = 0;
|
||||||
|
for (let k = 1; k <= halfFFT; k++) {
|
||||||
|
const freq = (k * sampleRate) / fftSize;
|
||||||
|
const shadowGain = calculateHeadShadow(freq, azimuthRad);
|
||||||
|
|
||||||
|
const phase = 2 * Math.PI * freq * (t - contraDelay / sampleRate);
|
||||||
|
sum += (shadowGain / halfFFT) * Math.cos(phase);
|
||||||
|
}
|
||||||
|
contraData[i] = sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to prevent clipping
|
||||||
|
let maxVal = 0;
|
||||||
|
for (let i = 0; i < IR_LENGTH; i++) {
|
||||||
|
maxVal = Math.max(maxVal, Math.abs(leftData[i]), Math.abs(rightData[i]));
|
||||||
|
}
|
||||||
|
if (maxVal > 0) {
|
||||||
|
const normFactor = 0.9 / maxVal;
|
||||||
|
for (let i = 0; i < IR_LENGTH; i++) {
|
||||||
|
leftData[i] *= normFactor;
|
||||||
|
rightData[i] *= normFactor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HRTF angle presets for virtual speaker configurations.
|
||||||
|
*/
|
||||||
|
export const HRTF_PRESETS = {
|
||||||
|
intimate: { label: 'Intimate', angleScale: 0.73 }, // ±22° front
|
||||||
|
studio: { label: 'Studio', angleScale: 1.0 }, // ±30° front (standard)
|
||||||
|
wide: { label: 'Wide', angleScale: 1.5 }, // ±45° front
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard 5.1 channel angles (ITU-R BS.775)
|
||||||
|
*/
|
||||||
|
export const CHANNEL_ANGLES_51 = [
|
||||||
|
{ index: 0, name: 'FL', azimuth: -30 },
|
||||||
|
{ index: 1, name: 'FR', azimuth: 30 },
|
||||||
|
{ index: 2, name: 'C', azimuth: 0 },
|
||||||
|
{ index: 3, name: 'LFE', azimuth: 0, isLFE: true },
|
||||||
|
{ index: 4, name: 'SL', azimuth: -110 },
|
||||||
|
{ index: 5, name: 'SR', azimuth: 110 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a complete set of HRTF impulse responses for 5.1 surround.
|
||||||
|
* Each entry contains separate left-ear and right-ear mono AudioBuffers
|
||||||
|
* suitable for use with ConvolverNode.
|
||||||
|
*
|
||||||
|
* @param {AudioContext} audioContext
|
||||||
|
* @param {string} [preset='studio'] - HRTF preset name
|
||||||
|
* @returns {Promise<Map<number, {left: AudioBuffer, right: AudioBuffer, stereo: AudioBuffer}>>}
|
||||||
|
*/
|
||||||
|
export async function generateHRTFSet(audioContext, preset = 'studio') {
|
||||||
|
const presetConfig = HRTF_PRESETS[preset] || HRTF_PRESETS.studio;
|
||||||
|
const angleScale = presetConfig.angleScale;
|
||||||
|
const results = new Map();
|
||||||
|
|
||||||
|
for (const ch of CHANNEL_ANGLES_51) {
|
||||||
|
if (ch.isLFE) {
|
||||||
|
// LFE: no HRTF, just pass through equally to both ears
|
||||||
|
const lfeBuffer = audioContext.createBuffer(2, IR_LENGTH, audioContext.sampleRate);
|
||||||
|
const lfeL = lfeBuffer.getChannelData(0);
|
||||||
|
const lfeR = lfeBuffer.getChannelData(1);
|
||||||
|
// Simple impulse at sample 0
|
||||||
|
lfeL[0] = 0.5;
|
||||||
|
lfeR[0] = 0.5;
|
||||||
|
results.set(ch.index, {
|
||||||
|
stereo: lfeBuffer,
|
||||||
|
left: extractChannel(audioContext, lfeBuffer, 0),
|
||||||
|
right: extractChannel(audioContext, lfeBuffer, 1),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale angle by preset
|
||||||
|
const scaledAzimuth = ch.azimuth * angleScale;
|
||||||
|
const stereoBuffer = await generateHRTF(audioContext, scaledAzimuth);
|
||||||
|
|
||||||
|
results.set(ch.index, {
|
||||||
|
stereo: stereoBuffer,
|
||||||
|
left: extractChannel(audioContext, stereoBuffer, 0),
|
||||||
|
right: extractChannel(audioContext, stereoBuffer, 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a single channel from a stereo buffer into a mono AudioBuffer.
|
||||||
|
* ConvolverNode requires the IR buffer channel count to match input or be mono.
|
||||||
|
*/
|
||||||
|
function extractChannel(audioContext, stereoBuffer, channelIndex) {
|
||||||
|
const mono = audioContext.createBuffer(1, stereoBuffer.length, audioContext.sampleRate);
|
||||||
|
mono.copyToChannel(stereoBuffer.getChannelData(channelIndex), 0);
|
||||||
|
return mono;
|
||||||
|
}
|
||||||
|
|
@ -1080,7 +1080,7 @@ async function renderLyricsComponent(container, track, audioPlayer, lyricsManage
|
||||||
const artist = getTrackArtists(track);
|
const artist = getTrackArtists(track);
|
||||||
const album = track.album?.title;
|
const album = track.album?.title;
|
||||||
const durationMs = track.duration ? Math.round(track.duration * 1000) : undefined;
|
const durationMs = track.duration ? Math.round(track.duration * 1000) : undefined;
|
||||||
const isrc = track.isrc || '';
|
const isrc = (track.isrc || track.mediaMetadata?.isrc || track.audioQuality?.isrc || '').trim();
|
||||||
|
|
||||||
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
||||||
let queryTitle = title;
|
let queryTitle = title;
|
||||||
|
|
@ -1101,7 +1101,7 @@ async function renderLyricsComponent(container, track, audioPlayer, lyricsManage
|
||||||
if (isrc) amLyrics.setAttribute('isrc', isrc);
|
if (isrc) amLyrics.setAttribute('isrc', isrc);
|
||||||
|
|
||||||
amLyrics.setAttribute('highlight-color', getLyricsHighlightColor());
|
amLyrics.setAttribute('highlight-color', getLyricsHighlightColor());
|
||||||
amLyrics.setAttribute('hover-background-color', 'rgba(59, 130, 246, 0.14)');
|
amLyrics.setAttribute('hover-background-color', 'color-mix(in srgb, var(--primary) 16%, transparent)');
|
||||||
amLyrics.setAttribute('autoscroll', '');
|
amLyrics.setAttribute('autoscroll', '');
|
||||||
amLyrics.setAttribute('interpolate', '');
|
amLyrics.setAttribute('interpolate', '');
|
||||||
amLyrics.style.height = '100%';
|
amLyrics.style.height = '100%';
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,47 @@ export class MusicAPI {
|
||||||
return this.tidalAPI.getArtistPictureSrcset(this.stripProviderPrefix(id));
|
return this.tidalAPI.getArtistPictureSrcset(this.stripProviderPrefix(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getArtistBanner(artistName) {
|
||||||
|
const cacheKey = `banner-${artistName}`.toLowerCase();
|
||||||
|
if (this.videoArtworkCache.has(cacheKey)) {
|
||||||
|
return this.videoArtworkCache.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://artwork-boidu-dev.samidy.workers.dev/artist?a=${encodeURIComponent(artistName)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
let hlsUrl = null;
|
||||||
|
if (data.animated) {
|
||||||
|
if (typeof data.animated === 'string') {
|
||||||
|
hlsUrl = data.animated;
|
||||||
|
} else if (typeof data.animated === 'object') {
|
||||||
|
hlsUrl = data.animated.hls || data.animated.url || data.animated.hlsUrl || data.animated.videoUrl;
|
||||||
|
|
||||||
|
if (!hlsUrl) {
|
||||||
|
for (const key in data.animated) {
|
||||||
|
if (typeof data.animated[key] === 'string' && data.animated[key].includes('.m3u8')) {
|
||||||
|
hlsUrl = data.animated[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
hlsUrl: hlsUrl,
|
||||||
|
};
|
||||||
|
this.videoArtworkCache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch artist banner:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extractStreamUrlFromManifest(manifest) {
|
extractStreamUrlFromManifest(manifest) {
|
||||||
return this.tidalAPI.extractStreamUrlFromManifest(manifest);
|
return this.tidalAPI.extractStreamUrlFromManifest(manifest);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
js/player.js
25
js/player.js
|
|
@ -16,6 +16,7 @@ import {
|
||||||
exponentialVolumeSettings,
|
exponentialVolumeSettings,
|
||||||
audioEffectsSettings,
|
audioEffectsSettings,
|
||||||
radioSettings,
|
radioSettings,
|
||||||
|
binauralDspSettings,
|
||||||
} 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';
|
||||||
|
|
@ -1881,9 +1882,29 @@ export class Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAtmosPlaying) {
|
if (isAtmosPlaying) {
|
||||||
|
// Auto-enable binaural DSP for spatial content
|
||||||
|
if (binauralDspSettings.getAutoEnableForSpatial() && !binauralDspSettings.isEnabled()) {
|
||||||
|
void audioContextManager.toggleBinaural(true);
|
||||||
|
// Update toggle in settings UI if visible
|
||||||
|
const toggle = document.getElementById('binaural-dsp-toggle');
|
||||||
|
if (toggle) toggle.checked = true;
|
||||||
|
const container = document.getElementById('binaural-dsp-container');
|
||||||
|
if (container) container.style.display = 'block';
|
||||||
|
}
|
||||||
|
// Notify binaural DSP of the actual multichannel layout when Shaka exposes it.
|
||||||
|
const atmosChannelCount =
|
||||||
|
Number.isFinite(activeVariant.channelsCount) && activeVariant.channelsCount > 0
|
||||||
|
? activeVariant.channelsCount
|
||||||
|
: 6;
|
||||||
|
void audioContextManager.notifyBinauralChannelCount(atmosChannelCount);
|
||||||
|
|
||||||
|
const binauralActive = audioContextManager.isBinauralActive();
|
||||||
badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge';
|
badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge';
|
||||||
badgeEl.innerHTML = SVG_ATMOS(20);
|
badgeEl.innerHTML =
|
||||||
|
SVG_ATMOS(20) + (binauralActive ? ' <span class="binaural-badge">Binaural</span>' : '');
|
||||||
} else {
|
} else {
|
||||||
|
// Notify binaural DSP that we're in stereo mode
|
||||||
|
void audioContextManager.notifyBinauralChannelCount(2);
|
||||||
badgeEl.className = 'quality-badge quality-hires shaka-quality-badge';
|
badgeEl.className = 'quality-badge quality-hires shaka-quality-badge';
|
||||||
badgeEl.textContent = text;
|
badgeEl.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
@ -2129,7 +2150,7 @@ export class Player {
|
||||||
await this._bgAudioPlugin.stop();
|
await this._bgAudioPlugin.stop();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not running in Capacitor or plugin unavailable — ignore
|
// Not running in Capacitor or plugin unavailable - ignore
|
||||||
} finally {
|
} finally {
|
||||||
this._bgAudioPending = false;
|
this._bgAudioPending = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1045
js/settings.js
1045
js/settings.js
File diff suppressed because it is too large
Load diff
218
js/storage.js
218
js/storage.js
|
|
@ -687,6 +687,23 @@ export const cardSettings = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const artistBannerSettings = {
|
||||||
|
STORAGE_KEY: 'artist-banners-enabled',
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
try {
|
||||||
|
const val = localStorage.getItem(this.STORAGE_KEY);
|
||||||
|
return val === null ? true : val === 'true';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setEnabled(enabled) {
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const replayGainSettings = {
|
export const replayGainSettings = {
|
||||||
STORAGE_KEY_MODE: 'replay-gain-mode', // 'off', 'track', 'album'
|
STORAGE_KEY_MODE: 'replay-gain-mode', // 'off', 'track', 'album'
|
||||||
STORAGE_KEY_PREAMP: 'replay-gain-preamp',
|
STORAGE_KEY_PREAMP: 'replay-gain-preamp',
|
||||||
|
|
@ -1071,6 +1088,7 @@ export const equalizerSettings = {
|
||||||
GAINS_KEY: 'equalizer-gains',
|
GAINS_KEY: 'equalizer-gains',
|
||||||
BAND_TYPES_KEY: 'equalizer-band-types',
|
BAND_TYPES_KEY: 'equalizer-band-types',
|
||||||
BAND_QS_KEY: 'equalizer-band-qs',
|
BAND_QS_KEY: 'equalizer-band-qs',
|
||||||
|
BAND_CHANNELS_KEY: 'equalizer-band-channels',
|
||||||
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',
|
||||||
|
|
@ -1328,7 +1346,7 @@ export const equalizerSettings = {
|
||||||
}
|
}
|
||||||
// If different band count, try to interpolate or return flat
|
// If different band count, try to interpolate or return flat
|
||||||
if (gains.length > 0) {
|
if (gains.length > 0) {
|
||||||
return this._interpolateGains(gains, count);
|
return this.interpolateGains(gains, count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1424,7 +1442,7 @@ export const equalizerSettings = {
|
||||||
}
|
}
|
||||||
// Interpolate stored Qs to match requested band count instead of discarding
|
// Interpolate stored Qs to match requested band count instead of discarding
|
||||||
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS) {
|
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS) {
|
||||||
return this._interpolateGains(qs, count);
|
return this.interpolateGains(qs, count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -1443,10 +1461,36 @@ export const equalizerSettings = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getBandChannels(bandCount) {
|
||||||
|
const count = bandCount || this.getBandCount();
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.BAND_CHANNELS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const channels = JSON.parse(stored);
|
||||||
|
if (Array.isArray(channels) && channels.length === count) {
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return new Array(count).fill('stereo');
|
||||||
|
},
|
||||||
|
|
||||||
|
setBandChannels(channels) {
|
||||||
|
try {
|
||||||
|
if (Array.isArray(channels) && channels.length >= this.MIN_BANDS && channels.length <= this.MAX_BANDS) {
|
||||||
|
localStorage.setItem(this.BAND_CHANNELS_KEY, JSON.stringify(channels));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EQ] Failed to save band channels:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interpolate gains array to match target band count
|
* Interpolate gains array to match target band count
|
||||||
*/
|
*/
|
||||||
_interpolateGains(sourceGains, targetCount) {
|
interpolateGains(sourceGains, targetCount) {
|
||||||
if (sourceGains.length === targetCount) {
|
if (sourceGains.length === targetCount) {
|
||||||
return [...sourceGains];
|
return [...sourceGains];
|
||||||
}
|
}
|
||||||
|
|
@ -1700,10 +1744,12 @@ export const equalizerSettings = {
|
||||||
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Graphic EQ (16-band) separate storage ---
|
// --- Graphic EQ separate storage ---
|
||||||
GEQ_ENABLED_KEY: 'graphic-eq-enabled',
|
GEQ_ENABLED_KEY: 'graphic-eq-enabled',
|
||||||
GEQ_GAINS_KEY: 'graphic-eq-gains',
|
GEQ_GAINS_KEY: 'graphic-eq-gains',
|
||||||
GEQ_PREAMP_KEY: 'graphic-eq-preamp',
|
GEQ_PREAMP_KEY: 'graphic-eq-preamp',
|
||||||
|
GEQ_BAND_COUNT_KEY: 'graphic-eq-band-count',
|
||||||
|
GEQ_FREQ_RANGE_KEY: 'graphic-eq-freq-range',
|
||||||
|
|
||||||
isGraphicEqEnabled() {
|
isGraphicEqEnabled() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1721,24 +1767,78 @@ export const equalizerSettings = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getGraphicEqGains() {
|
getGraphicEqBandCount() {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(this.GEQ_GAINS_KEY);
|
const val = localStorage.getItem(this.GEQ_BAND_COUNT_KEY);
|
||||||
|
if (val !== null) {
|
||||||
|
const num = parseInt(val, 10);
|
||||||
|
if (num >= 3 && num <= 32) return num;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return 16;
|
||||||
|
},
|
||||||
|
|
||||||
|
setGraphicEqBandCount(count) {
|
||||||
|
const clamped = Math.max(3, Math.min(32, parseInt(count, 10) || 16));
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.GEQ_BAND_COUNT_KEY, String(clamped));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getGraphicEqFreqRange() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.GEQ_FREQ_RANGE_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (Array.isArray(parsed) && parsed.length === 16) {
|
if (parsed && Number.isFinite(parsed.min) && Number.isFinite(parsed.max)) {
|
||||||
return parsed.map((v) => (Number.isFinite(v) ? v : 0));
|
return parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
return new Array(16).fill(0);
|
return { min: 25, max: 20000 };
|
||||||
|
},
|
||||||
|
|
||||||
|
setGraphicEqFreqRange(min, max) {
|
||||||
|
const clampedMin = Math.max(10, Math.min(96000, parseInt(min, 10) || 25));
|
||||||
|
const clampedMax = Math.max(10, Math.min(96000, parseInt(max, 10) || 20000));
|
||||||
|
if (clampedMin >= clampedMax) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.GEQ_FREQ_RANGE_KEY, JSON.stringify({ min: clampedMin, max: clampedMax }));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getGraphicEqGains(bandCount) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.GEQ_GAINS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
const expectedCount = bandCount || this.getGraphicEqBandCount();
|
||||||
|
if (Array.isArray(parsed) && parsed.length === expectedCount) {
|
||||||
|
return parsed.map((v) => (Number.isFinite(v) ? v : 0));
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
return this.interpolateGains(parsed, expectedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return new Array(bandCount || this.getGraphicEqBandCount()).fill(0);
|
||||||
},
|
},
|
||||||
|
|
||||||
setGraphicEqGains(gains) {
|
setGraphicEqGains(gains) {
|
||||||
|
if (!Array.isArray(gains)) return;
|
||||||
|
const sanitized = gains.map((v) => (Number.isFinite(v) ? v : 0));
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(this.GEQ_GAINS_KEY, JSON.stringify(gains));
|
localStorage.setItem(this.GEQ_GAINS_KEY, JSON.stringify(sanitized));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
@ -1758,8 +1858,9 @@ export const equalizerSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
setGraphicEqPreamp(db) {
|
setGraphicEqPreamp(db) {
|
||||||
|
const clamped = Math.max(-20, Math.min(20, parseFloat(db) || 0));
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(this.GEQ_PREAMP_KEY, String(db));
|
localStorage.setItem(this.GEQ_PREAMP_KEY, String(clamped));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
@ -1782,6 +1883,101 @@ export const monoAudioSettings = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const binauralDspSettings = {
|
||||||
|
STORAGE_KEY: 'binaural-dsp',
|
||||||
|
|
||||||
|
_getAll() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(this.STORAGE_KEY)) || {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_setAll(obj) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(obj));
|
||||||
|
} catch {
|
||||||
|
// QuotaExceededError - storage full
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return this._getAll().enabled === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
setEnabled(enabled) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.enabled = !!enabled;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCrossfeedEnabled() {
|
||||||
|
const val = this._getAll().crossfeedEnabled;
|
||||||
|
return val === undefined ? true : val;
|
||||||
|
},
|
||||||
|
|
||||||
|
setCrossfeedEnabled(enabled) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.crossfeedEnabled = !!enabled;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCrossfeedLevel() {
|
||||||
|
return this._getAll().crossfeedLevel || 'medium';
|
||||||
|
},
|
||||||
|
|
||||||
|
setCrossfeedLevel(level) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.crossfeedLevel = level;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getHrtfPreset() {
|
||||||
|
return this._getAll().hrtfPreset || 'studio';
|
||||||
|
},
|
||||||
|
|
||||||
|
setHrtfPreset(preset) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.hrtfPreset = preset;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getWideningEnabled() {
|
||||||
|
const val = this._getAll().wideningEnabled;
|
||||||
|
return val === undefined ? true : val;
|
||||||
|
},
|
||||||
|
|
||||||
|
setWideningEnabled(enabled) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.wideningEnabled = !!enabled;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getWideningAmount() {
|
||||||
|
const val = this._getAll().wideningAmount;
|
||||||
|
return val === undefined ? 1.0 : val;
|
||||||
|
},
|
||||||
|
|
||||||
|
setWideningAmount(amount) {
|
||||||
|
const all = this._getAll();
|
||||||
|
const n = Number(amount);
|
||||||
|
all.wideningAmount = Number.isFinite(n) ? Math.max(0, Math.min(2, n)) : 1.0;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAutoEnableForSpatial() {
|
||||||
|
const val = this._getAll().autoEnableForSpatial;
|
||||||
|
return val === undefined ? true : val;
|
||||||
|
},
|
||||||
|
|
||||||
|
setAutoEnableForSpatial(enabled) {
|
||||||
|
const all = this._getAll();
|
||||||
|
all.autoEnableForSpatial = !!enabled;
|
||||||
|
this._setAll(all);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const exponentialVolumeSettings = {
|
export const exponentialVolumeSettings = {
|
||||||
STORAGE_KEY: 'exponential-volume-enabled',
|
STORAGE_KEY: 'exponential-volume-enabled',
|
||||||
|
|
||||||
|
|
|
||||||
44
js/ui.js
44
js/ui.js
|
|
@ -29,6 +29,7 @@ import {
|
||||||
contentBlockingSettings,
|
contentBlockingSettings,
|
||||||
settingsUiState,
|
settingsUiState,
|
||||||
fullscreenCoverNoRoundSettings,
|
fullscreenCoverNoRoundSettings,
|
||||||
|
artistBannerSettings,
|
||||||
} 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';
|
||||||
|
|
@ -4775,6 +4776,16 @@ export class UIRenderer {
|
||||||
await this.showPage('artist');
|
await this.showPage('artist');
|
||||||
this.currentArtistId = artistId;
|
this.currentArtistId = artistId;
|
||||||
|
|
||||||
|
const bannerContainer = document.getElementById('artist-detail-banner-container');
|
||||||
|
if (bannerContainer) {
|
||||||
|
const oldVideo = bannerContainer.querySelector('video');
|
||||||
|
if (oldVideo && oldVideo._hls) {
|
||||||
|
oldVideo._hls.destroy();
|
||||||
|
}
|
||||||
|
bannerContainer.innerHTML = '';
|
||||||
|
bannerContainer.style.opacity = '0';
|
||||||
|
}
|
||||||
|
|
||||||
const imageEl = document.getElementById('artist-detail-image');
|
const imageEl = document.getElementById('artist-detail-image');
|
||||||
const nameEl = document.getElementById('artist-detail-name');
|
const nameEl = document.getElementById('artist-detail-name');
|
||||||
const metaEl = document.getElementById('artist-detail-meta');
|
const metaEl = document.getElementById('artist-detail-meta');
|
||||||
|
|
@ -4823,6 +4834,39 @@ export class UIRenderer {
|
||||||
try {
|
try {
|
||||||
const artist = await this.api.getArtist(artistId, provider);
|
const artist = await this.api.getArtist(artistId, provider);
|
||||||
|
|
||||||
|
const currentId = this.currentArtistId;
|
||||||
|
this.api
|
||||||
|
.getArtistBanner(artist.name)
|
||||||
|
.then(async (banner) => {
|
||||||
|
if (this.currentArtistId !== currentId) return;
|
||||||
|
|
||||||
|
if (banner && banner.hlsUrl && bannerContainer) {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.autoplay = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.setAttribute('muted', '');
|
||||||
|
video.setAttribute('autoplay', '');
|
||||||
|
video.setAttribute('playsinline', '');
|
||||||
|
video.style.opacity = '1';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.setupHlsVideo(video, banner, null);
|
||||||
|
if (this.currentArtistId === currentId) {
|
||||||
|
bannerContainer.appendChild(video);
|
||||||
|
bannerContainer.style.opacity = '1';
|
||||||
|
video.play().catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to setup artist banner video:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.warn('Failed to fetch artist banner:', e);
|
||||||
|
});
|
||||||
|
|
||||||
// Handle Biography
|
// Handle Biography
|
||||||
if (bioEl) {
|
if (bioEl) {
|
||||||
// Pre-define regex patterns for better performance
|
// Pre-define regex patterns for better performance
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export class Visualizer {
|
||||||
|
|
||||||
// Pause animation loop when the app is backgrounded so the analyser's
|
// Pause animation loop when the app is backgrounded so the analyser's
|
||||||
// FFT reads don't compete with the EQ biquad filter chain for audio
|
// FFT reads don't compete with the EQ biquad filter chain for audio
|
||||||
// thread time — the main cause of audio skipping with AutoEQ in background.
|
// thread time - the main cause of audio skipping with AutoEQ in background.
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden' && this.isActive) {
|
if (document.visibilityState === 'hidden' && this.isActive) {
|
||||||
this._backgroundPaused = true;
|
this._backgroundPaused = true;
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
"@svta/common-media-library": "^0.18.1",
|
"@svta/common-media-library": "^0.18.1",
|
||||||
"@types/wicg-file-system-access": "^2023.10.7",
|
"@types/wicg-file-system-access": "^2023.10.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
||||||
"@uimaxbai/am-lyrics": "^1.1.8",
|
"@uimaxbai/am-lyrics": "^1.2.7",
|
||||||
"@vitest/web-worker": "^4.1.2",
|
"@vitest/web-worker": "^4.1.2",
|
||||||
"appwrite": "^23.0.0",
|
"appwrite": "^23.0.0",
|
||||||
"butterchurn": "^2.6.7",
|
"butterchurn": "^2.6.7",
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,5 @@ album:103897783
|
||||||
album:151728406
|
album:151728406
|
||||||
album:199412873
|
album:199412873
|
||||||
album:3280432
|
album:3280432
|
||||||
album:37927851
|
album:37927851
|
||||||
|
album:18083938
|
||||||
|
|
|
||||||
438
styles.css
438
styles.css
|
|
@ -2542,6 +2542,136 @@ body.multi-select-mode .track-item:hover {
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-header-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header-banner video {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
filter: brightness(0.6);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-artist .detail-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12rem 3rem 4rem 3rem;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: -8rem;
|
||||||
|
margin-left: calc(var(--spacing-xl) * -1);
|
||||||
|
margin-right: calc(var(--spacing-xl) * -1);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
min-height: 550px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
background-color: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
#page-artist .detail-header {
|
||||||
|
margin-top: -7rem;
|
||||||
|
margin-left: calc(var(--spacing-lg) * -1);
|
||||||
|
margin-right: calc(var(--spacing-lg) * -1);
|
||||||
|
padding: 10rem 2rem 3rem 2rem;
|
||||||
|
min-height: 450px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#page-artist .detail-header {
|
||||||
|
margin-top: -6rem;
|
||||||
|
margin-left: calc(var(--spacing-md) * -1);
|
||||||
|
margin-right: calc(var(--spacing-md) * -1);
|
||||||
|
padding: 8rem 1rem 2rem 1rem;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#page-artist .detail-header {
|
||||||
|
margin-top: -5rem;
|
||||||
|
margin-left: -1rem;
|
||||||
|
margin-right: -1rem;
|
||||||
|
padding: 5rem 1rem 2rem 1rem;
|
||||||
|
min-height: 300px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-artist .detail-header-image {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-artist .detail-header-info .title {
|
||||||
|
font-size: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-artist .detail-header-info .meta {
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-artist .detail-header-actions {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-artist .artist-bio {
|
||||||
|
text-align: center;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header-banner::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, 0.4) 0%,
|
||||||
|
rgba(0, 0, 0, 0) 40%,
|
||||||
|
rgba(0, 0, 0, 0.2) 70%,
|
||||||
|
var(--background) 100%
|
||||||
|
);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-artist .detail-header-image {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border: 4px solid var(--background);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-artist .detail-header-info {
|
||||||
|
z-index: 2;
|
||||||
|
text-shadow: 0 2px 15px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.detail-header-info .type {
|
.detail-header-info .type {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
@ -3753,7 +3883,9 @@ input:checked + .slider::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
#context-menu,
|
#context-menu,
|
||||||
#sort-menu {
|
#sort-menu,
|
||||||
|
#eq-node-context-menu,
|
||||||
|
#eq-empty-context-menu {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background-color: var(--card);
|
background-color: var(--card);
|
||||||
|
|
@ -3768,12 +3900,16 @@ input:checked + .slider::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
#context-menu ul,
|
#context-menu ul,
|
||||||
#sort-menu ul {
|
#sort-menu ul,
|
||||||
|
#eq-node-context-menu ul,
|
||||||
|
#eq-empty-context-menu ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#context-menu li,
|
#context-menu li,
|
||||||
#sort-menu li {
|
#sort-menu li,
|
||||||
|
#eq-node-context-menu li,
|
||||||
|
#eq-empty-context-menu li {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -3795,7 +3931,9 @@ input:checked + .slider::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
#context-menu li:hover,
|
#context-menu li:hover,
|
||||||
#sort-menu li:hover {
|
#sort-menu li:hover,
|
||||||
|
#eq-node-context-menu li:hover,
|
||||||
|
#eq-empty-context-menu li:hover {
|
||||||
background-color: var(--secondary);
|
background-color: var(--secondary);
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
|
|
||||||
|
|
@ -3803,7 +3941,8 @@ input:checked + .slider::before {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
#context-menu li.separator {
|
#context-menu li.separator,
|
||||||
|
#eq-node-context-menu li.separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background-color: var(--border);
|
background-color: var(--border);
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
|
|
@ -3812,11 +3951,17 @@ input:checked + .slider::before {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#context-menu li.separator:hover {
|
#context-menu li.separator:hover,
|
||||||
|
#eq-node-context-menu li.separator:hover {
|
||||||
background-color: var(--border);
|
background-color: var(--border);
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#eq-node-context-menu li.eq-ctx-active {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.blocked-items-list {
|
.blocked-items-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
|
|
@ -7893,6 +8038,183 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
16-Band Graphic Equalizer (Legacy EQ)
|
||||||
|
======================================== */
|
||||||
|
.graphic-eq-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-config-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-config-row label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geq-config-input {
|
||||||
|
width: 70px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-secondary, #222);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-preset-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-preset-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-preset-select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-bands {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--spacing-md) var(--spacing-sm);
|
||||||
|
background: rgb(0, 0, 0, 0.15);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
min-height: 240px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-band {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-band-value {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-height: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-band-slider-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 160px;
|
||||||
|
width: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-band-slider-wrap input[type='range'] {
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
direction: rtl;
|
||||||
|
width: 28px;
|
||||||
|
height: 100%;
|
||||||
|
accent-color: var(--foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-band-label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-bottom-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-preamp {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-preamp-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-preamp-slider {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
accent-color: var(--highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-preamp-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
min-width: 45px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.graphic-eq-bands {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-band-slider-wrap {
|
||||||
|
height: 130px;
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-band-label {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-band-value {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-bottom-row {
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-eq-preamp {
|
||||||
|
min-width: 0;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Precision AutoEQ - Redesigned Equalizer
|
Precision AutoEQ - Redesigned Equalizer
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
@ -7928,9 +8250,9 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--highlight);
|
||||||
background: var(--input);
|
background: rgb(var(--highlight-rgb), 0.12);
|
||||||
color: var(--muted-foreground);
|
color: var(--highlight);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -7939,9 +8261,9 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
||||||
}
|
}
|
||||||
|
|
||||||
.eq-howto-btn:hover {
|
.eq-howto-btn:hover {
|
||||||
border-color: var(--primary);
|
border-color: var(--highlight);
|
||||||
color: var(--primary);
|
color: var(--primary-foreground);
|
||||||
background: rgb(var(--highlight-rgb), 0.08);
|
background: var(--highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.eq-howto-panel {
|
.eq-howto-panel {
|
||||||
|
|
@ -8100,7 +8422,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
background: var(--background);
|
background: color-mix(in srgb, var(--background) 25%, #111);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -8893,7 +9215,8 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autoeq-type-select {
|
.autoeq-type-select,
|
||||||
|
.autoeq-channel-select {
|
||||||
padding: 0.15rem 0.3rem;
|
padding: 0.15rem 0.3rem;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -8906,11 +9229,13 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autoeq-type-select:hover {
|
.autoeq-type-select:hover,
|
||||||
|
.autoeq-channel-select:hover {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autoeq-type-select:focus {
|
.autoeq-type-select:focus,
|
||||||
|
.autoeq-channel-select:focus {
|
||||||
border-color: var(--ring);
|
border-color: var(--ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11007,3 +11332,84 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Binaural / Spatial DSP
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.binaural-dsp-container {
|
||||||
|
padding: var(--spacing-md) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binaural-status {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
background: var(--secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binaural-mode-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binaural-sub-setting {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binaural-sub-setting select {
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--foreground);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binaural-sub-setting .info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binaural-sub-setting .label {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binaural-sub-setting .description {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binaural-slider {
|
||||||
|
width: 140px;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binaural-width-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary);
|
||||||
|
min-width: 2.5em;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binaural-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.8;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue