Skip to content

fix(linux): use pactl to detect PipeWire/PulseAudio monitor for system audio capture - #457

Merged
jamiepine merged 1 commit into
jamiepine:mainfrom
estefrac:fix/linux-audio-capture-monitor-detection
Apr 18, 2026
Merged

jamiepine merged 1 commit into
jamiepine:mainfrom
estefrac:fix/linux-audio-capture-monitor-detection

Conversation

@estefrac

@estefrac estefrac commented Apr 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

System audio capture on Linux was silently falling back to the microphone input instead of capturing desktop audio, resulting in very low volume and electrical interference.

Root Cause

cpal 0.15 uses ALSA as its Linux backend. ALSA does not natively expose PulseAudio/PipeWire monitor sources. The existing code searched for "monitor" in cpal device names (line 56), which never matches on most Linux desktop systems — so it always fell back to default_input_device() (the physical microphone).

Both macOS (ScreenCaptureKit) and Windows (WASAPI loopback) use dedicated OS-level loopback APIs, but the Linux implementation relied on a name-string heuristic that doesn't work with cpal's ALSA backend.

Fix

Uses pactl (available on virtually all PulseAudio and PipeWire desktop systems) to:

  1. Detect the default sink via pactl get-default-sink
  2. Find its .monitor source via pactl list short sources
  3. Set PULSE_SOURCE env var before cpal::default_host() initialization — this is the documented PulseAudio mechanism for overriding the default capture source

The original name-based device search is preserved as a fallback when pactl is unavailable.

Changes

  • Only file modified: tauri/src-tauri/src/audio_capture/linux.rs
  • No new dependencies — uses std::process::Command to shell out to pactl
  • Zero breaking changes — public API (start_capture, stop_capture, is_supported) unchanged
  • Graceful degradation — if pactl is missing, behavior is identical to before

Testing

Tested on:

  • PipeWire 1.0.5 with pipewire-pulse compatibility layer
  • Realtek ALC897 codec (HD-Audio Generic, alsa_output.pci-0000_0d_00.6.analog-stereo)
  • System audio captured cleanly at full volume with no interference

Console output confirms monitor detection:

Linux audio capture: Found default sink monitor via pactl: alsa_output.pci-0000_0d_00.6.analog-stereo.monitor
Linux audio capture: Setting PULSE_SOURCE=alsa_output.pci-0000_0d_00.6.analog-stereo.monitor
Linux audio capture: Using PULSE_SOURCE monitor device: ...
Linux audio capture: Stream started successfully

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced audio source detection on Linux to better identify and select appropriate audio devices for capturing audio, with improved fallback mechanisms.

â€Ķm audio capture

cpal 0.15 uses ALSA as its Linux backend, which does not expose
PulseAudio/PipeWire monitor sources. The previous approach searched
for 'monitor' in cpal device names, which never matched on most
Linux systems, silently falling back to the microphone input.

This fix:
- Detects the correct monitor source via 'pactl get-default-sink'
  and 'pactl list short sources'
- Sets PULSE_SOURCE env var before cpal initialization so PulseAudio's
  ALSA plugin routes the default input through the monitor
- Preserves the original name-based search as fallback when pactl is
  unavailable
- No new dependencies added

Tested on PipeWire 1.0.5 with Realtek ALC897 (HD-Audio Generic).
@coderabbitai

coderabbitai Bot commented Apr 17, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Added a new helper function find_monitor_source_via_pactl() that invokes pactl to locate PulseAudio/PipeWire monitor sources, then integrated this detection into start_capture() and is_supported() to prioritize discovered monitor sources over fallback device scanning behavior.

Changes

Cohort / File(s) Summary
Linux Audio Capture Monitor Source Detection
tauri/src-tauri/src/audio_capture/linux.rs
Added find_monitor_source_via_pactl() helper to detect monitor sources via pactl command, preferring default sink's monitor then falling back to any monitor. Integrated into start_capture() to set PULSE_SOURCE when found and configure cpal device selection accordingly. Updated is_supported() to check pactl monitor availability first before legacy cpal scanning.

