fix: address sender-side voice ducking with voice processing controls
Disable AGC by default to prevent Chrome from crushing mic sensitivity when stream audio is playing. Add user-facing toggles for echo cancellation, noise suppression, and auto gain control. Decouple voice and stream audio by routing through ctx.destination instead of shared compressor. Track mic stream generation to re-publish when settings change.
This commit is contained in:
@@ -12,6 +12,10 @@ export class AudioManager {
|
||||
isInitialized = false;
|
||||
listeners = new Set();
|
||||
soundBuffers = new Map();
|
||||
voiceEchoCancellation = true;
|
||||
voiceNoiseSuppression = true;
|
||||
voiceAutoGainControl = false;
|
||||
streamGeneration = 0;
|
||||
constructor() { }
|
||||
static getInstance() {
|
||||
if (!AudioManager.instance) {
|
||||
@@ -121,13 +125,14 @@ export class AudioManager {
|
||||
const constraints = {
|
||||
audio: {
|
||||
deviceId: deviceId === 'default' ? undefined : { exact: deviceId },
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
echoCancellation: this.voiceEchoCancellation,
|
||||
noiseSuppression: this.voiceNoiseSuppression,
|
||||
autoGainControl: this.voiceAutoGainControl,
|
||||
}
|
||||
};
|
||||
this.currentStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.currentInputDeviceId = deviceId;
|
||||
this.streamGeneration++;
|
||||
if (this.ctx && this.inputGain) {
|
||||
if (this.inputSource) {
|
||||
this.inputSource.disconnect();
|
||||
@@ -150,6 +155,28 @@ export class AudioManager {
|
||||
this.inputGain.gain.setTargetAtTime(gainValue, this.ctx.currentTime, 0.1);
|
||||
}
|
||||
}
|
||||
setVoiceProcessing(opts) {
|
||||
let changed = false;
|
||||
if (opts.echoCancellation !== undefined && opts.echoCancellation !== this.voiceEchoCancellation) {
|
||||
this.voiceEchoCancellation = opts.echoCancellation;
|
||||
changed = true;
|
||||
}
|
||||
if (opts.noiseSuppression !== undefined && opts.noiseSuppression !== this.voiceNoiseSuppression) {
|
||||
this.voiceNoiseSuppression = opts.noiseSuppression;
|
||||
changed = true;
|
||||
}
|
||||
if (opts.autoGainControl !== undefined && opts.autoGainControl !== this.voiceAutoGainControl) {
|
||||
this.voiceAutoGainControl = opts.autoGainControl;
|
||||
changed = true;
|
||||
}
|
||||
if (changed && this.currentStream) {
|
||||
this.currentStream.getTracks().forEach(t => t.stop());
|
||||
this.currentStream = null;
|
||||
}
|
||||
}
|
||||
getStreamGeneration() {
|
||||
return this.streamGeneration;
|
||||
}
|
||||
/**
|
||||
* CRITICAL: Always returns a CLONE of the destination track.
|
||||
* This prevents LiveKit's cleanup from killing the main singleton track
|
||||
@@ -190,7 +217,7 @@ export class AudioManager {
|
||||
getMasterOutput() {
|
||||
if (!this.ctx)
|
||||
this.initContext();
|
||||
return this.masterCompressor;
|
||||
return this.ctx.destination;
|
||||
}
|
||||
getContext() {
|
||||
return this.ctx;
|
||||
|
||||
@@ -14,6 +14,10 @@ export class AudioManager {
|
||||
|
||||
private listeners: Set<() => void> = new Set();
|
||||
private soundBuffers: Map<string, AudioBuffer> = new Map();
|
||||
private voiceEchoCancellation = true;
|
||||
private voiceNoiseSuppression = true;
|
||||
private voiceAutoGainControl = false;
|
||||
private streamGeneration = 0;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -140,14 +144,15 @@ export class AudioManager {
|
||||
const constraints = {
|
||||
audio: {
|
||||
deviceId: deviceId === 'default' ? undefined : { exact: deviceId },
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
echoCancellation: this.voiceEchoCancellation,
|
||||
noiseSuppression: this.voiceNoiseSuppression,
|
||||
autoGainControl: this.voiceAutoGainControl,
|
||||
}
|
||||
};
|
||||
|
||||
this.currentStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.currentInputDeviceId = deviceId;
|
||||
this.streamGeneration++;
|
||||
|
||||
if (this.ctx && this.inputGain) {
|
||||
if (this.inputSource) {
|
||||
@@ -172,6 +177,30 @@ export class AudioManager {
|
||||
}
|
||||
}
|
||||
|
||||
setVoiceProcessing(opts: { echoCancellation?: boolean; noiseSuppression?: boolean; autoGainControl?: boolean }) {
|
||||
let changed = false;
|
||||
if (opts.echoCancellation !== undefined && opts.echoCancellation !== this.voiceEchoCancellation) {
|
||||
this.voiceEchoCancellation = opts.echoCancellation;
|
||||
changed = true;
|
||||
}
|
||||
if (opts.noiseSuppression !== undefined && opts.noiseSuppression !== this.voiceNoiseSuppression) {
|
||||
this.voiceNoiseSuppression = opts.noiseSuppression;
|
||||
changed = true;
|
||||
}
|
||||
if (opts.autoGainControl !== undefined && opts.autoGainControl !== this.voiceAutoGainControl) {
|
||||
this.voiceAutoGainControl = opts.autoGainControl;
|
||||
changed = true;
|
||||
}
|
||||
if (changed && this.currentStream) {
|
||||
this.currentStream.getTracks().forEach(t => t.stop());
|
||||
this.currentStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
getStreamGeneration(): number {
|
||||
return this.streamGeneration;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRITICAL: Always returns a CLONE of the destination track.
|
||||
* This prevents LiveKit's cleanup from killing the main singleton track
|
||||
@@ -210,7 +239,7 @@ export class AudioManager {
|
||||
*/
|
||||
getMasterOutput(): AudioNode {
|
||||
if (!this.ctx) this.initContext();
|
||||
return this.masterCompressor!;
|
||||
return this.ctx!.destination;
|
||||
}
|
||||
|
||||
getContext(): AudioContext | null {
|
||||
|
||||
Reference in New Issue
Block a user