EQ Studio
diff --git a/js/audio-context.js b/js/audio-context.js
index 0dec0dc..538070b 100644
--- a/js/audio-context.js
+++ b/js/audio-context.js
@@ -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();
}
/**
diff --git a/js/binaural-dsp.js b/js/binaural-dsp.js
new file mode 100644
index 0000000..9ddb661
--- /dev/null
+++ b/js/binaural-dsp.js
@@ -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 };
diff --git a/js/hrtf-generator.js b/js/hrtf-generator.js
new file mode 100644
index 0000000..b12dcf5
--- /dev/null
+++ b/js/hrtf-generator.js
@@ -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
} 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