From dc578565060dcf25857e3c0f62f460ffe820eb1e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:37:50 +0100 Subject: [PATCH] fix: RNNoise mono-left-only audio via explicit ChannelMerger stereo upmix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RNNoise worklet outputs mono (1ch) which played only in the left ear. Replace unreliable automatic up-mixing (channelCount/channelCountMode on inputGain) with a ChannelMergerNode that duplicates the mono signal to both L and R channels — guaranteed stereo by the Web Audio spec. --- packages/web/src/audio/AudioManager.js | 19 ++++++++++++------- packages/web/src/audio/AudioManager.ts | 19 ++++++++++++------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/web/src/audio/AudioManager.js b/packages/web/src/audio/AudioManager.js index 831e86df..35cbeb16 100644 --- a/packages/web/src/audio/AudioManager.js +++ b/packages/web/src/audio/AudioManager.js @@ -25,6 +25,7 @@ export class AudioManager { streamGeneration = 0; inputSwitchChain = Promise.resolve(null); rnnoiseNode = null; + stereoMerger = null; rnnoiseEnabled = false; rnnoiseReady = false; constructor() { } @@ -40,11 +41,6 @@ export class AudioManager { const AudioContextClass = window.AudioContext || window.webkitAudioContext; this.ctx = new AudioContextClass({ sampleRate: 48000 }); this.inputGain = this.ctx.createGain(); - // Force stereo up-mix so mono sources (e.g. RNNoise worklet output) - // are duplicated to both L+R channels instead of left-only. - this.inputGain.channelCount = 2; - this.inputGain.channelCountMode = 'explicit'; - this.inputGain.channelInterpretation = 'speakers'; this.inputDestination = this.ctx.createMediaStreamDestination(); this.analyser = this.ctx.createAnalyser(); this.analyser.fftSize = 256; @@ -210,9 +206,18 @@ export class AudioManager { wasmBinary, maxChannels: 1, }); - this.rnnoiseNode.connect(this.inputGain); + + // Explicit mono→stereo: RNNoise outputs 1 channel, so duplicate it + // to both L and R via a ChannelMergerNode. This is spec-guaranteed + // stereo, unlike relying on automatic up-mixing which fails in some + // browsers when the source is an AudioWorkletNode. + this.stereoMerger = this.ctx.createChannelMerger(2); + this.rnnoiseNode.connect(this.stereoMerger, 0, 0); // mono → left + this.rnnoiseNode.connect(this.stereoMerger, 0, 1); // mono → right + this.stereoMerger.connect(this.inputGain); + this.rnnoiseReady = true; - console.log('[AudioManager] RNNoise worklet loaded and connected'); + console.log('[AudioManager] RNNoise worklet loaded and connected (mono→stereo via ChannelMerger)'); } catch (err) { console.error('[AudioManager] Failed to load RNNoise worklet:', err); diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index 2308d74f..792165ec 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -27,6 +27,7 @@ export class AudioManager { private streamGeneration = 0; private inputSwitchChain: Promise = Promise.resolve(null); private rnnoiseNode: AudioWorkletNode | null = null; + private stereoMerger: ChannelMergerNode | null = null; private rnnoiseEnabled = false; private rnnoiseReady = false; @@ -45,11 +46,6 @@ export class AudioManager { this.ctx = new AudioContextClass({ sampleRate: 48000 }); this.inputGain = this.ctx.createGain(); - // Force stereo up-mix so mono sources (e.g. RNNoise worklet output) - // are duplicated to both L+R channels instead of left-only. - this.inputGain.channelCount = 2; - this.inputGain.channelCountMode = 'explicit'; - this.inputGain.channelInterpretation = 'speakers'; this.inputDestination = this.ctx.createMediaStreamDestination(); this.analyser = this.ctx.createAnalyser(); this.analyser.fftSize = 256; @@ -258,9 +254,18 @@ export class AudioManager { wasmBinary, maxChannels: 1, }); - this.rnnoiseNode.connect(this.inputGain!); + + // Explicit mono→stereo: RNNoise outputs 1 channel, so duplicate it + // to both L and R via a ChannelMergerNode. This is spec-guaranteed + // stereo, unlike relying on automatic up-mixing which fails in some + // browsers when the source is an AudioWorkletNode. + this.stereoMerger = this.ctx!.createChannelMerger(2); + this.rnnoiseNode.connect(this.stereoMerger, 0, 0); // mono → left + this.rnnoiseNode.connect(this.stereoMerger, 0, 1); // mono → right + this.stereoMerger.connect(this.inputGain!); + this.rnnoiseReady = true; - console.log('[AudioManager] RNNoise worklet loaded and connected'); + console.log('[AudioManager] RNNoise worklet loaded and connected (mono→stereo via ChannelMerger)'); } catch (err) { console.error('[AudioManager] Failed to load RNNoise worklet:', err); this.rnnoiseReady = false;