From 5b848432172285764c70ff33cd637598916e4186 Mon Sep 17 00:00:00 2001 From: devsyncwrld Date: Tue, 1 Sep 2026 15:36:55 -0300 Subject: [PATCH] fix(screenshare): make shared system audio audible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listeners had to run the participant at 200% with attenuation off. Two causes, both on the publishing side. WASAPI returns the process mix at a level well below what a microphone track arrives at, so the track was quiet before it ever left the machine. A gain stage now boosts it, followed by a limiter — a compressor with a high ratio and fast attack — so the boost cannot clip loud passages. And the track was published with LiveKit's defaults, which are tuned for speech: mono, low bitrate, and DTX enabled, which stops transmitting during silence. On game and music audio that reads as thin and clipped. It now publishes with the music stereo preset, stereo forced, and DTX and RED off. Lives in packages/web, so it reaches the desktop app through a normal deploy — no new installer. --- packages/web/src/utils/nativeScreenAudio.ts | 38 +++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/web/src/utils/nativeScreenAudio.ts b/packages/web/src/utils/nativeScreenAudio.ts index e5032fa5..f9ab3231 100644 --- a/packages/web/src/utils/nativeScreenAudio.ts +++ b/packages/web/src/utils/nativeScreenAudio.ts @@ -1,4 +1,4 @@ -import { Room, Track, LocalAudioTrack } from 'livekit-client'; +import { Room, Track, LocalAudioTrack, AudioPresets } from 'livekit-client'; import { isElectron } from '../platform/platform'; /** @@ -16,6 +16,8 @@ import { isElectron } from '../platform/platform'; class NativeScreenAudio { private ctx: AudioContext | null = null; private destination: MediaStreamAudioDestinationNode | null = null; + private gain: GainNode | null = null; + private limiter: DynamicsCompressorNode | null = null; private track: LocalAudioTrack | null = null; private unsubscribe: (() => void) | null = null; private nextStartTime = 0; @@ -28,6 +30,14 @@ class NativeScreenAudio { /** Beyond this the stream has stalled; resync rather than drift forever. */ private static readonly MAX_DRIFT_S = 0.5; + /** + * WASAPI hands back the process's own mix, which sits far below the level a + * microphone track arrives at — listeners had to run the participant at 200% + * to hear anything. Boosted here rather than asking every listener to + * compensate, with a limiter after it so loud passages do not clip. + */ + private static readonly BOOST = 3.0; + isActive(): boolean { return this.track !== null; } @@ -38,6 +48,21 @@ class NativeScreenAudio { this.ctx = new AudioContext(); this.destination = this.ctx.createMediaStreamDestination(); + + // source → gain → limiter → destination. The limiter is a compressor with + // a high ratio and fast attack: it only engages on peaks the boost would + // otherwise clip, and is transparent below that. + this.gain = this.ctx.createGain(); + this.gain.gain.value = NativeScreenAudio.BOOST; + this.limiter = this.ctx.createDynamicsCompressor(); + this.limiter.threshold.value = -6; + this.limiter.knee.value = 0; + this.limiter.ratio.value = 20; + this.limiter.attack.value = 0.003; + this.limiter.release.value = 0.1; + this.gain.connect(this.limiter); + this.limiter.connect(this.destination); + this.nextStartTime = 0; this.unsubscribe = window.backspace.onNativeAudioData((data, meta) => { @@ -54,6 +79,13 @@ class NativeScreenAudio { try { await room.localParticipant.publishTrack(this.track, { source: Track.Source.ScreenShareAudio, + // Defaults are tuned for speech: mono, low bitrate, and DTX on, which + // stops transmitting during silence. On game and music audio that + // reads as clipped and thin. + audioPreset: AudioPresets.musicHighQualityStereo, + forceStereo: true, + dtx: false, + red: false, }); return true; } catch (err) { @@ -91,7 +123,7 @@ class NativeScreenAudio { const source = ctx.createBufferSource(); source.buffer = buffer; - source.connect(destination); + source.connect(this.gain ?? destination); const now = ctx.currentTime; if (this.nextStartTime < now || this.nextStartTime > now + NativeScreenAudio.MAX_DRIFT_S) { @@ -112,6 +144,8 @@ class NativeScreenAudio { } catch { /* already gone */ } this.track = null; } + this.gain = null; + this.limiter = null; this.destination = null; if (this.ctx) { try {