Repository navigation
fix(linux): use pactl to detect PipeWire/PulseAudio monitor for system audio capture - #457
Conversation
âĶ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).
ð WalkthroughWalkthroughAdded a new helper function Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effortðŊ 3 (Moderate) | âąïļ ~22 minutes Poem
ðĨ Pre-merge checks | â 3â Passed checks (3 passed)
âïļ Tip: You can configure your own custom pre-merge checks in the settings. âĻ Finishing Touchesð§Š Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
ð§đ Nitpick comments (1)
tauri/src-tauri/src/audio_capture/linux.rs (1)
359-376:is_supported()now spawns up to twopactlprocesses per call â consider caching.If this is invoked from UI paths (e.g. polling to enable/disable a button), every call forks
pactltwice. Cache the result in aOnceLock<bool>(or anAtomicBoolwith 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
ð Files selected for processing (1)
tauri/src-tauri/src/audio_capture/linux.rs
| 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); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| // 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); | ||
| } |
There was a problem hiding this comment.
ð§Đ 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:
- 1: https://doc.rust-lang.org/stable/std/env/fn.set_var.html
- 2: https://doc.rust-lang.org/beta/std/env/fn.set_var.html
- 3: https://doc.rust-lang.org/std/env/fn.set_var.html
- 4: https://doc.rust-lang.org/edition-guide/rust-2024/newly-unsafe-functions.html
- 5: https://doc.rust-lang.org/1.84.1/std/env/fn.set_var.html
- 6: https://doc.rust-lang.org/nightly/std/env/fn.set_var.html
- 7: https://doc.rust-lang.org/1.77.1/std/env/fn.set_var.html
- 8: https://doc.rust-lang.org/beta/edition-guide/rust-2024/newly-unsafe-functions.html
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:
-
Undefined behavior / data race.
std::env::set_varis unsafe to call in multi-threaded programs. Rust 2024 Edition marks it asunsafe 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'ssetenvand theenvironarray are not thread-safe on Unix-like systems. A Tauri app has multiple live threads (tokio runtime, webview, plugins, etc.) whenstart_captureruns, creating a real race condition, not just theoretical. -
Process-global state leak.
PULSE_SOURCEremains 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 instop_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) withPULSE_SOURCEset only for that child viaCommand::env, piping PCM back instead of modifying the process environment, or - If modification is unavoidable, set
PULSE_SOURCEonce 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.
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.15uses 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 todefault_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:pactl get-default-sink.monitorsource viapactl list short sourcesPULSE_SOURCEenv var beforecpal::default_host()initialization â this is the documented PulseAudio mechanism for overriding the default capture sourceThe original name-based device search is preserved as a fallback when
pactlis unavailable.Changes
tauri/src-tauri/src/audio_capture/linux.rsstd::process::Commandto shell out topactlstart_capture,stop_capture,is_supported) unchangedpactlis missing, behavior is identical to beforeTesting
Tested on:
pipewire-pulsecompatibility layeralsa_output.pci-0000_0d_00.6.analog-stereo)Console output confirms monitor detection:
Summary by CodeRabbit