import { Room, Track, LocalAudioTrack } from 'livekit-client'; import { isElectron } from '../platform/platform'; /** * Turns the desktop app's native system-audio capture into a track LiveKit can * publish. * * The native module hands the main process raw PCM, which arrives here in * chunks over IPC. Web Audio has no "push samples" input, so each chunk is * scheduled as a buffer source on a running cursor — the standard way to play a * live stream without gaps. * * Exists because Electron's `audio: 'loopback'` captures the whole output mix, * this app's own playback included, so every listener heard themselves echo. */ class NativeScreenAudio { private ctx: AudioContext | null = null; private destination: MediaStreamAudioDestinationNode | null = null; private track: LocalAudioTrack | null = null; private unsubscribe: (() => void) | null = null; private nextStartTime = 0; /** * Held ahead of the cursor so a late chunk does not land in the past and get * dropped. Small enough not to be noticeable against the video. */ private static readonly BUFFER_AHEAD_S = 0.08; /** Beyond this the stream has stalled; resync rather than drift forever. */ private static readonly MAX_DRIFT_S = 0.5; isActive(): boolean { return this.track !== null; } async start(room: Room): Promise { if (!isElectron() || !window.backspace?.onNativeAudioData) return false; if (this.track) return true; this.ctx = new AudioContext(); this.destination = this.ctx.createMediaStreamDestination(); this.nextStartTime = 0; this.unsubscribe = window.backspace.onNativeAudioData((data, meta) => { this.enqueue(data, meta); }); const mediaTrack = this.destination.stream.getAudioTracks()[0]; if (!mediaTrack) { await this.stop(); return false; } this.track = new LocalAudioTrack(mediaTrack); try { await room.localParticipant.publishTrack(this.track, { source: Track.Source.ScreenShareAudio, }); return true; } catch (err) { console.error('[NativeScreenAudio] publish failed', err); await this.stop(); return false; } } private enqueue(data: ArrayBuffer, meta: { sampleRate: number; channels: number; isFloat: boolean; bitsPerSample: number }): void { const ctx = this.ctx; const destination = this.destination; if (!ctx || !destination) return; // Only float32 is handled: it is what every supported platform reports. // Anything else is dropped rather than played as noise. if (!meta.isFloat || meta.bitsPerSample !== 32) return; const samples = new Float32Array(data); const channels = Math.max(1, meta.channels); const frames = Math.floor(samples.length / channels); if (frames === 0) return; const buffer = ctx.createBuffer(channels, frames, meta.sampleRate); for (let channel = 0; channel < channels; channel++) { const channelData = buffer.getChannelData(channel); // Interleaved in, planar out. The bounds are computed from the buffer's // own length, but the index signature is still optional under // noUncheckedIndexedAccess — a truncated final chunk reads as silence // rather than NaN, which would click. for (let frame = 0; frame < frames; frame++) { channelData[frame] = samples[frame * channels + channel] ?? 0; } } const source = ctx.createBufferSource(); source.buffer = buffer; source.connect(destination); const now = ctx.currentTime; if (this.nextStartTime < now || this.nextStartTime > now + NativeScreenAudio.MAX_DRIFT_S) { this.nextStartTime = now + NativeScreenAudio.BUFFER_AHEAD_S; } source.start(this.nextStartTime); this.nextStartTime += buffer.duration; } async stop(): Promise { this.unsubscribe?.(); this.unsubscribe = null; window.backspace?.stopNativeAudio?.(); if (this.track) { try { this.track.stop(); } catch { /* already gone */ } this.track = null; } this.destination = null; if (this.ctx) { try { await this.ctx.close(); } catch { /* already closed */ } this.ctx = null; } this.nextStartTime = 0; } } export const nativeScreenAudio = new NativeScreenAudio();