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 <noreply@anthropic.com>
This commit is contained in:
Harshvardhan Vatsa 2026-07-16 16:16:55 +05:30
parent ab03a2d619
commit 342504e12b
2 changed files with 60 additions and 4 deletions

View file

@ -48,7 +48,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";
@ -226,6 +227,28 @@ protocol.registerSchemesAsPrivileged([
const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture", "clipboard-read", "clipboard-sanitized-write"]);
// 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<void> {
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.
}
}
function configureSessionPermissions(targetSession: Session): void {
targetSession.setPermissionCheckHandler((_webContents, permission) => {
return ALLOWED_SESSION_PERMISSIONS.has(permission);
@ -238,7 +261,18 @@ function configureSessionPermissions(targetSession: Session): void {
// 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.
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({});

View file

@ -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');