Skip to content
Open
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
75 changes: 70 additions & 5 deletions frontend/src-tauri/src/audio/devices/configuration.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use anyhow::{anyhow, Result};
use lazy_static::lazy_static;
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::atomic::AtomicU64;
Expand Down Expand Up @@ -121,15 +122,19 @@ pub async fn get_device_and_config(
use cpal::traits::{DeviceTrait, HostTrait};

let host = cpal::default_host();
debug!("[DeviceConfig] Looking for device: '{}' (type: {:?})", audio_device.name, audio_device.device_type);

match audio_device.device_type {
DeviceType::Input => {
debug!("[DeviceConfig] Searching input devices for microphone...");
for device in host.input_devices()? {
if let Ok(name) = device.name() {
debug!("[DeviceConfig] Checking input device: '{}'", name);
if name == audio_device.name {
let default_config = device
.default_input_config()
.map_err(|e| anyhow!("Failed to get default input config: {}", e))?;
info!("[DeviceConfig] Found microphone: '{}' with config: {:?}", name, default_config);
return Ok((device, default_config));
}
}
Expand All @@ -154,19 +159,79 @@ 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()? {
// For Linux, system audio uses PulseAudio/PipeWire monitor sources
// Monitor sources are INPUT devices that capture audio from output sinks
info!("[DeviceConfig] Linux: Looking for system audio device '{}' in input devices", audio_device.name);

// Check if this is a PulseAudio/PipeWire source (contains "alsa_output" or ends with ".monitor")
let is_pulseaudio_source = audio_device.name.contains("alsa_output")
|| audio_device.name.contains("alsa_input")
|| audio_device.name.ends_with(".monitor");

if is_pulseaudio_source {
info!("[DeviceConfig] Linux: '{}' is a PulseAudio/PipeWire source", audio_device.name);

// For PulseAudio sources, we need to set PULSE_SOURCE and use "pulse" ALSA device
// This tells the pulse ALSA plugin which source to capture from
std::env::set_var("PULSE_SOURCE", &audio_device.name);
info!("[DeviceConfig] Linux: Set PULSE_SOURCE={}", audio_device.name);

// Find and use the "pulse" ALSA device
for device in host.input_devices()? {
if let Ok(name) = device.name() {
if name == audio_device.name {
if name == "pulse" {
let default_config = device
.default_input_config()
.map_err(|e| anyhow!("Failed to get default input config: {}", e))?;
.map_err(|e| anyhow!("Failed to get pulse input config: {}", e))?;
info!("[DeviceConfig] Linux: Using 'pulse' device to capture from '{}', config: {:?}",
audio_device.name, default_config);
return Ok((device, default_config));
}
}
}

warn!("[DeviceConfig] Linux: 'pulse' ALSA device not found - PulseAudio/ALSA plugin may not be installed");
}

// Collect all available input devices for debugging
let mut available_inputs: Vec<String> = Vec::new();

// Search input devices for the monitor source (ALSA direct)
for device in host.input_devices()? {
if let Ok(name) = device.name() {
available_inputs.push(name.clone());
debug!("[DeviceConfig] Linux: Checking input device: '{}'", name);

// Exact match
if name == audio_device.name {
let default_config = device
.default_input_config()
.map_err(|e| anyhow!("Failed to get default input config: {}", e))?;
info!("[DeviceConfig] Linux: Found exact match for system audio: '{}' with config: {:?}",
name, default_config);
return Ok((device, default_config));
}
}
}

// Try partial matching (for friendly names or ALSA variants)
debug!("[DeviceConfig] Linux: No exact match, trying partial match...");
for device in host.input_devices()? {
if let Ok(name) = device.name() {
if name.contains(&audio_device.name) || audio_device.name.contains(&name) {
let default_config = device
.default_input_config()
.map_err(|e| anyhow!("Failed to get default input config: {}", e))?;
info!("[DeviceConfig] Linux: Found partial match for system audio: '{}' (requested: '{}')",
name, audio_device.name);
return Ok((device, default_config));
}
}
}

// Log available devices for troubleshooting
warn!("[DeviceConfig] Linux: System audio device '{}' not found!", audio_device.name);
warn!("[DeviceConfig] Linux: Available input devices: {:?}", available_inputs);
}
}
}
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
253 changes: 238 additions & 15 deletions frontend/src-tauri/src/audio/devices/platform/linux.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,256 @@
use anyhow::Result;
use cpal::traits::{DeviceTrait, HostTrait};
use log::{debug, info, warn};
use std::collections::HashMap;
use std::process::Command;

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

/// Configure Linux audio devices using ALSA/PulseAudio
/// Read friendly names from /proc/asound/cards
fn get_card_friendly_names() -> HashMap<String, String> {
let mut names = HashMap::new();

if let Ok(content) = std::fs::read_to_string("/proc/asound/cards") {
for line in content.lines() {
// Parse lines like: " 1 [Wireless ]: USB-Audio - JBL Quantum 910 Wireless"
let trimmed = line.trim();
if let Some(bracket_start) = trimmed.find('[') {
if let Some(bracket_end) = trimmed.find(']') {
let card_id = trimmed[bracket_start + 1..bracket_end].trim().to_string();
// Get friendly name after the " - "
if let Some(dash_pos) = trimmed.find(" - ") {
let friendly_name = trimmed[dash_pos + 3..].trim().to_string();
names.insert(card_id, friendly_name);
}
}
}
}
}

names
}

/// Convert ALSA device name to friendly name
fn make_friendly_name(alsa_name: &str, card_names: &HashMap<String, String>) -> String {
// Extract card name from formats like "hw:CARD=Wireless,DEV=0" or "plughw:CARD=PCH,DEV=1"
if let Some(card_start) = alsa_name.find("CARD=") {
let after_card = &alsa_name[card_start + 5..];
let card_id = if let Some(comma_pos) = after_card.find(',') {
&after_card[..comma_pos]
} else {
after_card
};

if let Some(friendly) = card_names.get(card_id) {
// Return friendly name with device info
return format!("{} ({})", friendly, alsa_name);
}
}

// Fallback to original name
alsa_name.to_string()
}

/// Get PulseAudio/PipeWire monitor sources via pactl command
/// Returns a list of (source_name, description) tuples for monitor sources
fn get_pulseaudio_monitors() -> Vec<(String, String)> {
let mut monitors = Vec::new();

// Try to get sources from pactl (works with PulseAudio and PipeWire)
let output = match Command::new("pactl")
.args(["list", "sources", "short"])
.output()
{
Ok(output) => output,
Err(e) => {
debug!("[Linux Audio] Failed to run pactl: {}", e);
return monitors;
}
};

if !output.status.success() {
debug!("[Linux Audio] pactl failed with status: {}", output.status);
return monitors;
}

let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
// Format: ID<tab>NAME<tab>MODULE<tab>FORMAT<tab>STATE
// Example: 60 alsa_output.pci-0000_00_1f.3.iec958-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
let source_name = parts[1].trim();
if source_name.ends_with(".monitor") {
info!("[Linux Audio] Found PulseAudio monitor: {}", source_name);
monitors.push((source_name.to_string(), source_name.to_string()));
}
}
}

monitors
}

/// Get PulseAudio/PipeWire input sources (microphones) via pactl command
fn get_pulseaudio_inputs() -> Vec<String> {
let mut inputs = Vec::new();

let output = match Command::new("pactl")
.args(["list", "sources", "short"])
.output()
{
Ok(output) => output,
Err(e) => {
debug!("[Linux Audio] Failed to run pactl for inputs: {}", e);
return inputs;
}
};

if !output.status.success() {
return inputs;
}

let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
let source_name = parts[1].trim();
// Include non-monitor sources as potential microphones
if !source_name.ends_with(".monitor") {
debug!("[Linux Audio] Found PulseAudio input: {}", source_name);
inputs.push(source_name.to_string());
}
}
}

inputs
}

/// Configure Linux audio devices using PulseAudio/PipeWire and ALSA
///
/// On Linux with PulseAudio/PipeWire, system audio capture works via monitor sources.
/// Monitor sources have names ending in ".monitor" and capture audio playing through
/// the corresponding output sink.
///
/// This function:
/// 1. Gets PulseAudio/PipeWire monitors via `pactl` (preferred for system audio)
/// 2. Gets PulseAudio/PipeWire inputs via `pactl` (for microphones)
/// 3. Falls back to ALSA devices from cpal if pactl unavailable
/// 4. Marks monitors as DeviceType::Output for UI display in "System Audio" dropdown
pub fn configure_linux_audio(host: &cpal::Host) -> Result<Vec<AudioDevice>> {
let mut devices = Vec::new();
let card_names = get_card_friendly_names();

// Add input devices
info!("[Linux Audio] Card friendly names: {:?}", card_names);

// First, try to get PulseAudio/PipeWire monitors (best for system audio)
info!("[Linux Audio] Checking for PulseAudio/PipeWire monitors...");
let pa_monitors = get_pulseaudio_monitors();
let pa_inputs = get_pulseaudio_inputs();

if !pa_monitors.is_empty() {
info!("[Linux Audio] Found {} PulseAudio/PipeWire monitor sources", pa_monitors.len());

// Add PulseAudio monitors as system audio devices
for (name, _desc) in &pa_monitors {
let friendly = make_monitor_friendly_name(name, &card_names);
info!("[Linux Audio] Adding monitor for system audio: {} -> {}", name, friendly);
devices.push(AudioDevice::new(name.clone(), DeviceType::Output));
}
}

if !pa_inputs.is_empty() {
info!("[Linux Audio] Found {} PulseAudio/PipeWire input sources", pa_inputs.len());

// Add PulseAudio inputs as microphones
for name in &pa_inputs {
let friendly = make_friendly_name(name, &card_names);
info!("[Linux Audio] Adding PulseAudio input (microphone): {} -> {}", name, friendly);
devices.push(AudioDevice::new(name.clone(), DeviceType::Input));
}
}

// Also enumerate ALSA devices from cpal for fallback/additional devices
info!("[Linux Audio] Enumerating ALSA devices from cpal...");
for device in host.input_devices()? {
if let Ok(name) = device.name() {
devices.push(AudioDevice::new(name, DeviceType::Input));
// Skip if we already have this device from PulseAudio
if devices.iter().any(|d| d.name == name) {
continue;
}

debug!("[Linux Audio] Found ALSA input device: {}", name);

if name.contains(".monitor") || name.to_lowercase().contains("monitor") {
// Monitor source for system audio
let friendly = make_monitor_friendly_name(&name, &card_names);
info!("[Linux Audio] Found ALSA monitor (system audio): {} -> {}", name, friendly);
devices.push(AudioDevice::new(name, DeviceType::Output));
} else if name.contains("Loopback") || name.to_lowercase().contains("loopback") {
// ALSA loopback device for system audio
let friendly = make_friendly_name(&name, &card_names);
info!("[Linux Audio] Found ALSA loopback (system audio): {} -> {}", name, friendly);
devices.push(AudioDevice::new(name, DeviceType::Output));
} else {
// Regular microphone
let friendly = make_friendly_name(&name, &card_names);
info!("[Linux Audio] Found ALSA microphone: {} -> {}", name, friendly);
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
));
}
// Count device types for logging
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] Total devices found: {} ({} microphones, {} system audio sources)",
devices.len(), mic_count, sys_count);

if sys_count == 0 {
warn!("[Linux Audio] No monitor sources found! System audio capture may not work.");
warn!("[Linux Audio] Ensure PulseAudio/PipeWire is running.");
warn!("[Linux Audio] Run 'pactl list sources short' to check available sources.");
}

Ok(devices)
}

/// Create a user-friendly name for monitor sources
/// Extracts meaningful info from PulseAudio monitor names like:
/// "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" -> "Built-in Audio (System Audio)"
fn make_monitor_friendly_name(monitor_name: &str, card_names: &HashMap<String, String>) -> String {
// Try to extract card identifier from the monitor name
// Format: alsa_output.{card_info}.{profile}.monitor

// Check for common patterns
if monitor_name.contains("pci-") {
// Built-in audio card
return "Built-in Audio (System Audio)".to_string();
}

if monitor_name.contains("usb-") {
// USB audio device
// Try to find friendly name from card_names
for (card_id, friendly) in card_names {
if monitor_name.to_lowercase().contains(&card_id.to_lowercase()) {
return format!("{} (System Audio)", friendly);
}
}
return "USB Audio (System Audio)".to_string();
}

Ok(devices)
if monitor_name.contains("hdmi") || monitor_name.contains("HDMI") {
return "HDMI Audio (System Audio)".to_string();
}

if monitor_name.contains("Loopback") || monitor_name.contains("loopback") {
return "System Loopback (System Audio)".to_string();
}

if monitor_name.contains("bluetooth") || monitor_name.contains("bluez") {
return "Bluetooth (System Audio)".to_string();
}

// Default: just add system audio suffix
format!("{} (System Audio)", monitor_name)
}
Loading