diff --git a/index.html b/index.html index e4eb366..473e9b7 100644 --- a/index.html +++ b/index.html @@ -4159,6 +4159,128 @@ +
+
+ Binaural / Spatial DSP + Multichannel HRTF rendering for Atmos & 3D Audio, crossfeed + for stereo +
+ +
+ + +
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>} + */ +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; +} diff --git a/js/player.js b/js/player.js index 508e9d5..1e23b6a 100644 --- a/js/player.js +++ b/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 ? ' Binaural' : ''); } 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; } diff --git a/js/settings.js b/js/settings.js index ee7c22c..958feb7 100644 --- a/js/settings.js +++ b/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) { diff --git a/js/storage.js b/js/storage.js index 8cf5d15..55ce212 100644 --- a/js/storage.js +++ b/js/storage.js @@ -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', diff --git a/styles.css b/styles.css index 3ad777e..0b50ce0 100644 --- a/styles.css +++ b/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; +}