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 (#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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8a782358bc
commit
d589325c3c
2 changed files with 60 additions and 4 deletions
|
|
@ -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<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.
|
||||
}
|
||||
}
|
||||
|
||||
// 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({});
|
||||
|
|
|
|||
|
|
@ -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