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:
1 parent
91fa7c7f5f
commit
0ccdd9981f
5 files changed
+120
-20
No files matched your search
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user