diff --git a/src-tauri/src/audio/alsa_backend.rs b/src-tauri/src/audio/alsa_backend.rs index 12df62b20..26812dbfd 100644 --- a/src-tauri/src/audio/alsa_backend.rs +++ b/src-tauri/src/audio/alsa_backend.rs @@ -17,6 +17,49 @@ use rodio::{ }; use std::process::Command; +/// Common audio sample rates to check for device support +const COMMON_SAMPLE_RATES: &[u32] = &[ + 44100, // CD quality + 48000, // DVD/DAT quality + 88200, // 2x CD + 96000, // DVD-Audio + 176400, // 4x CD + 192000, // High-res audio + 352800, // DSD64 equivalent + 384000, // Ultra high-res +]; + +/// Extract supported sample rates from a CPAL device +fn get_supported_sample_rates(device: &rodio::cpal::Device) -> Option> { + use rodio::cpal::traits::DeviceTrait; + + let configs = device.supported_output_configs().ok()?; + let configs_vec: Vec<_> = configs.collect(); + + if configs_vec.is_empty() { + return None; + } + + let mut supported = Vec::new(); + + for rate in COMMON_SAMPLE_RATES { + let sample_rate = rodio::cpal::SampleRate(*rate); + // Check if any config supports this rate + let is_supported = configs_vec.iter().any(|config| { + sample_rate >= config.min_sample_rate() && sample_rate <= config.max_sample_rate() + }); + if is_supported { + supported.push(*rate); + } + } + + if supported.is_empty() { + None + } else { + Some(supported) + } +} + pub struct AlsaBackend { host: rodio::cpal::Host, } @@ -151,7 +194,7 @@ impl AlsaBackend { if !can_open { // Try to get supported configs to validate device exists // Create a test device to check if it's valid - if let Ok(test_devices) = self.host.output_devices() { + if let Ok(_test_devices) = self.host.output_devices() { // CPAL may not enumerate hw: devices but can still open them // We'll add them anyway and let the backend handle errors devices.push(AudioDevice { @@ -160,33 +203,38 @@ impl AlsaBackend { description: Some(format!("{} (Direct Hardware - Bit-perfect)", card_desc)), is_default: false, max_sample_rate: Some(384000), // Assume high sample rate capability + supported_sample_rates: None, // Can't detect without CPAL access }); } } else { - // CPAL found it, get real max sample rate - let max_sample_rate = self.host + // CPAL found it, get real sample rate info + let cpal_device = self.host .output_devices() .ok() .and_then(|mut devs| { devs.find(|d| d.name().ok().as_deref() == Some(&hw_device_id)) - }) - .and_then(|device| { - device - .supported_output_configs() - .ok() - .and_then(|mut configs| { - configs - .max_by_key(|c| c.max_sample_rate().0) - .map(|c| c.max_sample_rate().0) - }) }); + let max_sample_rate = cpal_device.as_ref().and_then(|device| { + device + .supported_output_configs() + .ok() + .and_then(|mut configs| { + configs + .max_by_key(|c| c.max_sample_rate().0) + .map(|c| c.max_sample_rate().0) + }) + }); + + let supported_sample_rates = cpal_device.as_ref().and_then(get_supported_sample_rates); + devices.push(AudioDevice { id: hw_device_id.clone(), name: hw_device_id.clone(), description: Some(format!("{} (Direct Hardware - Bit-perfect)", card_desc)), is_default: false, max_sample_rate, + supported_sample_rates, }); } @@ -198,6 +246,7 @@ impl AlsaBackend { description: Some(format!("{} (Plugin Hardware)", card_desc)), is_default: false, max_sample_rate: Some(384000), + supported_sample_rates: None, // plughw can convert, so all rates are "supported" }); } @@ -267,12 +316,16 @@ impl AlsaBackend { .map(|c| c.max_sample_rate().0) }); + // Get supported sample rates + let supported_sample_rates = get_supported_sample_rates(&device); + devices.push(AudioDevice { id: id.clone(), name: name.clone(), description: None, // CPAL doesn't provide descriptions is_default, max_sample_rate, + supported_sample_rates, }); } diff --git a/src-tauri/src/audio/backend.rs b/src-tauri/src/audio/backend.rs index 822929fd0..9e1471e49 100644 --- a/src-tauri/src/audio/backend.rs +++ b/src-tauri/src/audio/backend.rs @@ -80,6 +80,10 @@ pub struct AudioDevice { /// Maximum supported sample rate (if known) pub max_sample_rate: Option, + + /// Supported sample rates (common audio rates that the device supports) + /// Contains values like 44100, 48000, 88200, 96000, 176400, 192000, etc. + pub supported_sample_rates: Option>, } /// Audio backend configuration diff --git a/src-tauri/src/audio/pipewire_backend.rs b/src-tauri/src/audio/pipewire_backend.rs index 59aff9357..06c06ba5b 100644 --- a/src-tauri/src/audio/pipewire_backend.rs +++ b/src-tauri/src/audio/pipewire_backend.rs @@ -73,6 +73,7 @@ impl PipeWireBackend { description: None, is_default, max_sample_rate: current_max_rate.take(), + supported_sample_rates: None, // PipeWire handles sample rate conversion }); } current_max_rate = None; @@ -102,6 +103,7 @@ impl PipeWireBackend { description: None, is_default, max_sample_rate: current_max_rate, + supported_sample_rates: None, // PipeWire handles sample rate conversion }); } diff --git a/src/lib/components/DeviceDropdown.svelte b/src/lib/components/DeviceDropdown.svelte index c490125be..478e94493 100644 --- a/src/lib/components/DeviceDropdown.svelte +++ b/src/lib/components/DeviceDropdown.svelte @@ -12,6 +12,7 @@ value: string; // Display name id: string; // Device ID (for categorization) isDefault?: boolean; + sampleRates?: number[]; // Supported sample rates in Hz (e.g., 44100, 48000, 96000, 192000) } interface DeviceGroup { @@ -94,6 +95,17 @@ return groups; }); + // Format sample rates for display (e.g., "44.1, 48, 96, 192 kHz") + function formatSampleRates(rates: number[] | undefined): string | null { + if (!rates || rates.length === 0) return null; + const formatted = rates.map(r => { + const kHz = r / 1000; + // Show decimal only for 44.1, 88.2, 176.4, 352.8 + return kHz % 1 !== 0 ? kHz.toFixed(1) : kHz.toString(); + }); + return formatted.join(', ') + ' kHz'; + } + // Filtered groups based on search const filteredGroups = $derived.by(() => { if (!searchQuery.trim()) return groupedDevices; @@ -298,13 +310,20 @@
{group.label}
{#each group.devices as device (device.value)} + {@const sampleRatesText = formatSampleRates(device.sampleRates)}