- Clear default input value (use empty string) - Add ALSA hardware capabilities query via: - /proc/asound/cardX/stream0 for USB devices - aplay --dump-hw-params for detailed hw params - Parse rate ranges [min max] and expand to common rates - Now shows actual DAC capabilities, not just current config
608 lines
21 KiB
Rust
608 lines
21 KiB
Rust
//! 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<Vec<BackendInfo>, String> {
|
|
log::info!("Command: get_available_backends");
|
|
|
|
let backends = BackendManager::available_backends();
|
|
let backend_infos: Vec<BackendInfo> = 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<Vec<AudioDevice>, 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<Vec<AlsaPluginInfo>, 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<u32>,
|
|
pub formats: Vec<String>,
|
|
pub channels: Option<u32>,
|
|
pub description: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// 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<String> = None;
|
|
let mut current_id: Option<String> = 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<u32> = 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::<u32>() {
|
|
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::<u32>() {
|
|
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<u32> = 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::<u32>() {
|
|
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::<u32>() {
|
|
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::<u32>() {
|
|
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::<u32>() {
|
|
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::<u32>() {
|
|
caps.channels = Some(ch);
|
|
}
|
|
}
|
|
// Rate like 192000Hz
|
|
if part.ends_with("Hz") {
|
|
if let Ok(rate) = part.trim_end_matches("Hz").parse::<u32>() {
|
|
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
|
|
}
|