From d589325c3cd46f4c2ebecf30f5be705a496aafe5 Mon Sep 17 00:00:00 2001 From: Harshvardhan Vatsa <76729417+hrsvrn@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:53:48 +0530 Subject: [PATCH] fix(x): make meeting system-audio capture work on Linux (#757) * fix(x): make meeting system-audio capture work on Linux (#722) Meeting transcription failed on Linux because the system-audio side of the recorder was unreliable there. Two real problems, neither of which was the one proposed in the issue: - desktopCapturer.getSources() goes through the Wayland screencast portal, which can block on a system dialog or hang outright. Audio-only display-media requests (meeting transcription discards the video track) now answer with the requesting frame as the mandatory video source and Chromium's PulseAudio loopback for audio - no portal involved. - Chromium captures loopback audio at the default sink's monitor-source volume. Desktop tools sometimes leave that volume near zero, which turns the capture into digital silence with no error anywhere. Before capture starts, ensureLinuxMonitorVolume() raises it back to 100% (raise-only, best-effort via pactl). The issue's proposed fix - enumerating PipeWire/Pulse monitor sources via enumerateDevices() and capturing with getUserMedia - is not viable: Chromium filters monitor sources out of input-device enumeration on Linux (audio_manager_pulse.cc), so they never appear. Verified against Electron 39 / Chromium 142 that audio:'loopback' works on Linux through the Pulse layer (PipeWire's pipewire-pulse or classic PulseAudio). On Linux the loopback track mirrors the output device rather than the meeting app, so "ended" never fires when the meeting closes; auto-stop relies on the existing silence detector. Capture failures now surface a toast pointing at PipeWire/PulseAudio instead of silently going idle. Co-Authored-By: Claude Fable 5 * fix(x): restore Linux system-audio capture lost in main merge The merge of main (5ad7ddb1) resolved the conflict in main.ts by taking main's refactored configureAppDisplayMediaHandler wholesale, dropping ensureLinuxMonitorVolume() and the Linux branch of the display-media handler while keeping the now-unused execFile/promisify imports. Re-apply both into the new handler structure: audio-requesting display media on Linux is answered with the requesting frame + 'loopback' (no Wayland portal), after raising a near-zero default-sink monitor volume back to 100% via pactl (raise-only, best-effort). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- apps/x/apps/main/src/main.ts | 38 ++++++++++++++++++- .../src/hooks/useMeetingTranscription.ts | 26 ++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 322f66eb..e442cba3 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -49,7 +49,8 @@ import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js"; import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js"; import started from "electron-squirrel-startup"; -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js"; @@ -250,13 +251,46 @@ function configureSessionPermissions(targetSession: Session, extraPermissions: r }); } +// On Linux, Chromium's loopback capture records the default sink's monitor +// through the PulseAudio layer, at the monitor source's own volume. Desktop +// tools sometimes leave that volume near zero — it's invisible plumbing that +// doesn't affect what the user hears — which turns the whole capture into +// digital silence with no error anywhere. Raise it back to 100% before +// capture starts (raise only, so a deliberate >100% boost is left alone). +// Best-effort: no pactl or no Pulse layer just skips. +async function ensureLinuxMonitorVolume(): Promise { + const execFileP = promisify(execFile); + try { + const { stdout: sinkOut } = await execFileP("pactl", ["get-default-sink"], { timeout: 3000 }); + const monitor = `${sinkOut.trim()}.monitor`; + const { stdout: volOut } = await execFileP("pactl", ["get-source-volume", monitor], { timeout: 3000 }); + const percents = [...volOut.matchAll(/(\d+)%/g)].map((m) => Number(m[1])); + if (percents.length === 0 || Math.min(...percents) >= 100) return; + await execFileP("pactl", ["set-source-volume", monitor, "100%"], { timeout: 3000 }); + console.log(`[meeting] Raised ${monitor} volume from ${Math.min(...percents)}% to 100% for system-audio capture`); + } catch { + // pactl missing or non-Pulse audio stack — nothing to fix here. + } +} + // Auto-approve display media requests and route system audio as loopback. // Electron requires a video source in the callback even if we only want audio. // We pass the first available screen source; the renderer discards the video track. // App session only — the embedded browser partition registers its own handler // (a user-facing source picker) in BrowserViewManager. function configureAppDisplayMediaHandler(targetSession: Session): void { - targetSession.setDisplayMediaRequestHandler(async (_request, callback) => { + targetSession.setDisplayMediaRequestHandler(async (request, callback) => { + // On Linux, enumerating screens via desktopCapturer goes through the + // Wayland screencast portal, which can block on a system dialog or hang + // outright. Requests that want audio (meeting transcription — the only + // audio consumer; it discards the video track) don't need a real screen, + // so answer with the requesting frame as the mandatory video source and + // Chromium's PulseAudio loopback for the audio. + if (process.platform === 'linux' && request.audioRequested && request.frame) { + await ensureLinuxMonitorVolume(); + callback({ video: request.frame, audio: 'loopback' }); + return; + } const sources = await desktopCapturer.getSources({ types: ['screen'] }); if (sources.length === 0) { callback({}); diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index b47146ef..4e5a73d5 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -54,6 +54,13 @@ const MUTE_POLLS_TO_STOP = 3; // event fires normally (handled by the listener in start()), so the poll below is // gated to macOS. const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac'); +// On Linux getDisplayMedia loopback works too (Chromium captures the default +// sink's monitor through the PulseAudio layer), but the request needs special +// handling in main.ts — see setDisplayMediaRequestHandler there. Note that +// capturing the monitor *source* directly via enumerateDevices/getUserMedia +// is NOT an option: Chromium filters monitor sources out of device +// enumeration on Linux (audio_manager_pulse.cc), so they never appear. +const isLinux = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('linux'); // --------------------------------------------------------------------------- // Headphone detection @@ -286,7 +293,10 @@ export function useMeetingTranscription(onAutoStop?: () => void) { autoGainControl: true, }, }), - // 4. Get system audio via getDisplayMedia (loopback) + // 4. Get system audio via getDisplayMedia (loopback). Works on all + // platforms; on Linux main.ts answers this request with the + // requesting frame as the throwaway video source (avoids the + // flaky Wayland screen-capture portal) + Pulse loopback audio. (async () => { const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }); stream.getVideoTracks().forEach(t => t.stop()); @@ -307,7 +317,15 @@ export function useMeetingTranscription(onAutoStop?: () => void) { if (failed) { if (wsResult.status === 'rejected') console.error('[meeting] WebSocket setup failed:', wsResult.reason); if (micResult.status === 'rejected') console.error('[meeting] Microphone access denied:', micResult.reason); - if (systemResult.status === 'rejected') console.error('[meeting] System audio access denied:', systemResult.reason); + if (systemResult.status === 'rejected') { + console.error('[meeting] System audio access denied:', systemResult.reason); + if (isLinux) { + toast.error('Could not capture system audio', { + description: 'Meeting audio capture needs PipeWire or PulseAudio. Make sure one of them is running, then try again.', + duration: 10000, + }); + } + } // Clean up any resources that did succeed if (wsResult.status === 'fulfilled') { wsResult.value.close(); } if (micResult.status === 'fulfilled') { micResult.value.getTracks().forEach(t => t.stop()); } @@ -375,6 +393,10 @@ export function useMeetingTranscription(onAutoStop?: () => void) { // "Stop sharing"), the track fires "ended" — treat that as the meeting // ending and stop. Our own cleanup() calls track.stop(), which does NOT // fire "ended", so this won't double-trigger on a manual stop. + // On Linux the loopback stream mirrors the default output device, not + // the meeting app, so it stays live after the meeting closes and + // "ended" never fires — auto-stop on Linux comes from the silence + // detector armed below. systemStream.getAudioTracks().forEach(track => { track.addEventListener('ended', () => { console.log('[meeting] system-audio track ended (shared source closed) — auto-stopping');