kv-music/js/binaural-dsp.js
tryptz 5bbc36cdb1 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
2026-04-10 16:06:04 +03:00

699 lines
23 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 };
}
/**
* 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 };