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