- Remove stale IIR coefficient JSDoc comment - Reset M/S channel state on EQ import to prevent stale assignments - Enforce strictly increasing GEQ frequencies to prevent rounding duplicates - Guard Q calculation against zero octave spacing (Infinity/NaN) - Export EQ from stored metadata instead of live BiquadFilterNode state - Accept .csv in legacy GEQ import file input - Expose public reconnect() on BinauralDSP instead of calling _connectInternal - Dispatch binaural-mode-changed on channel count change, not just mode change - Remove no-op channelCount/channelCountMode on MediaElementAudioSourceNode - Add void to floating promises (toggleBinaural, notifyBinauralChannelCount, _loadBinauralSettings) - Wrap binauralDspSettings._setAll in try/catch for QuotaExceededError - Make generateHRTF synchronous (no awaits, was misleadingly async)
746 lines
24 KiB
JavaScript
746 lines
24 KiB
JavaScript
// js/binaural-dsp.js
|
|
// Binaural DSP engine: multichannel HRTF rendering, crossfeed, and stereo widening.
|
|
// Placed before EQ in the audio chain.
|
|
|
|
import { generateHRTFSet, HRTF_PRESETS, CHANNEL_ANGLES_51 } from './hrtf-generator.js';
|
|
|
|
/**
|
|
* Crossfeed presets (Bauer bs2b-style)
|
|
*/
|
|
const CROSSFEED_PRESETS = {
|
|
low: { cutoff: 500, crossGainDb: -6, delayMs: 0.2 },
|
|
medium: { cutoff: 700, crossGainDb: -4.5, delayMs: 0.3 },
|
|
high: { cutoff: 1000, crossGainDb: -3, delayMs: 0.4 },
|
|
};
|
|
|
|
export class BinauralDSP {
|
|
/**
|
|
* @param {AudioContext} audioContext
|
|
*/
|
|
constructor(audioContext) {
|
|
this.ctx = audioContext;
|
|
this.enabled = false;
|
|
this.mode = 'stereo'; // 'stereo' | 'multichannel'
|
|
this.channelCount = 2;
|
|
|
|
// Sub-feature states
|
|
this.crossfeedEnabled = true;
|
|
this.crossfeedLevel = 'medium';
|
|
this.hrtfPreset = 'studio';
|
|
this.wideningEnabled = true;
|
|
this.wideningAmount = 1.0;
|
|
|
|
// Graph nodes (created lazily)
|
|
this.inputNode = this.ctx.createGain();
|
|
this.outputNode = this.ctx.createGain();
|
|
this.bypassNode = this.ctx.createGain(); // direct path when disabled
|
|
|
|
// Crossfeed nodes
|
|
this._cfSplitter = null;
|
|
this._cfMerger = null;
|
|
this._cfDirectL = null;
|
|
this._cfDirectR = null;
|
|
this._cfCrossLR = null; // L → R cross path
|
|
this._cfCrossRL = null; // R → L cross path
|
|
this._cfFilterLR = null;
|
|
this._cfFilterRL = null;
|
|
this._cfDelayLR = null;
|
|
this._cfDelayRL = null;
|
|
this._cfOutputNode = null;
|
|
|
|
// Multichannel HRTF nodes
|
|
this._mcSplitter = null;
|
|
this._mcMerger = null;
|
|
this._mcConvolversL = []; // per-channel left-ear convolvers
|
|
this._mcConvolversR = []; // per-channel right-ear convolvers
|
|
this._mcLfeGain = null;
|
|
this._mcOutputNode = null;
|
|
this._hrtfBuffers = null; // Map from generateHRTFSet
|
|
|
|
// Stereo widener nodes
|
|
this._wSplitter = null;
|
|
this._wMerger = null;
|
|
this._wMidL = null;
|
|
this._wMidR = null;
|
|
this._wSideL = null;
|
|
this._wSideR = null;
|
|
this._wMidGain = null;
|
|
this._wSideGain = null;
|
|
this._wMidMix = null;
|
|
this._wSideMix = null;
|
|
this._wDecoderMidToL = null;
|
|
this._wDecoderSideToL = null;
|
|
this._wDecoderMidToR = null;
|
|
this._wDecoderSideToR = null;
|
|
this._wLMix = null;
|
|
this._wRMix = null;
|
|
this._wOutputMerger = null;
|
|
this._wOutputNode = null;
|
|
|
|
// Initialize the internal bypass connection
|
|
this._connectInternal();
|
|
}
|
|
|
|
/**
|
|
* Get the input/output nodes for graph insertion.
|
|
*/
|
|
getNodes() {
|
|
return { input: this.inputNode, output: this.outputNode };
|
|
}
|
|
|
|
/**
|
|
* Reconnect internal graph (public API for external callers).
|
|
*/
|
|
reconnect() {
|
|
this._connectInternal();
|
|
}
|
|
|
|
/**
|
|
* Connect internal graph based on current state.
|
|
*/
|
|
_connectInternal() {
|
|
this._disconnectAll();
|
|
|
|
if (!this.enabled) {
|
|
// Bypass: input → output directly
|
|
this.inputNode.connect(this.outputNode);
|
|
return;
|
|
}
|
|
|
|
if (this.mode === 'multichannel' && this._mcOutputNode) {
|
|
this._connectMultichannelPath();
|
|
} else {
|
|
this._connectStereoPath();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect the stereo processing path: crossfeed → widener → output
|
|
*/
|
|
_connectStereoPath() {
|
|
let lastNode = this.inputNode;
|
|
|
|
if (this.crossfeedEnabled && this._cfOutputNode) {
|
|
lastNode.connect(this._cfSplitter);
|
|
|
|
// Direct paths
|
|
this._cfSplitter.connect(this._cfDirectL, 0);
|
|
this._cfSplitter.connect(this._cfDirectR, 1);
|
|
|
|
// Cross paths: L → R
|
|
this._cfSplitter.connect(this._cfFilterLR, 0);
|
|
this._cfFilterLR.connect(this._cfDelayLR);
|
|
this._cfDelayLR.connect(this._cfCrossLR);
|
|
|
|
// Cross paths: R → L
|
|
this._cfSplitter.connect(this._cfFilterRL, 1);
|
|
this._cfFilterRL.connect(this._cfDelayRL);
|
|
this._cfDelayRL.connect(this._cfCrossRL);
|
|
|
|
// Merge: L channel = directL + crossRL, R channel = directR + crossLR
|
|
this._cfDirectL.connect(this._cfMerger, 0, 0);
|
|
this._cfCrossRL.connect(this._cfMerger, 0, 0);
|
|
this._cfDirectR.connect(this._cfMerger, 0, 1);
|
|
this._cfCrossLR.connect(this._cfMerger, 0, 1);
|
|
|
|
this._cfMerger.connect(this._cfOutputNode);
|
|
lastNode = this._cfOutputNode;
|
|
}
|
|
|
|
if (this.wideningEnabled && this._wOutputNode) {
|
|
this._connectWidener(lastNode);
|
|
lastNode = this._wOutputNode;
|
|
}
|
|
|
|
lastNode.connect(this.outputNode);
|
|
}
|
|
|
|
/**
|
|
* Connect the multichannel HRTF rendering path: splitter → per-ch HRTF → merger → widener → output
|
|
*/
|
|
_connectMultichannelPath() {
|
|
// Input must pass multichannel through
|
|
this.inputNode.channelCount = this.channelCount;
|
|
this.inputNode.channelCountMode = 'max';
|
|
this.inputNode.channelInterpretation = 'discrete';
|
|
|
|
this.inputNode.connect(this._mcSplitter);
|
|
|
|
const numChannels = Math.min(this.channelCount, CHANNEL_ANGLES_51.length);
|
|
|
|
for (let i = 0; i < numChannels; i++) {
|
|
const chInfo = CHANNEL_ANGLES_51[i];
|
|
|
|
if (chInfo.isLFE) {
|
|
// LFE: direct mix to both ears at reduced level
|
|
this._mcSplitter.connect(this._mcLfeGain, i);
|
|
this._mcLfeGain.connect(this._mcMerger, 0, 0);
|
|
this._mcLfeGain.connect(this._mcMerger, 0, 1);
|
|
} else {
|
|
// HRTF convolution: split to left and right ear convolvers
|
|
this._mcSplitter.connect(this._mcConvolversL[i], i);
|
|
this._mcSplitter.connect(this._mcConvolversR[i], i);
|
|
this._mcConvolversL[i].connect(this._mcMerger, 0, 0); // left ear
|
|
this._mcConvolversR[i].connect(this._mcMerger, 0, 1); // right ear
|
|
}
|
|
}
|
|
|
|
this._mcMerger.connect(this._mcOutputNode);
|
|
let lastNode = this._mcOutputNode;
|
|
|
|
if (this.wideningEnabled && this._wOutputNode) {
|
|
this._connectWidener(lastNode);
|
|
lastNode = this._wOutputNode;
|
|
}
|
|
|
|
lastNode.connect(this.outputNode);
|
|
}
|
|
|
|
/**
|
|
* Connect the stereo widener from a source node.
|
|
*/
|
|
_connectWidener(sourceNode) {
|
|
sourceNode.connect(this._wSplitter);
|
|
|
|
// Encode L/R → M/S
|
|
this._wSplitter.connect(this._wMidL, 0);
|
|
this._wSplitter.connect(this._wMidR, 1);
|
|
this._wMidL.connect(this._wMidMix);
|
|
this._wMidR.connect(this._wMidMix);
|
|
|
|
this._wSplitter.connect(this._wSideL, 0);
|
|
this._wSplitter.connect(this._wSideR, 1);
|
|
this._wSideL.connect(this._wSideMix);
|
|
this._wSideR.connect(this._wSideMix);
|
|
|
|
// Apply width gains
|
|
this._wMidMix.connect(this._wMidGain);
|
|
this._wSideMix.connect(this._wSideGain);
|
|
|
|
// Decode M/S → L/R
|
|
this._wMidGain.connect(this._wDecoderMidToL);
|
|
this._wSideGain.connect(this._wDecoderSideToL);
|
|
this._wDecoderMidToL.connect(this._wLMix);
|
|
this._wDecoderSideToL.connect(this._wLMix);
|
|
|
|
this._wMidGain.connect(this._wDecoderMidToR);
|
|
this._wSideGain.connect(this._wDecoderSideToR);
|
|
this._wDecoderMidToR.connect(this._wRMix);
|
|
this._wDecoderSideToR.connect(this._wRMix);
|
|
|
|
// Merge L/R back to stereo
|
|
this._wLMix.connect(this._wOutputMerger, 0, 0);
|
|
this._wRMix.connect(this._wOutputMerger, 0, 1);
|
|
this._wOutputMerger.connect(this._wOutputNode);
|
|
}
|
|
|
|
/**
|
|
* Disconnect all internal nodes safely.
|
|
*/
|
|
_disconnectAll() {
|
|
const sd = (node) => {
|
|
try {
|
|
node?.disconnect();
|
|
} catch {
|
|
/* */
|
|
}
|
|
};
|
|
|
|
sd(this.inputNode);
|
|
sd(this.bypassNode);
|
|
|
|
// Crossfeed
|
|
sd(this._cfSplitter);
|
|
sd(this._cfMerger);
|
|
sd(this._cfDirectL);
|
|
sd(this._cfDirectR);
|
|
sd(this._cfCrossLR);
|
|
sd(this._cfCrossRL);
|
|
sd(this._cfFilterLR);
|
|
sd(this._cfFilterRL);
|
|
sd(this._cfDelayLR);
|
|
sd(this._cfDelayRL);
|
|
sd(this._cfOutputNode);
|
|
|
|
// Multichannel
|
|
sd(this._mcSplitter);
|
|
sd(this._mcMerger);
|
|
sd(this._mcLfeGain);
|
|
this._mcConvolversL.forEach(sd);
|
|
this._mcConvolversR.forEach(sd);
|
|
sd(this._mcOutputNode);
|
|
|
|
// Widener
|
|
sd(this._wSplitter);
|
|
sd(this._wMerger);
|
|
sd(this._wMidL);
|
|
sd(this._wMidR);
|
|
sd(this._wSideL);
|
|
sd(this._wSideR);
|
|
sd(this._wMidGain);
|
|
sd(this._wSideGain);
|
|
sd(this._wMidMix);
|
|
sd(this._wSideMix);
|
|
sd(this._wDecoderMidToL);
|
|
sd(this._wDecoderSideToL);
|
|
sd(this._wDecoderMidToR);
|
|
sd(this._wDecoderSideToR);
|
|
sd(this._wLMix);
|
|
sd(this._wRMix);
|
|
sd(this._wOutputMerger);
|
|
sd(this._wOutputNode);
|
|
}
|
|
|
|
// ==========================================
|
|
// Crossfeed creation
|
|
// ==========================================
|
|
|
|
_createCrossfeedNodes() {
|
|
const preset = CROSSFEED_PRESETS[this.crossfeedLevel] || CROSSFEED_PRESETS.medium;
|
|
const crossGain = Math.pow(10, preset.crossGainDb / 20);
|
|
const directGain = 1.0 - crossGain * 0.5; // Slightly reduce direct to compensate
|
|
|
|
this._cfSplitter = this.ctx.createChannelSplitter(2);
|
|
this._cfMerger = this.ctx.createChannelMerger(2);
|
|
|
|
// Direct paths
|
|
this._cfDirectL = this.ctx.createGain();
|
|
this._cfDirectL.gain.value = directGain;
|
|
this._cfDirectL.channelCount = 1;
|
|
this._cfDirectL.channelCountMode = 'explicit';
|
|
|
|
this._cfDirectR = this.ctx.createGain();
|
|
this._cfDirectR.gain.value = directGain;
|
|
this._cfDirectR.channelCount = 1;
|
|
this._cfDirectR.channelCountMode = 'explicit';
|
|
|
|
// Cross paths: L → R
|
|
this._cfFilterLR = this.ctx.createBiquadFilter();
|
|
this._cfFilterLR.type = 'lowpass';
|
|
this._cfFilterLR.frequency.value = preset.cutoff;
|
|
this._cfFilterLR.Q.value = 0.707;
|
|
this._cfFilterLR.channelCount = 1;
|
|
this._cfFilterLR.channelCountMode = 'explicit';
|
|
|
|
this._cfDelayLR = this.ctx.createDelay(0.01);
|
|
this._cfDelayLR.delayTime.value = preset.delayMs / 1000;
|
|
|
|
this._cfCrossLR = this.ctx.createGain();
|
|
this._cfCrossLR.gain.value = crossGain;
|
|
this._cfCrossLR.channelCount = 1;
|
|
this._cfCrossLR.channelCountMode = 'explicit';
|
|
|
|
// Cross paths: R → L
|
|
this._cfFilterRL = this.ctx.createBiquadFilter();
|
|
this._cfFilterRL.type = 'lowpass';
|
|
this._cfFilterRL.frequency.value = preset.cutoff;
|
|
this._cfFilterRL.Q.value = 0.707;
|
|
this._cfFilterRL.channelCount = 1;
|
|
this._cfFilterRL.channelCountMode = 'explicit';
|
|
|
|
this._cfDelayRL = this.ctx.createDelay(0.01);
|
|
this._cfDelayRL.delayTime.value = preset.delayMs / 1000;
|
|
|
|
this._cfCrossRL = this.ctx.createGain();
|
|
this._cfCrossRL.gain.value = crossGain;
|
|
this._cfCrossRL.channelCount = 1;
|
|
this._cfCrossRL.channelCountMode = 'explicit';
|
|
|
|
this._cfOutputNode = this.ctx.createGain();
|
|
}
|
|
|
|
_destroyCrossfeedNodes() {
|
|
const nodes = [
|
|
this._cfSplitter,
|
|
this._cfMerger,
|
|
this._cfDirectL,
|
|
this._cfDirectR,
|
|
this._cfCrossLR,
|
|
this._cfCrossRL,
|
|
this._cfFilterLR,
|
|
this._cfFilterRL,
|
|
this._cfDelayLR,
|
|
this._cfDelayRL,
|
|
this._cfOutputNode,
|
|
];
|
|
nodes.forEach((n) => {
|
|
try {
|
|
n?.disconnect();
|
|
} catch {
|
|
/* */
|
|
}
|
|
});
|
|
this._cfSplitter = null;
|
|
this._cfMerger = null;
|
|
this._cfDirectL = null;
|
|
this._cfDirectR = null;
|
|
this._cfCrossLR = null;
|
|
this._cfCrossRL = null;
|
|
this._cfFilterLR = null;
|
|
this._cfFilterRL = null;
|
|
this._cfDelayLR = null;
|
|
this._cfDelayRL = null;
|
|
this._cfOutputNode = null;
|
|
}
|
|
|
|
// ==========================================
|
|
// Multichannel HRTF creation
|
|
// ==========================================
|
|
|
|
async _createMultichannelNodes() {
|
|
const numChannels = Math.min(this.channelCount, CHANNEL_ANGLES_51.length);
|
|
|
|
this._mcSplitter = this.ctx.createChannelSplitter(numChannels);
|
|
this._mcMerger = this.ctx.createChannelMerger(2); // binaural output
|
|
|
|
this._mcLfeGain = this.ctx.createGain();
|
|
this._mcLfeGain.gain.value = 0.5;
|
|
this._mcLfeGain.channelCount = 1;
|
|
this._mcLfeGain.channelCountMode = 'explicit';
|
|
|
|
// Generate HRTF impulse responses
|
|
if (!this._hrtfBuffers || this._hrtfBuffers._preset !== this.hrtfPreset) {
|
|
this._hrtfBuffers = await generateHRTFSet(this.ctx, this.hrtfPreset);
|
|
this._hrtfBuffers._preset = this.hrtfPreset;
|
|
}
|
|
|
|
this._mcConvolversL = [];
|
|
this._mcConvolversR = [];
|
|
|
|
for (let i = 0; i < numChannels; i++) {
|
|
const chInfo = CHANNEL_ANGLES_51[i];
|
|
if (chInfo.isLFE) {
|
|
// Placeholder — LFE uses gain node instead
|
|
this._mcConvolversL.push(null);
|
|
this._mcConvolversR.push(null);
|
|
continue;
|
|
}
|
|
|
|
const hrtf = this._hrtfBuffers.get(i);
|
|
|
|
const convL = this.ctx.createConvolver();
|
|
convL.normalize = false;
|
|
convL.buffer = hrtf.left;
|
|
convL.channelCount = 1;
|
|
convL.channelCountMode = 'explicit';
|
|
|
|
const convR = this.ctx.createConvolver();
|
|
convR.normalize = false;
|
|
convR.buffer = hrtf.right;
|
|
convR.channelCount = 1;
|
|
convR.channelCountMode = 'explicit';
|
|
|
|
this._mcConvolversL.push(convL);
|
|
this._mcConvolversR.push(convR);
|
|
}
|
|
|
|
this._mcOutputNode = this.ctx.createGain();
|
|
}
|
|
|
|
_destroyMultichannelNodes() {
|
|
const sd = (n) => {
|
|
try {
|
|
n?.disconnect();
|
|
} catch {
|
|
/* */
|
|
}
|
|
};
|
|
sd(this._mcSplitter);
|
|
sd(this._mcMerger);
|
|
sd(this._mcLfeGain);
|
|
this._mcConvolversL.forEach(sd);
|
|
this._mcConvolversR.forEach(sd);
|
|
sd(this._mcOutputNode);
|
|
|
|
this._mcSplitter = null;
|
|
this._mcMerger = null;
|
|
this._mcLfeGain = null;
|
|
this._mcConvolversL = [];
|
|
this._mcConvolversR = [];
|
|
this._mcOutputNode = null;
|
|
}
|
|
|
|
// ==========================================
|
|
// Stereo widener creation
|
|
// ==========================================
|
|
|
|
_createWidenerNodes() {
|
|
this._wSplitter = this.ctx.createChannelSplitter(2);
|
|
this._wOutputMerger = this.ctx.createChannelMerger(2);
|
|
|
|
// M/S encoder gains
|
|
this._wMidL = this.ctx.createGain();
|
|
this._wMidL.gain.value = 0.5;
|
|
this._wMidL.channelCount = 1;
|
|
this._wMidL.channelCountMode = 'explicit';
|
|
|
|
this._wMidR = this.ctx.createGain();
|
|
this._wMidR.gain.value = 0.5;
|
|
this._wMidR.channelCount = 1;
|
|
this._wMidR.channelCountMode = 'explicit';
|
|
|
|
this._wSideL = this.ctx.createGain();
|
|
this._wSideL.gain.value = 0.5;
|
|
this._wSideL.channelCount = 1;
|
|
this._wSideL.channelCountMode = 'explicit';
|
|
|
|
this._wSideR = this.ctx.createGain();
|
|
this._wSideR.gain.value = -0.5;
|
|
this._wSideR.channelCount = 1;
|
|
this._wSideR.channelCountMode = 'explicit';
|
|
|
|
// Mono mix points
|
|
this._wMidMix = this.ctx.createGain();
|
|
this._wMidMix.channelCount = 1;
|
|
this._wMidMix.channelCountMode = 'explicit';
|
|
|
|
this._wSideMix = this.ctx.createGain();
|
|
this._wSideMix.channelCount = 1;
|
|
this._wSideMix.channelCountMode = 'explicit';
|
|
|
|
// Width control: mid and side gains
|
|
this._wMidGain = this.ctx.createGain();
|
|
this._wMidGain.gain.value = this._calcMidGain();
|
|
this._wSideGain = this.ctx.createGain();
|
|
this._wSideGain.gain.value = this._calcSideGain();
|
|
|
|
// M/S decoder
|
|
this._wDecoderMidToL = this.ctx.createGain();
|
|
this._wDecoderMidToL.gain.value = 1.0;
|
|
this._wDecoderSideToL = this.ctx.createGain();
|
|
this._wDecoderSideToL.gain.value = 1.0;
|
|
this._wDecoderMidToR = this.ctx.createGain();
|
|
this._wDecoderMidToR.gain.value = 1.0;
|
|
this._wDecoderSideToR = this.ctx.createGain();
|
|
this._wDecoderSideToR.gain.value = -1.0;
|
|
|
|
// L/R recombination
|
|
this._wLMix = this.ctx.createGain();
|
|
this._wLMix.channelCount = 1;
|
|
this._wLMix.channelCountMode = 'explicit';
|
|
this._wRMix = this.ctx.createGain();
|
|
this._wRMix.channelCount = 1;
|
|
this._wRMix.channelCountMode = 'explicit';
|
|
|
|
this._wOutputNode = this.ctx.createGain();
|
|
}
|
|
|
|
_destroyWidenerNodes() {
|
|
const nodes = [
|
|
this._wSplitter,
|
|
this._wOutputMerger,
|
|
this._wMidL,
|
|
this._wMidR,
|
|
this._wSideL,
|
|
this._wSideR,
|
|
this._wMidGain,
|
|
this._wSideGain,
|
|
this._wMidMix,
|
|
this._wSideMix,
|
|
this._wDecoderMidToL,
|
|
this._wDecoderSideToL,
|
|
this._wDecoderMidToR,
|
|
this._wDecoderSideToR,
|
|
this._wLMix,
|
|
this._wRMix,
|
|
this._wOutputNode,
|
|
];
|
|
nodes.forEach((n) => {
|
|
try {
|
|
n?.disconnect();
|
|
} catch {
|
|
/* */
|
|
}
|
|
});
|
|
this._wSplitter = null;
|
|
this._wOutputMerger = null;
|
|
this._wMidL = null;
|
|
this._wMidR = null;
|
|
this._wSideL = null;
|
|
this._wSideR = null;
|
|
this._wMidGain = null;
|
|
this._wSideGain = null;
|
|
this._wMidMix = null;
|
|
this._wSideMix = null;
|
|
this._wDecoderMidToL = null;
|
|
this._wDecoderSideToL = null;
|
|
this._wDecoderMidToR = null;
|
|
this._wDecoderSideToR = null;
|
|
this._wLMix = null;
|
|
this._wRMix = null;
|
|
this._wOutputNode = null;
|
|
}
|
|
|
|
_calcMidGain() {
|
|
// At amount=1.0, mid=1.0; at amount=2.0, mid~0.6; at amount=0, mid=2.0
|
|
return 2.0 - this.wideningAmount;
|
|
}
|
|
|
|
_calcSideGain() {
|
|
return this.wideningAmount;
|
|
}
|
|
|
|
// ==========================================
|
|
// Public API
|
|
// ==========================================
|
|
|
|
/**
|
|
* Enable/disable the entire binaural DSP block.
|
|
*/
|
|
async setEnabled(enabled) {
|
|
this.enabled = enabled;
|
|
if (enabled) {
|
|
await this._ensureNodesCreated();
|
|
}
|
|
this._connectInternal();
|
|
}
|
|
|
|
/**
|
|
* Detect channel count and configure mode accordingly.
|
|
* Call this when source changes or track starts playing.
|
|
* @param {number} channelCount - Number of channels in the source
|
|
*/
|
|
async detectAndConfigure(channelCount) {
|
|
const prevMode = this.mode;
|
|
const prevChannels = this.channelCount;
|
|
this.channelCount = channelCount;
|
|
|
|
if (channelCount > 2) {
|
|
this.mode = 'multichannel';
|
|
} else {
|
|
this.mode = 'stereo';
|
|
}
|
|
|
|
if (this.enabled && (this.mode !== prevMode || channelCount !== prevChannels)) {
|
|
await this._ensureNodesCreated();
|
|
this._connectInternal();
|
|
|
|
window.dispatchEvent(
|
|
new CustomEvent('binaural-mode-changed', {
|
|
detail: { mode: this.mode, channels: this.channelCount },
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set crossfeed level.
|
|
* @param {'low'|'medium'|'high'} level
|
|
*/
|
|
setCrossfeedLevel(level) {
|
|
if (!CROSSFEED_PRESETS[level]) return;
|
|
this.crossfeedLevel = level;
|
|
|
|
// Update existing crossfeed nodes if they exist
|
|
if (this._cfFilterLR) {
|
|
const preset = CROSSFEED_PRESETS[level];
|
|
const crossGain = Math.pow(10, preset.crossGainDb / 20);
|
|
const directGain = 1.0 - crossGain * 0.5;
|
|
const now = this.ctx.currentTime;
|
|
|
|
this._cfFilterLR.frequency.setTargetAtTime(preset.cutoff, now, 0.005);
|
|
this._cfFilterRL.frequency.setTargetAtTime(preset.cutoff, now, 0.005);
|
|
this._cfDelayLR.delayTime.setTargetAtTime(preset.delayMs / 1000, now, 0.005);
|
|
this._cfDelayRL.delayTime.setTargetAtTime(preset.delayMs / 1000, now, 0.005);
|
|
this._cfCrossLR.gain.setTargetAtTime(crossGain, now, 0.005);
|
|
this._cfCrossRL.gain.setTargetAtTime(crossGain, now, 0.005);
|
|
this._cfDirectL.gain.setTargetAtTime(directGain, now, 0.005);
|
|
this._cfDirectR.gain.setTargetAtTime(directGain, now, 0.005);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enable/disable crossfeed sub-feature.
|
|
*/
|
|
async setCrossfeedEnabled(enabled) {
|
|
this.crossfeedEnabled = enabled;
|
|
if (this.enabled) {
|
|
await this._ensureNodesCreated();
|
|
this._connectInternal();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set HRTF preset (changes virtual speaker angles).
|
|
* @param {'intimate'|'studio'|'wide'} preset
|
|
*/
|
|
async setHrtfPreset(preset) {
|
|
if (!HRTF_PRESETS[preset]) return;
|
|
this.hrtfPreset = preset;
|
|
|
|
if (this.enabled && this.mode === 'multichannel') {
|
|
// Regenerate HRTF buffers with new angles
|
|
this._destroyMultichannelNodes();
|
|
await this._createMultichannelNodes();
|
|
this._connectInternal();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set stereo widening amount.
|
|
* @param {number} amount - 0.0 (mono) to 2.0 (extra wide), 1.0 = neutral
|
|
*/
|
|
setWideningAmount(amount) {
|
|
this.wideningAmount = Math.max(0, Math.min(2, amount));
|
|
|
|
if (this._wMidGain && this._wSideGain) {
|
|
const now = this.ctx.currentTime;
|
|
this._wMidGain.gain.setTargetAtTime(this._calcMidGain(), now, 0.005);
|
|
this._wSideGain.gain.setTargetAtTime(this._calcSideGain(), now, 0.005);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enable/disable stereo widening sub-feature.
|
|
*/
|
|
async setWideningEnabled(enabled) {
|
|
this.wideningEnabled = enabled;
|
|
if (this.enabled) {
|
|
await this._ensureNodesCreated();
|
|
this._connectInternal();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure all required nodes are created for the current mode.
|
|
*/
|
|
async _ensureNodesCreated() {
|
|
// Always create widener and crossfeed nodes
|
|
if (!this._cfOutputNode && this.crossfeedEnabled) {
|
|
this._createCrossfeedNodes();
|
|
}
|
|
if (!this._wOutputNode && this.wideningEnabled) {
|
|
this._createWidenerNodes();
|
|
}
|
|
if (this.mode === 'multichannel' && !this._mcOutputNode) {
|
|
await this._createMultichannelNodes();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current processing mode info.
|
|
*/
|
|
getStatus() {
|
|
return {
|
|
enabled: this.enabled,
|
|
mode: this.mode,
|
|
channels: this.channelCount,
|
|
crossfeed: { enabled: this.crossfeedEnabled, level: this.crossfeedLevel },
|
|
hrtfPreset: this.hrtfPreset,
|
|
widening: { enabled: this.wideningEnabled, amount: this.wideningAmount },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Destroy all nodes and clean up.
|
|
*/
|
|
destroy() {
|
|
this._disconnectAll();
|
|
this._destroyCrossfeedNodes();
|
|
this._destroyMultichannelNodes();
|
|
this._destroyWidenerNodes();
|
|
this._hrtfBuffers = null;
|
|
}
|
|
}
|
|
|
|
export { CROSSFEED_PRESETS, HRTF_PRESETS };
|