Sequence Diagram

sequenceDiagram
    participant StartCapture as start_capture()
    participant FindMonitor as find_monitor_source_via_pactl()
    participant Pactl as pactl (external)
    participant Env as PULSE_SOURCE<br/>(env var)
    participant Cpal as cpal Device<br/>Selection

    StartCapture->>FindMonitor: invoke()
    FindMonitor->>Pactl: execute 'pactl list sinks'
    Pactl-->>FindMonitor: sink info
    FindMonitor->>Pactl: execute 'pactl list sources'
    Pactl-->>FindMonitor: sources with *.monitor
    FindMonitor-->>StartCapture: Option<String> (monitor name)
    
    alt Monitor source found
        StartCapture->>Env: set PULSE_SOURCE = monitor_name
        StartCapture->>Cpal: select default input device
        Cpal-->>StartCapture: device handle
    else Monitor source not found
        StartCapture->>Cpal: scan for "monitor" in device names
        alt "monitor" device found
            Cpal-->>StartCapture: monitor device
        else Fallback to default
            Cpal-->>StartCapture: default input device
        end
    end
Loading

Estimated code review effort

ðŸŽŊ 3 (Moderate) | ⏱ïļ ~22 minutes

Poem

🐰 With whiskers twitching, code updated bright,
We hop through PulseAudio's audio flight,
pactl speaks where monitors hide,
Device detection now amplified,
Linux audio capture—captured just right! 🎙ïļ

ðŸšĨ Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and clearly summarizes the main change—detecting PipeWire/PulseAudio monitor sources via pactl for system audio capture on Linux, which directly addresses the root cause described in the PR objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏ïļ Tip: You can configure your own custom pre-merge checks in the settings.

âœĻ Finishing Touches
🧊 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

âĪïļ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

ðŸ§đ Nitpick comments (1)
tauri/src-tauri/src/audio_capture/linux.rs (1)

359-376: is_supported() now spawns up to two pactl processes per call — consider caching.

If this is invoked from UI paths (e.g. polling to enable/disable a button), every call forks pactl twice. Cache the result in a OnceLock<bool> (or an AtomicBool with a timestamp) unless you specifically need liveness, since device topology rarely changes mid-session.

ðŸĪ– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tauri/src-tauri/src/audio_capture/linux.rs` around lines 359 - 376, The
is_supported() function currently calls find_monitor_source_via_pactl() (and
cpal checks) on every invocation, spawning pactl repeatedly; add a static cache
(e.g. OnceLock<bool> named IS_AUDIO_CAPTURE_SUPPORTED) and have is_supported()
return the cached value if set, otherwise perform the existing detection (call
find_monitor_source_via_pactl(), then the cpal device checks and
host.default_input_device()), store the boolean result into the OnceLock and
return it; this prevents repeated pactl spawns while preserving the original
detection logic — use OnceLock<bool> unless you need periodic refresh, in which
case use an AtomicBool plus a timestamp and a short TTL.
ðŸĪ– Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tauri/src-tauri/src/audio_capture/linux.rs`:
- Around line 26-51: The current branch uses default_sink (from the pactl call)
without checking for an empty string; update the check around if let
Some(sink_name) = &default_sink to also require a non-empty trimmed value (e.g.,
sink_name.trim().is_empty() == false) before constructing monitor_name and
scanning stdout.lines(); if the sink name is empty, skip this branch and
optionally emit a clear log via the existing eprintln that no default sink was
found. Ensure you reference default_sink, sink_name, monitor_name, and the pactl
call when making the change so only non-empty sink names are treated as valid.
- Around line 104-114: The code currently calls
std::env::set_var("PULSE_SOURCE", ...) inside the capture path (see
find_monitor_source_via_pactl and the PULSE_SOURCE set block), which is unsafe
in multi-threaded programs and leaks process-global state; instead remove the
call to std::env::set_var from the capture thread and either (preferred) spawn
the audio capture helper as a child process with Command::env("PULSE_SOURCE",
source_name) and pipe its PCM output back (so only the child sees the env), or
(if the PulseAudio backend supports it) pass the monitor/source name directly
into the cpal initialization call; also ensure stop_capture does not assume
global env cleanup and remove any lingering use of PULSE_SOURCE in process-wide
state.

---

Nitpick comments:
In `@tauri/src-tauri/src/audio_capture/linux.rs`:
- Around line 359-376: The is_supported() function currently calls
find_monitor_source_via_pactl() (and cpal checks) on every invocation, spawning
pactl repeatedly; add a static cache (e.g. OnceLock<bool> named
IS_AUDIO_CAPTURE_SUPPORTED) and have is_supported() return the cached value if
set, otherwise perform the existing detection (call
find_monitor_source_via_pactl(), then the cpal device checks and
host.default_input_device()), store the boolean result into the OnceLock and
return it; this prevents repeated pactl spawns while preserving the original
detection logic — use OnceLock<bool> unless you need periodic refresh, in which
case use an AtomicBool plus a timestamp and a short TTL.
🊄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

â„đïļ Review info
⚙ïļ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b9df05ec-a818-4532-8f8c-e61361a2b13a

ðŸ“Ĩ Commits

Reviewing files that changed from the base of the PR and between 67bf8e9 and 0951242.

📒 Files selected for processing (1)
  • tauri/src-tauri/src/audio_capture/linux.rs

Comment on lines +26 to +51
let default_sink = std::process::Command::new("pactl")
.args(["get-default-sink"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
});

// If we know the default sink, look for its .monitor specifically
if let Some(sink_name) = &default_sink {
let monitor_name = format!("{}.monitor", sink_name);
for line in stdout.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 && parts[1] == monitor_name {
eprintln!(
"Linux audio capture: Found default sink monitor via pactl: {}",
monitor_name
);
return Some(monitor_name);
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠ïļ Potential issue | ðŸŸĄ Minor

Guard against an empty default_sink.

If pactl get-default-sink succeeds but prints an empty line (unusual but possible when no default is configured), sink_name becomes "" and monitor_name becomes ".monitor", which won't match any real source — harmless here, but the if let Some(sink_name) branch then silently falls through to the generic fallback. Worth an explicit non-empty check so the log/intent is clear:

Proposed tweak
-    if let Some(sink_name) = &default_sink {
+    if let Some(sink_name) = default_sink.as_deref().filter(|s| !s.is_empty()) {
         let monitor_name = format!("{}.monitor", sink_name);
ðŸĪ– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tauri/src-tauri/src/audio_capture/linux.rs` around lines 26 - 51, The current
branch uses default_sink (from the pactl call) without checking for an empty
string; update the check around if let Some(sink_name) = &default_sink to also
require a non-empty trimmed value (e.g., sink_name.trim().is_empty() == false)
before constructing monitor_name and scanning stdout.lines(); if the sink name
is empty, skip this branch and optionally emit a clear log via the existing
eprintln that no default sink was found. Ensure you reference default_sink,
sink_name, monitor_name, and the pactl call when making the change so only
non-empty sink names are treated as valid.

Comment on lines +104 to +114
// Try to set PULSE_SOURCE to a monitor before initializing cpal.
// This tells PulseAudio/PipeWire's ALSA plugin to use the monitor
// as the default input source for this process.
let monitor_source = find_monitor_source_via_pactl();
if let Some(ref source_name) = monitor_source {
eprintln!(
"Linux audio capture: Setting PULSE_SOURCE={}",
source_name
);
std::env::set_var("PULSE_SOURCE", source_name);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠ïļ Potential issue | 🟠 Major

ðŸ§Đ Analysis chain

🌐 Web query:

Is std::env::set_var safe to call from a spawned thread in a running Rust program, and what does the Rust std documentation (and Rust 2024 edition) say about its thread safety?

ðŸ’Ą Result:

No, std::env::set_var is not safe to call from a spawned thread in a running (multi-threaded) Rust program on non-Windows platforms. It is marked as an unsafe function and requires an unsafe block. Calling it in a multi-threaded context can lead to undefined behavior due to lack of thread-safety in underlying OS environment handling (e.g., on Unix-like systems via libc's setenv and environ). The Rust standard documentation (stable, beta, nightly as of 2026) explicitly states in the Safety section of std::env::set_var: "This function is safe to call in a single-threaded program. This function is also always safe to call on Windows, in single-threaded and multi-threaded programs. In multi-threaded programs on other operating systems, the only safe option is to not use set_var or remove_var at all. The exact requirement is: you must ensure that there are no other threads concurrently writing or reading(!) the environment through functions or global variables other than the ones in this module. ... All this makes it not practically possible for you to guarantee that no other thread will read the environment, so the only safe option is to not use set_var or remove_var in multi-threaded programs at all." In the Rust 2024 Edition (and later), std::env::set_var is newly marked as unsafe (pub unsafe fn), a breaking change from prior editions where it was safe but documented as potentially unsound. The Edition Guide explains: "It can be unsound to call std::env::set_var or std::env::remove_var in a multithreaded program due to safety limitations of the way the process environment is handled on some platforms. ... It is important to ensure that these functions are not called when any other thread might be running." For alternatives in multi-threaded programs, use Command::env to pass vars to child processes instead of modifying the current process's environment.

Citations:


std::env::set_var is unsafe in multi-threaded programs — use scoped alternatives instead.

Setting PULSE_SOURCE from inside thread::spawn creates two soundness issues:

  1. Undefined behavior / data race. std::env::set_var is unsafe to call in multi-threaded programs. Rust 2024 Edition marks it as unsafe fn (breaking change). The Rust std documentation states: "In multi-threaded programs on other operating systems, the only safe option is to not use set_var or remove_var at all." The underlying issue is that libc's setenv and the environ array are not thread-safe on Unix-like systems. A Tauri app has multiple live threads (tokio runtime, webview, plugins, etc.) when start_capture runs, creating a real race condition, not just theoretical.

  2. Process-global state leak. PULSE_SOURCE remains set for the rest of the process, so any future cpal/ALSA initialization (e.g., a microphone feature, or a second capture after the user changes the default sink) silently routes to the stale monitor. The variable is never unset in stop_capture, and the captured source becomes stale if the user switches outputs mid-session.

Alternatives:

  • Pass the source name directly to cpal if the PulseAudio backend accepts it, or
  • Spawn a child process (e.g., parec/pw-record) with PULSE_SOURCE set only for that child via Command::env, piping PCM back instead of modifying the process environment, or
  • If modification is unavoidable, set PULSE_SOURCE once at app startup (before threads spawn) with explicit documentation and restore it on stop.
ðŸĪ– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tauri/src-tauri/src/audio_capture/linux.rs` around lines 104 - 114, The code
currently calls std::env::set_var("PULSE_SOURCE", ...) inside the capture path
(see find_monitor_source_via_pactl and the PULSE_SOURCE set block), which is
unsafe in multi-threaded programs and leaks process-global state; instead remove
the call to std::env::set_var from the capture thread and either (preferred)
spawn the audio capture helper as a child process with
Command::env("PULSE_SOURCE", source_name) and pipe its PCM output back (so only
the child sees the env), or (if the PulseAudio backend supports it) pass the
monitor/source name directly into the cpal initialization call; also ensure
stop_capture does not assume global env cleanup and remove any lingering use of
PULSE_SOURCE in process-wide state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants