//! Tauri commands for audio backend management use crate::audio::{AlsaPlugin, AudioBackendType, AudioDevice, BackendManager}; use serde::{Deserialize, Serialize}; /// Backend information for UI display #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BackendInfo { pub backend_type: AudioBackendType, pub name: String, pub description: String, pub is_available: bool, } /// ALSA plugin information for UI display #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AlsaPluginInfo { pub plugin: AlsaPlugin, pub name: String, pub description: String, } /// Get list of available audio backends #[tauri::command] pub fn get_available_backends() -> Result, String> { log::info!("Command: get_available_backends"); let backends = BackendManager::available_backends(); let backend_infos: Vec = backends .into_iter() .map(|backend_type| { // Create backend instance to check availability and get description let backend = BackendManager::create_backend(backend_type); let (is_available, description) = match backend { Ok(b) => (b.is_available(), b.description().to_string()), Err(_) => (false, "Not available".to_string()), }; let name = match backend_type { AudioBackendType::PipeWire => "PipeWire", AudioBackendType::Alsa => "ALSA Direct", AudioBackendType::Pulse => "PulseAudio", }; BackendInfo { backend_type, name: name.to_string(), description, is_available, } }) .collect(); log::info!("Found {} backends", backend_infos.len()); for info in &backend_infos { log::info!(" - {} (available: {})", info.name, info.is_available); } Ok(backend_infos) } /// Get list of devices for a specific backend #[tauri::command] pub fn get_devices_for_backend(backend_type: AudioBackendType) -> Result, String> { log::info!("Command: get_devices_for_backend({:?})", backend_type); let backend = BackendManager::create_backend(backend_type)?; let devices = backend.enumerate_devices()?; log::info!("Found {} devices for {:?} backend", devices.len(), backend_type); for (idx, device) in devices.iter().enumerate() { log::info!(" [{}] {} (id: {})", idx, device.name, device.id); } Ok(devices) } /// Get list of available ALSA plugins #[tauri::command] pub fn get_alsa_plugins() -> Result, String> { log::info!("Command: get_alsa_plugins"); let plugins = vec![ AlsaPluginInfo { plugin: AlsaPlugin::Hw, name: "hw (Direct Hardware)".to_string(), description: "Bit-perfect, exclusive access, blocks device for other apps".to_string(), }, AlsaPluginInfo { plugin: AlsaPlugin::PlugHw, name: "plughw (Plugin Hardware)".to_string(), description: "Automatic format conversion, still relatively direct".to_string(), }, AlsaPluginInfo { plugin: AlsaPlugin::Pcm, name: "pcm (Default)".to_string(), description: "Generic ALSA device, most compatible".to_string(), }, ]; Ok(plugins) } /// Check if alsa-utils is installed (aplay command available) #[tauri::command] pub fn check_alsa_utils_installed() -> bool { use std::process::Command; Command::new("which") .arg("aplay") .output() .map(|o| o.status.success()) .unwrap_or(false) } /// Linux distribution info for install commands #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LinuxDistroInfo { pub distro_id: String, pub distro_name: String, pub install_command: String, } /// Detect Linux distribution and return appropriate install command for alsa-utils #[tauri::command] pub fn get_linux_distro() -> LinuxDistroInfo { use std::fs; // Try to read /etc/os-release let os_release = fs::read_to_string("/etc/os-release").unwrap_or_default(); let mut distro_id = String::new(); let mut distro_name = String::new(); for line in os_release.lines() { if let Some(id) = line.strip_prefix("ID=") { distro_id = id.trim_matches('"').to_lowercase(); } if let Some(name) = line.strip_prefix("NAME=") { distro_name = name.trim_matches('"').to_string(); } } // Determine install command based on distro let install_command = match distro_id.as_str() { "arch" | "manjaro" | "endeavouros" | "garuda" | "artix" => "sudo pacman -S alsa-utils".to_string(), "debian" | "ubuntu" | "linuxmint" | "pop" | "elementary" | "zorin" | "kali" => "sudo apt install alsa-utils".to_string(), "fedora" | "rhel" | "centos" | "rocky" | "alma" => "sudo dnf install alsa-utils".to_string(), "opensuse" | "opensuse-leap" | "opensuse-tumbleweed" => "sudo zypper install alsa-utils".to_string(), "void" => "sudo xbps-install alsa-utils".to_string(), "gentoo" => "sudo emerge media-sound/alsa-utils".to_string(), "nixos" => "nix-env -iA nixpkgs.alsa-utils".to_string(), _ => "# Install alsa-utils using your package manager".to_string(), }; LinuxDistroInfo { distro_id, distro_name, install_command, } } /// DAC capabilities information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DacCapabilities { pub node_name: String, pub sample_rates: Vec, pub formats: Vec, pub channels: Option, pub description: Option, pub error: Option, } /// Query DAC capabilities from PipeWire #[tauri::command] pub fn query_dac_capabilities(node_name: String) -> DacCapabilities { use std::process::Command; log::info!("Command: query_dac_capabilities({})", node_name); let mut caps = DacCapabilities { node_name: node_name.clone(), sample_rates: Vec::new(), formats: Vec::new(), channels: None, description: None, error: None, }; // First, try to get the node ID from the node name using pw-cli let pw_dump = Command::new("pw-cli") .args(["list-objects", "Node"]) .output(); let node_id = match pw_dump { Ok(output) if output.status.success() => { let stdout = String::from_utf8_lossy(&output.stdout); // Find the node ID for this node name // Format: id X, type PipeWire:Interface:Node/3 // ... node.name = "alsa_output.usb-..." let mut found_id: Option = None; let mut current_id: Option = None; for line in stdout.lines() { if line.contains("id ") && line.contains("type PipeWire:Interface:Node") { // Extract ID from line like "id 46, type PipeWire:Interface:Node/3" if let Some(id_part) = line.split("id ").nth(1) { if let Some(id) = id_part.split(',').next() { current_id = Some(id.trim().to_string()); } } } if line.contains("node.name") && line.contains(&node_name) { found_id = current_id.clone(); break; } } found_id } _ => None, }; // If we found the node ID, query its properties if let Some(id) = node_id { log::info!("Found node ID: {} for {}", id, node_name); // Query node properties using pw-cli let inspect = Command::new("pw-cli") .args(["info", &id]) .output(); if let Ok(output) = inspect { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); parse_pw_info(&stdout, &mut caps); } } // Also try pactl for additional info let pactl = Command::new("pactl") .args(["list", "sinks"]) .output(); if let Ok(output) = pactl { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); parse_pactl_sinks(&stdout, &node_name, &mut caps); } } } else { // Try pactl directly if pw-cli didn't find it let pactl = Command::new("pactl") .args(["list", "sinks"]) .output(); match pactl { Ok(output) if output.status.success() => { let stdout = String::from_utf8_lossy(&output.stdout); if !parse_pactl_sinks(&stdout, &node_name, &mut caps) { caps.error = Some("Device not found. Make sure the node name is correct.".to_string()); } } Ok(_) => { caps.error = Some("pactl command failed".to_string()); } Err(e) => { caps.error = Some(format!("Failed to run pactl: {}", e)); } } } // Query ALSA hardware capabilities for actual supported rates // This is more reliable than PipeWire/PulseAudio which only show current config query_alsa_hw_capabilities(&node_name, &mut caps); // Sort sample rates caps.sample_rates.sort(); caps.sample_rates.dedup(); // If we still don't have sample rates, add common defaults with a note if caps.sample_rates.is_empty() && caps.error.is_none() { caps.error = Some("Could not detect sample rates. Check device manually.".to_string()); } log::info!("DAC capabilities: {:?}", caps); caps } /// Query ALSA hardware capabilities from /proc/asound or aplay fn query_alsa_hw_capabilities(node_name: &str, caps: &mut DacCapabilities) { use std::fs; use std::process::Command; // Try to extract card info from node name // Format: alsa_output.usb-Manufacturer_Product-00.analog-stereo // or: alsa_output.pci-0000_00_1f.3.analog-stereo // First, try to find the card by listing /proc/asound/cards let cards_output = fs::read_to_string("/proc/asound/cards").unwrap_or_default(); // Try to match card by description from pactl or by USB identifier in node name let mut card_num: Option = None; // Extract USB identifier from node name if present let usb_id = if node_name.contains("usb-") { node_name .split("usb-") .nth(1) .and_then(|s| s.split('-').next()) .map(|s| s.to_lowercase()) } else { None }; // Search for matching card for line in cards_output.lines() { // Format: " 0 [Generic ]: USB-Audio - USB Audio" if let Some(num_str) = line.trim().split_whitespace().next() { if let Ok(num) = num_str.parse::() { let line_lower = line.to_lowercase(); // Match by USB identifier or description if let Some(ref usb) = usb_id { if line_lower.contains(usb) || line_lower.contains("usb") { card_num = Some(num); break; } } // Match by description if we have one if let Some(ref desc) = caps.description { if line_lower.contains(&desc.to_lowercase()) { card_num = Some(num); break; } } } } } // If we found a card, try to read its stream info if let Some(card) = card_num { log::info!("Found ALSA card {} for {}", card, node_name); // Try /proc/asound/cardX/stream0 for USB devices let stream_path = format!("/proc/asound/card{}/stream0", card); if let Ok(stream_info) = fs::read_to_string(&stream_path) { parse_alsa_stream_info(&stream_info, caps); } // Also try aplay --dump-hw-params for more detailed info // This requires a valid device, try common subdevices for subdev in 0..2 { let device = format!("hw:{},{}", card, subdev); let output = Command::new("aplay") .args(["-D", &device, "--dump-hw-params", "/dev/null"]) .output(); if let Ok(out) = output { // aplay outputs to stderr for --dump-hw-params let stderr = String::from_utf8_lossy(&out.stderr); if stderr.contains("RATE") { parse_aplay_hw_params(&stderr, caps); break; } } } } } /// Parse /proc/asound/cardX/stream0 output fn parse_alsa_stream_info(output: &str, caps: &mut DacCapabilities) { let mut in_playback = false; for line in output.lines() { let line = line.trim(); // Look for Playback section if line.starts_with("Playback:") { in_playback = true; continue; } if line.starts_with("Capture:") { in_playback = false; continue; } if in_playback { // Format line contains info like: "Format: S32_LE" if line.starts_with("Format:") { if let Some(format) = line.strip_prefix("Format:") { let format = format.trim().to_string(); if !format.is_empty() && !caps.formats.contains(&format) { caps.formats.push(format); } } } // Rates line: "Rates: 44100, 48000, 88200, 96000, 176400, 192000" if line.starts_with("Rates:") { if let Some(rates_str) = line.strip_prefix("Rates:") { for rate_str in rates_str.split(',') { if let Ok(rate) = rate_str.trim().parse::() { if !caps.sample_rates.contains(&rate) { caps.sample_rates.push(rate); } } } } } } } } /// Parse aplay --dump-hw-params output fn parse_aplay_hw_params(output: &str, caps: &mut DacCapabilities) { for line in output.lines() { let line = line.trim(); // RATE: [44100 192000] or RATE: 44100 48000 88200 96000 176400 192000 if line.starts_with("RATE:") { if let Some(rates_part) = line.strip_prefix("RATE:") { let rates_str = rates_part.trim(); // Check if it's a range [min max] if rates_str.starts_with('[') { // Range format: [44100 192000] // We'll add common rates within this range let common_rates = [44100, 48000, 88200, 96000, 176400, 192000, 352800, 384000]; let nums: Vec = rates_str .trim_matches(|c| c == '[' || c == ']') .split_whitespace() .filter_map(|s| s.parse().ok()) .collect(); if nums.len() >= 2 { let min = nums[0]; let max = nums[1]; for rate in common_rates { if rate >= min && rate <= max && !caps.sample_rates.contains(&rate) { caps.sample_rates.push(rate); } } } } else { // List format: 44100 48000 88200 96000 for rate_str in rates_str.split_whitespace() { if let Ok(rate) = rate_str.parse::() { if !caps.sample_rates.contains(&rate) { caps.sample_rates.push(rate); } } } } } } // FORMAT: S16_LE S24_LE S32_LE if line.starts_with("FORMAT:") { if let Some(formats_str) = line.strip_prefix("FORMAT:") { for format in formats_str.split_whitespace() { let format = format.trim().to_string(); if !format.is_empty() && !format.starts_with('[') && !caps.formats.contains(&format) { caps.formats.push(format); } } } } } } fn parse_pw_info(output: &str, caps: &mut DacCapabilities) { for line in output.lines() { let line = line.trim(); // Look for audio.rate or similar if line.contains("audio.rate") || line.contains("default.clock.rate") { if let Some(rate_str) = line.split('=').last() { if let Ok(rate) = rate_str.trim().trim_matches('"').parse::() { if !caps.sample_rates.contains(&rate) { caps.sample_rates.push(rate); } } } } // Look for allowed rates if line.contains("clock.allowed-rates") || line.contains("audio.allowed-rates") { // Format: [ 44100 48000 88200 96000 176400 192000 ] if let Some(rates_part) = line.split('[').last() { if let Some(rates_str) = rates_part.split(']').next() { for rate_str in rates_str.split_whitespace() { if let Ok(rate) = rate_str.parse::() { if !caps.sample_rates.contains(&rate) { caps.sample_rates.push(rate); } } } } } } // Look for audio format if line.contains("audio.format") || line.contains("format.dsp") { if let Some(format) = line.split('=').last() { let format = format.trim().trim_matches('"').to_string(); if !format.is_empty() && !caps.formats.contains(&format) { caps.formats.push(format); } } } // Look for channels if line.contains("audio.channels") { if let Some(ch_str) = line.split('=').last() { if let Ok(ch) = ch_str.trim().trim_matches('"').parse::() { caps.channels = Some(ch); } } } // Look for description if line.contains("node.description") { if let Some(desc) = line.split('=').last() { caps.description = Some(desc.trim().trim_matches('"').to_string()); } } } } fn parse_pactl_sinks(output: &str, node_name: &str, caps: &mut DacCapabilities) -> bool { let mut in_target_sink = false; let mut found = false; for line in output.lines() { let line_trimmed = line.trim(); // Check if we're entering a new sink block if line.starts_with("Sink #") { in_target_sink = false; } // Check if this is our target sink if line_trimmed.contains("Name:") && line_trimmed.contains(node_name) { in_target_sink = true; found = true; } if in_target_sink { // Get description if line_trimmed.starts_with("Description:") { if let Some(desc) = line_trimmed.strip_prefix("Description:") { caps.description = Some(desc.trim().to_string()); } } // Get sample spec (contains rate and format) if line_trimmed.starts_with("Sample Specification:") { // Format: "s32le 2ch 192000Hz" if let Some(spec) = line_trimmed.strip_prefix("Sample Specification:") { let parts: Vec<&str> = spec.trim().split_whitespace().collect(); for part in parts { // Format like s16le, s24le, s32le, float32le if part.starts_with('s') || part.starts_with("float") { let format = part.to_uppercase(); if !caps.formats.contains(&format) { caps.formats.push(format); } } // Channels like 2ch if part.ends_with("ch") { if let Ok(ch) = part.trim_end_matches("ch").parse::() { caps.channels = Some(ch); } } // Rate like 192000Hz if part.ends_with("Hz") { if let Ok(rate) = part.trim_end_matches("Hz").parse::() { if !caps.sample_rates.contains(&rate) { caps.sample_rates.push(rate); } } } } } } // Look for alternate sample rates in properties if line_trimmed.contains("device.buffering.buffer_size") { // End of useful properties for this sink } } } // Sort sample rates caps.sample_rates.sort(); found }