fix: eliminate voice ducking caused by rogue LiveKit audio elements

Add MutationObserver to neutralize LiveKit's re-attached <audio> elements,
mark our keep-alive elements with data-opencord, detach tracks on unsubscribe,
remove dangerous blanket .play(), and soften compressor to transparent limiter.
This commit is contained in:
Jannis Braun
2026-02-20 07:03:41 +01:00
parent 6f777f97ce
commit 5c38f076d3
22 changed files with 511 additions and 254 deletions
+38 -3
View File
@@ -6,6 +6,7 @@ export class AudioManager {
private inputDestination: MediaStreamAudioDestinationNode | null = null;
private silentGain: GainNode | null = null;
private analyser: AnalyserNode | null = null;
private masterCompressor: DynamicsCompressorNode | null = null;
private currentInputDeviceId: string = 'default';
private currentStream: MediaStream | null = null;
@@ -35,7 +36,17 @@ export class AudioManager {
this.silentGain = this.ctx.createGain();
this.silentGain.gain.value = 0;
// Master compressor/limiter — prevents clipping when multiple
// audio sources (voice + stream) sum at the output.
this.masterCompressor = this.ctx.createDynamicsCompressor();
this.masterCompressor.threshold.value = -1; // only engage near digital clipping
this.masterCompressor.knee.value = 0.5; // hard knee — transparent below threshold
this.masterCompressor.ratio.value = 4; // gentle limiting, no ducking
this.masterCompressor.attack.value = 0.0005; // 0.5ms — catch transient peaks
this.masterCompressor.release.value = 0.01; // 10ms — recover quickly
this.masterCompressor.connect(this.ctx.destination);
this.inputGain.connect(this.inputDestination);
this.inputGain.connect(this.analyser);
this.inputGain.connect(this.silentGain);
@@ -107,8 +118,8 @@ export class AudioManager {
gainNode.gain.value = options.volume ?? 0.5;
source.connect(gainNode);
gainNode.connect(this.ctx.destination);
gainNode.connect(this.masterCompressor!);
source.start(0);
return source;
}
@@ -178,6 +189,30 @@ export class AudioManager {
return this.analyser!;
}
/**
* Ensures the AudioContext exists and returns it.
* Unlike getContext(), this will never return null — it lazily
* creates the context if it hasn't been initialised yet.
* The context may be in 'suspended' state but Web Audio nodes
* can be created and connected regardless; audio will flow
* once the context resumes.
*/
ensureContext(): AudioContext {
if (!this.ctx) this.initContext();
return this.ctx!;
}
/**
* Returns the master output bus (DynamicsCompressorNode).
* All remote audio (voice, stream) should connect their GainNodes
* to this node instead of directly to ctx.destination. The compressor
* prevents clipping when multiple sources sum together.
*/
getMasterOutput(): AudioNode {
if (!this.ctx) this.initContext();
return this.masterCompressor!;
}
getContext(): AudioContext | null {
return this.ctx;
}