Files
ChatApp/apps/desktop/src/lib/screenAudio.ts
T
byGalax 825160ee46 feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:35:01 +02:00

93 lines
2.8 KiB
TypeScript

// System-audio loopback. Under Electron this is renderer-driven: we
// ask main for the primary screen's capturer id, then call
// getUserMedia with Chromium's `chromeMediaSource: 'desktop'`
// constraint to obtain the OS-mixer MediaStream directly. The whole
// Tauri WASAPI + AudioWorklet base64 pipeline is gone.
//
// Windows-only in practice (loopback audio is a Windows feature of
// Chromium's desktop source). On other platforms `startSystemAudioCapture`
// throws `SystemAudioUnavailable`; callers are expected to fall back
// to the standard getDisplayMedia flow.
import { isTauriRuntime } from './globalShortcut';
export interface SystemAudioHandle {
/** Monotonic id, used by callers to correlate stop() with start. */
captureId: number;
/** MediaStream with a single audio track carrying the OS mixer. */
stream: MediaStream;
/** Teardown — stops the MediaStreamTrack. Idempotent. */
stop: () => Promise<void>;
}
export class SystemAudioUnavailable extends Error {
constructor(reason: string) {
super('system audio unavailable: ' + reason);
this.name = 'SystemAudioUnavailable';
}
}
interface ChromiumAudioConstraint {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
};
}
export async function startSystemAudioCapture(): Promise<SystemAudioHandle> {
if (!isTauriRuntime()) {
throw new SystemAudioUnavailable('not an electron runtime');
}
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
throw new SystemAudioUnavailable('mediaDevices unavailable');
}
const resolved = await window.electronAPI.resolveLoopbackSource();
if (!resolved) {
throw new SystemAudioUnavailable('no screen source available');
}
const audioConstraint: ChromiumAudioConstraint = {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: resolved.sourceId,
},
};
let stream: MediaStream;
try {
// Cast: the Chromium `mandatory` constraint is non-standard and
// not covered by lib.dom.d.ts typings.
stream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraint as unknown as MediaTrackConstraints,
video: false,
});
} catch (err: unknown) {
throw new SystemAudioUnavailable(
err instanceof Error ? err.message : String(err),
);
}
const tracks = stream.getAudioTracks();
if (tracks.length === 0) {
for (const t of stream.getTracks()) t.stop();
throw new SystemAudioUnavailable('no audio track in returned stream');
}
const captureId = Date.now();
let stopped = false;
const stop = async (): Promise<void> => {
if (stopped) return;
stopped = true;
for (const track of stream.getTracks()) {
try {
track.stop();
} catch {
/* already stopped */
}
}
};
return { captureId, stream, stop };
}