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:
parent
bee43e7e89
commit
5bbc36cdb1
8 changed files with 1484 additions and 3 deletions
122
index.html
122
index.html
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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
699
js/binaural-dsp.js
Normal 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
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 {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;
|
||||
}
|
||||
18
js/player.js
18
js/player.js
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
101
js/settings.js
101
js/settings.js
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
81
styles.css
81
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue