mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
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:
parent
ab03a2d619
commit
342504e12b
2 changed files with 60 additions and 4 deletions
|
|
@ -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({});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue