Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ Build from source following our detailed guides:
- [Building on Linux](docs/building_in_linux.md)
- [General Build Instructions](docs/BUILDING.md)

**System audio capture** on Linux works via PulseAudio/PipeWire monitor sources.
Requires `pulseaudio-alsa` or `pipewire-alsa` (installed by default on Ubuntu, Fedora, and most desktop distros).
The app automatically captures from the monitor of your active output device (built-in, Bluetooth, HDMI, USB).

**Quick start:**

```bash
Expand Down
40 changes: 33 additions & 7 deletions frontend/src-tauri/src/audio/devices/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,18 +154,44 @@ pub async fn get_device_and_config(

#[cfg(target_os = "linux")]
{
// For Linux, we use PulseAudio monitor sources for system audio
if let Ok(pulse_host) = cpal::host_from_id(cpal::HostId::Alsa) {
for device in pulse_host.input_devices()? {
use log::{info, warn};

// On Linux, system audio devices are PulseAudio/PipeWire monitor sources.
// Monitor sources are accessible as INPUT devices via the "pulse" ALSA plugin
// when PULSE_SOURCE is set to the monitor name.
let is_pa_monitor = audio_device.name.ends_with(".monitor")
|| audio_device.name.contains("alsa_output");

if is_pa_monitor {
// Set PULSE_SOURCE so the "pulse" ALSA device reads from this monitor
std::env::set_var("PULSE_SOURCE", &audio_device.name);
info!("[Linux DeviceConfig] Set PULSE_SOURCE={}", audio_device.name);

// Use the "pulse" ALSA device to capture from the monitor
for device in host.input_devices()? {
if let Ok(name) = device.name() {
if name == audio_device.name {
let default_config = device
if name == "pulse" {
let config = device
.default_input_config()
.map_err(|e| anyhow!("Failed to get default input config: {}", e))?;
return Ok((device, default_config));
.map_err(|e| anyhow!("Failed to get pulse input config: {}", e))?;
info!("[Linux DeviceConfig] Capturing monitor '{}' via 'pulse' device", audio_device.name);
return Ok((device, config));
}
}
}
warn!("[Linux DeviceConfig] 'pulse' ALSA device not found — install pulseaudio-alsa or pipewire-alsa");
}

// Fallback: try to find the monitor as a direct ALSA input device
for device in host.input_devices()? {
if let Ok(name) = device.name() {
if name == audio_device.name {
let config = device
.default_input_config()
.map_err(|e| anyhow!("Failed to get input config: {}", e))?;
return Ok((device, config));
}
}
}
}
}
Expand Down
5 changes: 4 additions & 1 deletion frontend/src-tauri/src/audio/devices/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ pub async fn list_audio_devices() -> Result<Vec<AudioDevice>> {
}
};

// Add any additional devices from the default host
// On non-Linux platforms, supplement with any CPAL output devices not already listed.
// On Linux we rely exclusively on pactl enumeration in configure_linux_audio to avoid
// adding raw ALSA device names (pulse, default, hw:*) as spurious system audio options.
#[cfg(not(target_os = "linux"))]
if let Ok(other_devices) = host.devices() {
for device in other_devices {
if let Ok(name) = device.name() {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src-tauri/src/audio/devices/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ pub mod fallback;
pub use discovery::{list_audio_devices, trigger_audio_permission};
pub use microphone::{default_input_device, find_builtin_input_device};
pub use speakers::{default_output_device, find_builtin_output_device};
#[cfg(target_os = "linux")]
pub use speakers::default_system_audio_device;
pub use configuration::{get_device_and_config, parse_audio_device, AudioDevice, DeviceType, DeviceControl, AudioTranscriptionEngine, LAST_AUDIO_CAPTURE};

// Re-export fallback functions (platform-specific)
Expand Down
167 changes: 150 additions & 17 deletions frontend/src-tauri/src/audio/devices/platform/linux.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,166 @@
use anyhow::Result;
use cpal::traits::{DeviceTrait, HostTrait};
use log::{debug, info, warn};
use std::process::Command;

use crate::audio::devices::configuration::{AudioDevice, DeviceType};

/// Configure Linux audio devices using ALSA/PulseAudio
/// Query pactl for PulseAudio/PipeWire monitor sources (system audio)
/// Returns source names ending in ".monitor"
fn get_pulseaudio_monitors() -> Vec<String> {
let output = match Command::new("pactl").args(["list", "sources", "short"]).output() {
Ok(o) => o,
Err(e) => {
debug!("[Linux Audio] pactl not found: {}", e);
return Vec::new();
}
};

if !output.status.success() {
return Vec::new();
}

String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
// Format: ID\tNAME\tMODULE\tFORMAT\tSTATE
let name = line.split('\t').nth(1)?.trim();
if name.ends_with(".monitor") {
info!("[Linux Audio] Found monitor: {}", name);
Some(name.to_string())
} else {
None
}
})
.collect()
}

/// Query pactl for PulseAudio/PipeWire input sources (microphones)
fn get_pulseaudio_inputs() -> Vec<String> {
let output = match Command::new("pactl").args(["list", "sources", "short"]).output() {
Ok(o) => o,
Err(_) => return Vec::new(),
};

if !output.status.success() {
return Vec::new();
}

String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let name = line.split('\t').nth(1)?.trim();
if !name.ends_with(".monitor") {
debug!("[Linux Audio] Found input: {}", name);
Some(name.to_string())
} else {
None
}
})
.collect()
}

/// Configure Linux audio devices using PulseAudio/PipeWire and ALSA fallback.
///
/// Monitor sources (names ending in ".monitor") are exposed as DeviceType::Output
/// so they appear in the "System Audio" device selector in the UI.
/// Regular sources are exposed as DeviceType::Input (microphones).
pub fn configure_linux_audio(host: &cpal::Host) -> Result<Vec<AudioDevice>> {
let mut devices = Vec::new();
let mut devices: Vec<AudioDevice> = Vec::new();

// Prefer PulseAudio/PipeWire enumeration via pactl
let monitors = get_pulseaudio_monitors();
let inputs = get_pulseaudio_inputs();

if !monitors.is_empty() || !inputs.is_empty() {
info!("[Linux Audio] Using pactl: {} monitors, {} inputs", monitors.len(), inputs.len());

// Add input devices
for name in monitors {
devices.push(AudioDevice::new(name, DeviceType::Output));
}
for name in inputs {
devices.push(AudioDevice::new(name, DeviceType::Input));
}
}

// ALSA fallback / supplement: add any devices not already listed
for device in host.input_devices()? {
if let Ok(name) = device.name() {
let Ok(name) = device.name() else { continue };
if devices.iter().any(|d| d.name == name) {
continue;
}
debug!("[Linux Audio] ALSA device: {}", name);
if name.contains(".monitor") || name.to_lowercase().contains("loopback") {
devices.push(AudioDevice::new(name, DeviceType::Output));
} else {
devices.push(AudioDevice::new(name, DeviceType::Input));
}
}

// Add PulseAudio monitor sources for system audio
if let Ok(pulse_host) = cpal::host_from_id(cpal::HostId::Alsa) {
for device in pulse_host.input_devices()? {
if let Ok(name) = device.name() {
// Check if it's a monitor source
if name.contains("monitor") {
devices.push(AudioDevice::new(
format!("{} (System Audio)", name),
DeviceType::Output
));
}
let mic_count = devices.iter().filter(|d| d.device_type == DeviceType::Input).count();
let sys_count = devices.iter().filter(|d| d.device_type == DeviceType::Output).count();
info!("[Linux Audio] {} microphones, {} system audio sources", mic_count, sys_count);

if sys_count == 0 {
warn!("[Linux Audio] No monitor sources found. Run 'pactl list sources short' to verify PulseAudio/PipeWire is running.");
}

Ok(devices)
}

/// Get the PulseAudio default sink name via `pactl get-default-sink`
fn get_default_sink() -> Option<String> {
let output = Command::new("pactl").arg("get-default-sink").output().ok()?;
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() {
return Some(name);
}
}
None
}

/// Return the best system audio device for Linux (monitor source).
///
/// Priority:
/// 1. Monitor of the current default PulseAudio/PipeWire sink (follows whatever
/// the user has set as output — built-in, Bluetooth, USB, HDMI, etc.)
/// 2. First available monitor
/// 3. ALSA loopback
pub fn default_system_audio_device() -> Result<AudioDevice> {
let monitors = get_pulseaudio_monitors();

// Follow the actual default sink so we capture whatever the user hears,
// regardless of whether it is Bluetooth, built-in, HDMI, or USB.
if let Some(default_sink) = get_default_sink() {
let monitor_name = format!("{}.monitor", default_sink);
if let Some(name) = monitors.iter().find(|n| **n == monitor_name) {
info!("[Linux] Default system audio (follows default sink '{}'): {}", default_sink, name);
return Ok(AudioDevice::new(name.clone(), DeviceType::Output));
}
// Sink exists but its monitor wasn't listed — construct the name anyway
// and let the capture path resolve it via PULSE_SOURCE
info!("[Linux] Constructing monitor name for default sink '{}': {}", default_sink, monitor_name);
return Ok(AudioDevice::new(monitor_name, DeviceType::Output));
}

// Fall back to first available monitor
if let Some(name) = monitors.into_iter().next() {
info!("[Linux] Default system audio (first monitor fallback): {}", name);
return Ok(AudioDevice::new(name, DeviceType::Output));
}

// Last resort: ALSA loopback
let host = cpal::default_host();
for device in host.input_devices()? {
if let Ok(name) = device.name() {
if name.to_lowercase().contains("loopback") {
info!("[Linux] Using ALSA loopback as system audio: {}", name);
return Ok(AudioDevice::new(name, DeviceType::Output));
}
}
}

Ok(devices)
}
warn!("[Linux] No system audio device found. Ensure PulseAudio/PipeWire is running.");
anyhow::bail!("No system audio monitor source found on Linux. Run 'pactl list sources short' to check.")
}
8 changes: 8 additions & 0 deletions frontend/src-tauri/src/audio/devices/speakers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,12 @@ pub fn find_builtin_output_device() -> Result<Option<AudioDevice>> {

warn!("⚠️ No built-in speaker found (searched {} patterns)", builtin_patterns.len());
Ok(None)
}

/// Get the default system audio capture device on Linux via PulseAudio/PipeWire monitor source.
///
/// Delegates to the linux platform module which queries pactl.
#[cfg(target_os = "linux")]
pub fn default_system_audio_device() -> Result<AudioDevice> {
super::platform::linux::default_system_audio_device()
}
43 changes: 39 additions & 4 deletions frontend/src-tauri/src/audio/recording_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ use super::devices::{AudioDevice, list_audio_devices};
#[cfg(target_os = "macos")]
use super::devices::get_safe_recording_devices_macos;

#[cfg(not(target_os = "macos"))]
#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))]
use super::devices::{default_input_device, default_output_device};

#[cfg(target_os = "linux")]
use super::devices::{default_input_device, default_system_audio_device};
use super::recording_state::{RecordingState, AudioChunk, DeviceType as RecordingDeviceType};
use super::pipeline::AudioPipelineManager;
use super::stream::AudioStreamManager;
Expand Down Expand Up @@ -189,11 +192,44 @@ impl RecordingManager {
self.start_recording(microphone_device, system_device, auto_save).await
}

#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "linux")]
{
info!("🐧 [Linux] Starting recording with default devices");

let microphone_device = match default_input_device() {
Ok(device) => {
info!("[Linux] Using default microphone: {}", device.name);
Some(Arc::new(device))
}
Err(e) => {
warn!("[Linux] No default microphone available: {}", e);
None
}
};

// On Linux, system audio is captured via PulseAudio/PipeWire monitor sources
let system_device = match default_system_audio_device() {
Ok(device) => {
info!("[Linux] Using monitor source for system audio: {}", device.name);
Some(Arc::new(device))
}
Err(e) => {
warn!("[Linux] No system audio monitor available: {}. Only microphone will be captured.", e);
None
}
};

if microphone_device.is_none() {
return Err(anyhow::anyhow!("No microphone device available"));
}

self.start_recording(microphone_device, system_device, auto_save).await
}

#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))]
{
info!("Starting recording with default devices");

// Get default devices (no Bluetooth override on Windows/Linux)
let microphone_device = match default_input_device() {
Ok(device) => {
info!("Using default microphone: {}", device.name);
Expand All @@ -216,7 +252,6 @@ impl RecordingManager {
}
};

// Ensure at least microphone is available
if microphone_device.is_none() {
return Err(anyhow::anyhow!("No microphone device available"));
}
Expand Down