Add supported sample rates detection and display for ALSA devices

Backend changes:
- Add supported_sample_rates field to AudioDevice struct
- Detect common audio sample rates (44.1, 48, 88.2, 96, 176.4, 192, 352.8, 384 kHz)
- Use CPAL's supported_output_configs() to check rate support for hw: devices

Frontend changes:
- Display sample rates as subtitle in DeviceDropdown (e.g., "44.1, 48, 96, 192 kHz")
- Only shown for bit-perfect devices where detection is available
- Search not sensitive to sample rates field (per user request)
This commit is contained in:
vicrodh committed 2026-01-30 11:11:05 -06:00
1 parent 91fa7c7f5f
commit 0ccdd9981f
5 files changed
+120 -20

No files matched your search

+66 -13
View File
@@ -17,6 +17,49 @@ use rodio::{
}; };
use std::process::Command; 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<Vec<u32>> {
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 { pub struct AlsaBackend {
host: rodio::cpal::Host, host: rodio::cpal::Host,
} }
@@ -151,7 +194,7 @@ impl AlsaBackend {
if !can_open { if !can_open {
// Try to get supported configs to validate device exists // Try to get supported configs to validate device exists
// Create a test device to check if it's valid // 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 // CPAL may not enumerate hw: devices but can still open them
// We'll add them anyway and let the backend handle errors // We'll add them anyway and let the backend handle errors
devices.push(AudioDevice { devices.push(AudioDevice {
@@ -160,33 +203,38 @@ impl AlsaBackend {
description: Some(format!("{} (Direct Hardware - Bit-perfect)", card_desc)), description: Some(format!("{} (Direct Hardware - Bit-perfect)", card_desc)),
is_default: false, is_default: false,
max_sample_rate: Some(384000), // Assume high sample rate capability max_sample_rate: Some(384000), // Assume high sample rate capability
supported_sample_rates: None, // Can't detect without CPAL access
}); });
} }
} else { } else {
// CPAL found it, get real max sample rate // CPAL found it, get real sample rate info
let max_sample_rate = self.host let cpal_device = self.host
.output_devices() .output_devices()
.ok() .ok()
.and_then(|mut devs| { .and_then(|mut devs| {
devs.find(|d| d.name().ok().as_deref() == Some(&hw_device_id)) 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 { devices.push(AudioDevice {
id: hw_device_id.clone(), id: hw_device_id.clone(),
name: hw_device_id.clone(), name: hw_device_id.clone(),
description: Some(format!("{} (Direct Hardware - Bit-perfect)", card_desc)), description: Some(format!("{} (Direct Hardware - Bit-perfect)", card_desc)),
is_default: false, is_default: false,
max_sample_rate, max_sample_rate,
supported_sample_rates,
}); });
} }
@@ -198,6 +246,7 @@ impl AlsaBackend {
description: Some(format!("{} (Plugin Hardware)", card_desc)), description: Some(format!("{} (Plugin Hardware)", card_desc)),
is_default: false, is_default: false,
max_sample_rate: Some(384000), 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) .map(|c| c.max_sample_rate().0)
}); });
// Get supported sample rates
let supported_sample_rates = get_supported_sample_rates(&device);
devices.push(AudioDevice { devices.push(AudioDevice {
id: id.clone(), id: id.clone(),
name: name.clone(), name: name.clone(),
description: None, // CPAL doesn't provide descriptions description: None, // CPAL doesn't provide descriptions
is_default, is_default,
max_sample_rate, max_sample_rate,
supported_sample_rates,
}); });
} }
+4
View File
@@ -80,6 +80,10 @@ pub struct AudioDevice {
/// Maximum supported sample rate (if known) /// Maximum supported sample rate (if known)
pub max_sample_rate: Option<u32>, pub max_sample_rate: Option<u32>,
/// 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<Vec<u32>>,
} }
/// Audio backend configuration /// Audio backend configuration
+2
View File
@@ -73,6 +73,7 @@ impl PipeWireBackend {
description: None, description: None,
is_default, is_default,
max_sample_rate: current_max_rate.take(), max_sample_rate: current_max_rate.take(),
supported_sample_rates: None, // PipeWire handles sample rate conversion
}); });
} }
current_max_rate = None; current_max_rate = None;
@@ -102,6 +103,7 @@ impl PipeWireBackend {
description: None, description: None,
is_default, is_default,
max_sample_rate: current_max_rate, max_sample_rate: current_max_rate,
supported_sample_rates: None, // PipeWire handles sample rate conversion
}); });
} }
+44 -5
View File
@@ -12,6 +12,7 @@
value: string; // Display name value: string; // Display name
id: string; // Device ID (for categorization) id: string; // Device ID (for categorization)
isDefault?: boolean; isDefault?: boolean;
sampleRates?: number[]; // Supported sample rates in Hz (e.g., 44100, 48000, 96000, 192000)
} }
interface DeviceGroup { interface DeviceGroup {
@@ -94,6 +95,17 @@
return groups; 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 // Filtered groups based on search
const filteredGroups = $derived.by(() => { const filteredGroups = $derived.by(() => {
if (!searchQuery.trim()) return groupedDevices; if (!searchQuery.trim()) return groupedDevices;
@@ -298,13 +310,20 @@
<div class="group"> <div class="group">
<div class="group-header">{group.label}</div> <div class="group-header">{group.label}</div>
{#each group.devices as device (device.value)} {#each group.devices as device (device.value)}
{@const sampleRatesText = formatSampleRates(device.sampleRates)}
<button <button
class="option" class="option"
class:selected={device.value === value} class:selected={device.value === value}
class:has-subtitle={!!sampleRatesText}
onclick={() => handleOptionClick(device)} onclick={() => handleOptionClick(device)}
title={device.id !== device.value ? `${device.value}\n${device.id}` : device.value} title={device.id !== device.value ? `${device.value}\n${device.id}` : device.value}
> >
<span class="option-text">{device.value}</span> <div class="option-content">
<span class="option-text">{device.value}</span>
{#if sampleRatesText}
<span class="option-sample-rates">{sampleRatesText}</span>
{/if}
</div>
{#if group.key === 'bitperfect'} {#if group.key === 'bitperfect'}
<span class="badge bit-perfect">BP</span> <span class="badge bit-perfect">BP</span>
{/if} {/if}
@@ -451,8 +470,8 @@
.option { .option {
width: 100%; width: 100%;
height: 36px; min-height: 36px;
padding: 0 12px; padding: 6px 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -464,14 +483,34 @@
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: background-color 150ms ease, color 150ms ease; transition: background-color 150ms ease, color 150ms ease;
white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
} }
.option.has-subtitle {
min-height: 44px;
padding: 4px 12px;
}
.option-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.option-text { .option-text {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex: 1; white-space: nowrap;
}
.option-sample-rates {
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.option:hover { .option:hover {
+4 -2
View File
@@ -465,7 +465,7 @@
// Device options for grouped dropdown (ALSA Direct only) // Device options for grouped dropdown (ALSA Direct only)
let groupedDeviceOptions = $derived.by(() => { let groupedDeviceOptions = $derived.by(() => {
// System Default is always first // System Default is always first
const options: { value: string; id: string; isDefault?: boolean }[] = [ const options: { value: string; id: string; isDefault?: boolean; sampleRates?: number[] }[] = [
{ value: 'System Default', id: 'system-default', isDefault: true } { value: 'System Default', id: 'system-default', isDefault: true }
]; ];
@@ -488,7 +488,8 @@
options.push({ options.push({
value: displayName, value: displayName,
id: device.id, id: device.id,
isDefault: device.is_default isDefault: device.is_default,
sampleRates: device.supported_sample_rates ?? undefined
}); });
}); });
@@ -1082,6 +1083,7 @@
description: string | null; description: string | null;
is_default: boolean; is_default: boolean;
max_sample_rate: number | null; max_sample_rate: number | null;
supported_sample_rates: number[] | null;
} }
interface AlsaPluginInfo { interface AlsaPluginInfo {