fix(screenshare): make shared system audio audible
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s

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.
This commit is contained in:
2026-09-01 15:36:55 -03:00
parent bd8decb4b3
commit 5b84843217
+36 -2
View File
@@ -1,4 +1,4 @@
import { Room, Track, LocalAudioTrack } from 'livekit-client'; import { Room, Track, LocalAudioTrack, AudioPresets } from 'livekit-client';
import { isElectron } from '../platform/platform'; import { isElectron } from '../platform/platform';
/** /**
@@ -16,6 +16,8 @@ import { isElectron } from '../platform/platform';
class NativeScreenAudio { class NativeScreenAudio {
private ctx: AudioContext | null = null; private ctx: AudioContext | null = null;
private destination: MediaStreamAudioDestinationNode | null = null; private destination: MediaStreamAudioDestinationNode | null = null;
private gain: GainNode | null = null;
private limiter: DynamicsCompressorNode | null = null;
private track: LocalAudioTrack | null = null; private track: LocalAudioTrack | null = null;
private unsubscribe: (() => void) | null = null; private unsubscribe: (() => void) | null = null;
private nextStartTime = 0; private nextStartTime = 0;
@@ -28,6 +30,14 @@ class NativeScreenAudio {
/** Beyond this the stream has stalled; resync rather than drift forever. */ /** Beyond this the stream has stalled; resync rather than drift forever. */
private static readonly MAX_DRIFT_S = 0.5; 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 { isActive(): boolean {
return this.track !== null; return this.track !== null;
} }
@@ -38,6 +48,21 @@ class NativeScreenAudio {
this.ctx = new AudioContext(); this.ctx = new AudioContext();
this.destination = this.ctx.createMediaStreamDestination(); 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.nextStartTime = 0;
this.unsubscribe = window.backspace.onNativeAudioData((data, meta) => { this.unsubscribe = window.backspace.onNativeAudioData((data, meta) => {
@@ -54,6 +79,13 @@ class NativeScreenAudio {
try { try {
await room.localParticipant.publishTrack(this.track, { await room.localParticipant.publishTrack(this.track, {
source: Track.Source.ScreenShareAudio, 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; return true;
} catch (err) { } catch (err) {
@@ -91,7 +123,7 @@ class NativeScreenAudio {
const source = ctx.createBufferSource(); const source = ctx.createBufferSource();
source.buffer = buffer; source.buffer = buffer;
source.connect(destination); source.connect(this.gain ?? destination);
const now = ctx.currentTime; const now = ctx.currentTime;
if (this.nextStartTime < now || this.nextStartTime > now + NativeScreenAudio.MAX_DRIFT_S) { if (this.nextStartTime < now || this.nextStartTime > now + NativeScreenAudio.MAX_DRIFT_S) {
@@ -112,6 +144,8 @@ class NativeScreenAudio {
} catch { /* already gone */ } } catch { /* already gone */ }
this.track = null; this.track = null;
} }
this.gain = null;
this.limiter = null;
this.destination = null; this.destination = null;
if (this.ctx) { if (this.ctx) {
try { try {