feat: add binaural/spatial DSP with multichannel HRTF rendering

Implement a complete binaural audio processing pipeline that sits before
EQ Studio in the signal chain. Supports multichannel (5.1) HRTF
binauralization for Dolby Atmos and Apple 3D Audio content, with
crossfeed and stereo widening for regular stereo content.

New modules:
- hrtf-generator.js: Procedural HRTF impulse response synthesis using
  Woodworth head model (ITD, ILD, head shadow, pinna coloring)
- binaural-dsp.js: BinauralDSP engine with multichannel splitter,
  per-channel ConvolverNode HRTF rendering, Bauer-style crossfeed
  (low/medium/high), and M/S stereo widener

Integration:
- Audio graph: binaural block inserted before preamp/EQ, multichannel
  passthrough via MediaElementSource channelCount=6
- Storage: binauralDspSettings with full persistence (JSON in localStorage)
- UI: toggle + sub-controls (crossfeed level, HRTF preset, width slider)
  placed before EQ Studio in settings
- Player: auto-enables binaural DSP when Atmos content detected,
  shows binaural badge on Atmos tracks
This commit is contained in:
tryptz 2026-04-07 12:15:51 +00:00 committed by edideaur
parent bee43e7e89
commit 5bbc36cdb1
8 changed files with 1484 additions and 3 deletions

View file

@ -4159,6 +4159,128 @@
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Binaural / Spatial DSP</span>
<span class="description"
>Multichannel HRTF rendering for Atmos &amp; 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 (&plusmn;22&deg;)</option>
<option value="studio" selected>
Studio (&plusmn;30&deg;)
</option>
<option value="wide">Wide (&plusmn;45&deg;)</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>

View file

@ -3,7 +3,8 @@
// Supports 3-32 parametric EQ bands
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';
/**
* Compute RBJ cookbook IIR coefficients for shelf filters with Q support.
@ -138,6 +139,10 @@ class AudioContextManager {
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)
this._graphChangeCallbacks = [];
@ -487,14 +492,39 @@ class AudioContextManager {
}
if (!this.sources.has(audioElement)) {
this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement));
const src = this.audioContext.createMediaElementSource(audioElement);
// Allow multichannel passthrough for Atmos/spatial audio
try {
src.channelCount = 6;
src.channelCountMode = 'max';
src.channelInterpretation = 'discrete';
} catch {
// Some browsers may not support this
}
this.sources.set(audioElement, src);
}
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.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.7;
// Create binaural DSP processor
this.binauralDsp = new BinauralDSP(this.audioContext);
this._loadBinauralSettings();
this._createEQ();
this._createGraphicEQ();
this._createMSNodes();
@ -612,6 +642,12 @@ class AudioContextManager {
safeDisconnect(this.source);
safeDisconnect(this.monoGainNode);
safeDisconnect(this.monoMergerNode);
// Binaural DSP disconnects internally
if (this.binauralDsp) {
const { input, output } = this.binauralDsp.getNodes();
safeDisconnect(input);
safeDisconnect(output);
}
safeDisconnect(this.preampNode);
this.filters.forEach(safeDisconnect);
safeDisconnect(this.outputNode);
@ -652,6 +688,14 @@ class AudioContextManager {
lastNode = this.monoMergerNode;
}
// Insert binaural DSP before EQ
if (this.isBinauralEnabled && this.binauralDsp) {
const { input, output } = this.binauralDsp.getNodes();
lastNode.connect(input);
this.binauralDsp._connectInternal();
lastNode = output;
}
if (this.isEQEnabled && this.filters.length > 0) {
const useMS = this.msEnabled && this.midFilters.length > 0 && this.sideFilters.length > 0;
@ -839,6 +883,126 @@ class AudioContextManager {
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
*/
@ -998,6 +1162,7 @@ class AudioContextManager {
this.msEnabled = this.currentChannels.some((ch) => ch === 'mid' || ch === 'side');
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
this.preamp = equalizerSettings.getPreamp();
this.isBinauralEnabled = binauralDspSettings.isEnabled();
}
/**

699
js/binaural-dsp.js Normal file
View file

@ -0,0 +1,699 @@
// 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 };
}
/**
* 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;
this.channelCount = channelCount;
if (channelCount > 2) {
this.mode = 'multichannel';
} else {
this.mode = 'stereo';
}
if (this.enabled && this.mode !== prevMode) {
await this._ensureNodesCreated();
this._connectInternal();
window.dispatchEvent(new CustomEvent('binaural-mode-changed', {
detail: { mode: this.mode, channels: 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 };

207
js/hrtf-generator.js Normal file
View 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 {Promise<AudioBuffer>} Stereo AudioBuffer with HRTF IR
*/
export async 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
const ipsiDelay = Math.max(0, sourceOnRight ? 0 : itdSamples);
const contraDelay = Math.max(0, sourceOnRight ? itdSamples : 0);
// 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;
}

View file

