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 {