feat(call): native WASAPI system-audio for screen-share (Windows)
Hooks the custom screen-share picker up to a native WASAPI loopback capture so "Mit System-Sound" no longer falls back to the OS picker on Windows. Rust side opens the default render endpoint, channels 48 kHz f32 stereo to an AudioWorklet, which feeds a MediaStreamDestination for LiveKit to publish as ScreenShareAudio. Ring buffer sized for latency (80 ms target, drop-to-target on overflow) and the AudioContext is resumed eagerly so initial burstiness can't pile up. Adds a temporary attachTrack:audio diagnostic log to confirm source tagging matches between old and new clients. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,10 @@ import {
|
||||
NativeCaptureUnavailable,
|
||||
startNativeCapture,
|
||||
} from '../lib/screenCapture';
|
||||
import {
|
||||
type SystemAudioHandle,
|
||||
startSystemAudioCapture,
|
||||
} from '../lib/screenAudio';
|
||||
import {
|
||||
clearScreenShareVolumes,
|
||||
getScreenShareVolume,
|
||||
@@ -305,6 +309,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
// on the canvas track's 'ended' event. Not kept in React state because
|
||||
// it never feeds into a render.
|
||||
const nativeCaptureRef = useRef<NativeCaptureHandle | null>(null);
|
||||
// Matching handle for the Windows-only WASAPI system-audio capture.
|
||||
// Lives in lockstep with the video handle above when the user picks
|
||||
// "Mit System-Sound"; teardown is wired so that stopping either track
|
||||
// also stops the other, so stale audio can't outlive the video share.
|
||||
const nativeAudioCaptureRef = useRef<SystemAudioHandle | null>(null);
|
||||
const roomRef = useRef<Room | null>(null);
|
||||
// Web Audio graph that mixes live mic + soundboard sources into a single
|
||||
// published track. Created per call in joinRoom, destroyed in disconnectRoom.
|
||||
@@ -486,6 +495,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
nativeCaptureRef.current = null;
|
||||
await h.stop().catch(() => undefined);
|
||||
}
|
||||
if (nativeAudioCaptureRef.current) {
|
||||
const h = nativeAudioCaptureRef.current;
|
||||
nativeAudioCaptureRef.current = null;
|
||||
await h.stop().catch(() => undefined);
|
||||
}
|
||||
roomRef.current = null;
|
||||
setRoom(null);
|
||||
setRemoteParticipants([]);
|
||||
@@ -1190,24 +1204,25 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
const sourceId = overrides?.sourceId ?? null;
|
||||
|
||||
// Native capture path — tried first when the user came in via our
|
||||
// custom picker. xcap on the Rust side grabs frames, streams them as
|
||||
// JPEG over a Tauri channel, and we draw them onto a canvas whose
|
||||
// captureStream() becomes the MediaStream LiveKit publishes. This
|
||||
// completely skips the OS picker. Video-only (no system audio yet);
|
||||
// if the user asked for audio we fall through to the legacy paths
|
||||
// below so audio still works via getDisplayMedia.
|
||||
// custom picker. xcap on the Rust side grabs video frames and, on
|
||||
// Windows with "Mit System-Sound" on, the WASAPI loopback module
|
||||
// grabs the render endpoint. Both stream over Tauri channels into
|
||||
// tracks we publish directly to LiveKit — the OS picker never
|
||||
// appears. If native audio fails on a platform that can't supply
|
||||
// it (non-Windows v1), we continue with video-only and log; the
|
||||
// user still gets their direct-video share.
|
||||
if (!sourceId) {
|
||||
console.info(
|
||||
'screen-share: no sourceId supplied by picker, OS picker will open',
|
||||
);
|
||||
} else if (settings.includeSystemAudio) {
|
||||
console.info(
|
||||
'screen-share: system audio requested — native path unavailable (needs WASAPI/ScreenCaptureKit), OS picker will open',
|
||||
);
|
||||
}
|
||||
if (sourceId && !settings.includeSystemAudio) {
|
||||
if (sourceId) {
|
||||
try {
|
||||
console.info('screen-share: trying native capture path', { sourceId, fps });
|
||||
console.info('screen-share: trying native capture path', {
|
||||
sourceId,
|
||||
fps,
|
||||
includeSystemAudio: settings.includeSystemAudio,
|
||||
});
|
||||
const { Track: LkTrack } = await import('livekit-client');
|
||||
const maxWidth = ssParams.dims?.width ?? 1920;
|
||||
const maxHeight = ssParams.dims?.height ?? 1080;
|
||||
@@ -1228,9 +1243,54 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
source: LkTrack.Source.ScreenShare,
|
||||
videoCodec: 'vp9',
|
||||
});
|
||||
|
||||
// Optional native audio. Failure here is non-fatal — the video
|
||||
// pipeline is already running and bailing out would be worse
|
||||
// UX than shipping a silent share. The warning surfaces the
|
||||
// platform gap so the user knows why their audio is missing.
|
||||
let audioHandle: SystemAudioHandle | null = null;
|
||||
if (settings.includeSystemAudio) {
|
||||
try {
|
||||
audioHandle = await startSystemAudioCapture();
|
||||
nativeAudioCaptureRef.current = audioHandle;
|
||||
const audioMst = audioHandle.stream.getAudioTracks()[0];
|
||||
if (audioMst) {
|
||||
const audioPub = await lp.publishTrack(audioMst, {
|
||||
source: LkTrack.Source.ScreenShareAudio,
|
||||
});
|
||||
audioMst.addEventListener('ended', () => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (audioPub.track) await lp.unpublishTrack(audioPub.track);
|
||||
} catch {
|
||||
/* already unpublished */
|
||||
}
|
||||
const active = nativeAudioCaptureRef.current;
|
||||
if (active && active.captureId === audioHandle!.captureId) {
|
||||
nativeAudioCaptureRef.current = null;
|
||||
await active.stop().catch(() => undefined);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn(
|
||||
'screen-share: native system-audio unavailable, sharing video only',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
if (nativeAudioCaptureRef.current) {
|
||||
await nativeAudioCaptureRef.current.stop().catch(() => undefined);
|
||||
nativeAudioCaptureRef.current = null;
|
||||
}
|
||||
audioHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Canvas stream 'ended' fires on handle.stop() (we track.stop()
|
||||
// each track) — chain unpublish + native teardown so one ended
|
||||
// event cleans everything up regardless of who triggered it.
|
||||
// Also tear down any paired audio capture so sound can't
|
||||
// outlive the video share.
|
||||
videoMst.addEventListener('ended', () => {
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -1243,10 +1303,17 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
nativeCaptureRef.current = null;
|
||||
await active.stop().catch(() => undefined);
|
||||
}
|
||||
const audioActive = nativeAudioCaptureRef.current;
|
||||
if (audioActive) {
|
||||
nativeAudioCaptureRef.current = null;
|
||||
await audioActive.stop().catch(() => undefined);
|
||||
}
|
||||
setIsScreenSharing(false);
|
||||
})();
|
||||
});
|
||||
console.info('screen-share: native capture active');
|
||||
console.info('screen-share: native capture active', {
|
||||
audio: audioHandle != null,
|
||||
});
|
||||
setIsScreenSharing(true);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
@@ -1257,6 +1324,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
await nativeCaptureRef.current.stop().catch(() => undefined);
|
||||
nativeCaptureRef.current = null;
|
||||
}
|
||||
if (nativeAudioCaptureRef.current) {
|
||||
await nativeAudioCaptureRef.current.stop().catch(() => undefined);
|
||||
nativeAudioCaptureRef.current = null;
|
||||
}
|
||||
console.warn(
|
||||
'screen-share: native path failed, falling back',
|
||||
err instanceof Error ? err.message : err,
|
||||
@@ -1403,6 +1474,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
console.warn('native capture stop failed', err);
|
||||
}
|
||||
}
|
||||
if (nativeAudioCaptureRef.current) {
|
||||
const h = nativeAudioCaptureRef.current;
|
||||
nativeAudioCaptureRef.current = null;
|
||||
try {
|
||||
await h.stop();
|
||||
} catch (err: unknown) {
|
||||
console.warn('native audio capture stop failed', err);
|
||||
}
|
||||
}
|
||||
const r = roomRef.current;
|
||||
if (!r) return;
|
||||
const lp = r.localParticipant;
|
||||
@@ -2284,6 +2364,20 @@ function attachTrack(
|
||||
if (isScreenShareAudio) {
|
||||
audio.setAttribute('data-track-source', 'screenshare');
|
||||
}
|
||||
// Diagnostic — surfaces source-tag mismatches between SDK versions.
|
||||
// If a remote participant publishes system-audio but the tag never
|
||||
// reaches us, `isScreenShareAudio` flips false and the watching
|
||||
// gate is bypassed; seeing this in the console tells us whether
|
||||
// the unwanted playback is a gating bug or a tagging mismatch.
|
||||
console.info('attachTrack:audio', {
|
||||
participant: participant.identity,
|
||||
trackSource: track.source,
|
||||
pubSource: publication.source,
|
||||
isScreenShareAudio,
|
||||
watching: participant.identity
|
||||
? watchingShareUserIdsMirror.has(participant.identity)
|
||||
: null,
|
||||
});
|
||||
if (participant.identity) {
|
||||
audio.setAttribute('data-participant', participant.identity);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user