fix: RNNoise mono-left-only audio via explicit ChannelMerger stereo upmix

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.
This commit is contained in:
Jannis Braun
2026-02-23 00:37:50 +01:00
parent ccf2001047
commit dc57856506
2 changed files with 24 additions and 14 deletions
+12 -7
View File
@@ -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);
+12 -7
View File
@@ -27,6 +27,7 @@ export class AudioManager {
private streamGeneration = 0;
private inputSwitchChain: Promise<MediaStream | null> = 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;