@ -16,6 +16,7 @@ import {
exponentialVolumeSettings,
audioEffectsSettings,
radioSettings,
binauralDspSettings,
} from './storage.js';
import { audioContextManager } from './audio-context.js';
import { isIos, isSafari } from './platform-detection.js';
@ -1882,9 +1883,24 @@ export class Player {
}
if (isAtmosPlaying) {
// Auto-enable binaural DSP for spatial content
if (binauralDspSettings.getAutoEnableForSpatial() && !binauralDspSettings.isEnabled()) {
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 multichannel content (Atmos is typically 5.1+)
audioContextManager.notifyBinauralChannelCount(6);
const binauralActive = audioContextManager.isBinauralActive();
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 {
// Notify binaural DSP that we're in stereo mode
audioContextManager.notifyBinauralChannelCount(2);
badgeEl.className = 'quality-badge quality-hires shaka-quality-badge';
badgeEl.textContent = text;
}

View file

@ -35,6 +35,7 @@ import {
analyticsSettings,
modalSettings,
preferDolbyAtmosSettings,
binauralDspSettings,
fullscreenCoverNoRoundSettings,
fullscreenCoverVanillaTiltSettings,
fullscreenCoverTiltDistanceSettings,
@ -1154,6 +1155,106 @@ export async function initializeSettings(scrobbler, player, api, ui) {
});
}
// ========================================
// Binaural / Spatial DSP
// ========================================
const binauralToggle = document.getElementById('binaural-dsp-toggle');
const binauralContainer = document.getElementById('binaural-dsp-container');
const binauralAutoSpatialToggle = document.getElementById('binaural-auto-spatial-toggle');
const binauralCrossfeedToggle = document.getElementById('binaural-crossfeed-toggle');
const binauralCrossfeedLevel = document.getElementById('binaural-crossfeed-level');
const crossfeedLevelRow = document.getElementById('crossfeed-level-row');
const binauralHrtfPreset = document.getElementById('binaural-hrtf-preset');
const binauralWideningToggle = document.getElementById('binaural-widening-toggle');
const binauralWideningSlider = document.getElementById('binaural-widening-slider');
const binauralWidthValue = document.getElementById('binaural-width-value');
const wideningSliderRow = document.getElementById('widening-slider-row');
if (binauralToggle && binauralContainer) {
const isEnabled = binauralDspSettings.isEnabled();
binauralToggle.checked = isEnabled;
binauralContainer.style.display = isEnabled ? 'block' : 'none';
binauralToggle.addEventListener('change', async (e) => {
const enabled = e.target.checked;
binauralContainer.style.display = enabled ? 'block' : 'none';
await audioContextManager.toggleBinaural(enabled);
});
}
if (binauralAutoSpatialToggle) {
binauralAutoSpatialToggle.checked = binauralDspSettings.getAutoEnableForSpatial();
binauralAutoSpatialToggle.addEventListener('change', (e) => {
binauralDspSettings.setAutoEnableForSpatial(e.target.checked);
});
}
if (binauralCrossfeedToggle) {
binauralCrossfeedToggle.checked = binauralDspSettings.getCrossfeedEnabled();
if (crossfeedLevelRow) {
crossfeedLevelRow.style.display = binauralCrossfeedToggle.checked ? 'flex' : 'none';
}
binauralCrossfeedToggle.addEventListener('change', async (e) => {
const enabled = e.target.checked;
if (crossfeedLevelRow) {
crossfeedLevelRow.style.display = enabled ? 'flex' : 'none';
}
await audioContextManager.setBinauralCrossfeedEnabled(enabled);
});
}
if (binauralCrossfeedLevel) {
binauralCrossfeedLevel.value = binauralDspSettings.getCrossfeedLevel();
binauralCrossfeedLevel.addEventListener('change', (e) => {
audioContextManager.setBinauralCrossfeedLevel(e.target.value);
});
}
if (binauralHrtfPreset) {
binauralHrtfPreset.value = binauralDspSettings.getHrtfPreset();
binauralHrtfPreset.addEventListener('change', async (e) => {
await audioContextManager.setBinauralHrtfPreset(e.target.value);
});
}
if (binauralWideningToggle) {
binauralWideningToggle.checked = binauralDspSettings.getWideningEnabled();
if (wideningSliderRow) {
wideningSliderRow.style.display = binauralWideningToggle.checked ? 'flex' : 'none';
}
binauralWideningToggle.addEventListener('change', async (e) => {
const enabled = e.target.checked;
if (wideningSliderRow) {
wideningSliderRow.style.display = enabled ? 'flex' : 'none';
}
await audioContextManager.setBinauralWideningEnabled(enabled);
});
}
if (binauralWideningSlider && binauralWidthValue) {
binauralWideningSlider.value = binauralDspSettings.getWideningAmount();
binauralWidthValue.textContent = parseFloat(binauralWideningSlider.value).toFixed(2);
binauralWideningSlider.addEventListener('input', (e) => {
const amount = parseFloat(e.target.value);
binauralWidthValue.textContent = amount.toFixed(2);
audioContextManager.setBinauralWidening(amount);
});
}
// Listen for binaural mode changes (multichannel detection)
window.addEventListener('binaural-mode-changed', (e) => {
const statusEl = document.getElementById('binaural-status');
if (statusEl) {
const { mode, channels } = e.detail;
const label = statusEl.querySelector('.binaural-mode-label');
if (label) {
label.textContent = mode === 'multichannel'
? `Mode: Multichannel (${channels > 6 ? '7.1' : '5.1'} → Binaural)`
: 'Mode: Stereo';
}
}
});
// Exponential Volume Toggle
const exponentialVolumeToggle = document.getElementById('exponential-volume-toggle');
if (exponentialVolumeToggle) {

View file

@ -1866,6 +1866,96 @@ export const monoAudioSettings = {
},
};
export const binauralDspSettings = {
STORAGE_KEY: 'binaural-dsp',
_getAll() {
try {
return JSON.parse(localStorage.getItem(this.STORAGE_KEY)) || {};
} catch {
return {};
}
},
_setAll(obj) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(obj));
},
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();
all.wideningAmount = Math.max(0, Math.min(2, amount));
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 = {
STORAGE_KEY: 'exponential-volume-enabled',

View file

@ -11202,3 +11202,84 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
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 .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-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-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;
}