feat(audio): add quality limiting based on device capabilities

- Add limit_quality_to_device setting (enabled by default)
- Add device_max_sample_rate field to store selected device's max rate
- Implement limit_quality_for_device() to restrict quality based on device
- Apply quality limiting in play_track and prefetch_track commands
- Add set_audio_device_max_sample_rate Tauri command

This ensures bit-perfect playback by not requesting tracks that
exceed the device's sample rate capabilities. When enabled:
- Devices <= 48kHz: limited to Lossless (44.1kHz)
- Devices <= 96kHz: limited to HiRes (up to 96kHz)
- Devices > 96kHz: allow UltraHiRes (up to 192kHz)
This commit is contained in:
vicrodh committed 2026-02-01 19:21:02 -06:00
1 parent be4786e6d3
commit 6d53907233
3 files changed
+95 -3

No files matched your search

+68 -2
View File
@@ -24,6 +24,49 @@ fn parse_quality(quality_str: Option<&str>) -> Quality {
}
}
/// Limit quality based on device's max sample rate
/// This ensures bit-perfect playback by not requesting tracks that exceed device capabilities
fn limit_quality_for_device(quality: Quality, max_sample_rate: Option<u32>) -> Quality {
let Some(max_rate) = max_sample_rate else {
return quality; // No limit if device max rate unknown
};
// Quality mapping:
// - UltraHiRes (27): up to 192kHz - requires max_rate > 96000
// - HiRes (7): up to 96kHz - requires max_rate > 48000
// - Lossless (6): 44.1kHz - works with any device
// - Mp3 (5): compressed - works with any device
if max_rate <= 48000 {
// Device only supports up to 48kHz, limit to CD quality (44.1kHz)
match quality {
Quality::UltraHiRes | Quality::HiRes => {
log::info!(
"[Quality Limit] Device max {}Hz, limiting {} to Lossless (44.1kHz)",
max_rate, quality.display_name()
);
Quality::Lossless
}
_ => quality,
}
} else if max_rate <= 96000 {
// Device supports up to 96kHz, limit to HiRes
match quality {
Quality::UltraHiRes => {
log::info!(
"[Quality Limit] Device max {}Hz, limiting Hi-Res+ to Hi-Res (96kHz)",
max_rate
);
Quality::HiRes
}
_ => quality,
}
} else {
// Device supports > 96kHz, allow all qualities
quality
}
}
/// Result from play_track command with format info
#[derive(serde::Serialize)]
pub struct PlayTrackResult {
@@ -42,7 +85,18 @@ pub async fn play_track(
offline_cache: State<'_, OfflineCacheState>,
audio_settings: State<'_, AudioSettingsState>,
) -> Result<PlayTrackResult, String> {
let preferred_quality = parse_quality(quality.as_deref());
let mut preferred_quality = parse_quality(quality.as_deref());
// Apply quality limiting if enabled
{
let store = audio_settings.store.lock().map_err(|e| format!("Lock error: {}", e))?;
if let Ok(settings) = store.get_settings() {
if settings.limit_quality_to_device {
preferred_quality = limit_quality_for_device(preferred_quality, settings.device_max_sample_rate);
}
}
}
log::info!(
"Command: play_track {} (duration: {:?}s, quality_str={:?}, parsed={:?}, format_id={})",
track_id, duration_secs, quality, preferred_quality, preferred_quality.id()
@@ -276,8 +330,20 @@ pub async fn prefetch_track(
quality: Option<String>,
state: State<'_, AppState>,
offline_cache: State<'_, OfflineCacheState>,
audio_settings: State<'_, AudioSettingsState>,
) -> Result<(), String> {
let preferred_quality = parse_quality(quality.as_deref());
let mut preferred_quality = parse_quality(quality.as_deref());
// Apply quality limiting if enabled
{
let store = audio_settings.store.lock().map_err(|e| format!("Lock error: {}", e))?;
if let Ok(settings) = store.get_settings() {
if settings.limit_quality_to_device {
preferred_quality = limit_quality_for_device(preferred_quality, settings.device_max_sample_rate);
}
}
}
log::info!(
"Command: prefetch_track {} (quality_str={:?}, parsed={:?}, format_id={})",
track_id, quality, preferred_quality, preferred_quality.id()
+26 -1
View File
@@ -26,6 +26,9 @@ pub struct AudioSettings {
/// This ensures bit-perfect playback by avoiding tracks that exceed device capabilities.
/// Default: true (recommended for bit-perfect setups)
pub limit_quality_to_device: bool,
/// Cached max sample rate of the selected device (set when device is selected)
/// Used when limit_quality_to_device is true
pub device_max_sample_rate: Option<u32>,
}
impl Default for AudioSettings {
@@ -42,6 +45,7 @@ impl Default for AudioSettings {
stream_buffer_seconds: 3, // 3 seconds initial buffer
streaming_only: false, // Disabled by default (cache tracks for instant replay)
limit_quality_to_device: true, // Enabled by default for bit-perfect guarantee
device_max_sample_rate: None, // Set when device is selected
}
}
}
@@ -88,6 +92,7 @@ impl AudioSettingsStore {
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN stream_buffer_seconds INTEGER DEFAULT 3", []);
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN streaming_only INTEGER DEFAULT 0", []);
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN limit_quality_to_device INTEGER DEFAULT 1", []);
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN device_max_sample_rate INTEGER", []);
Ok(Self { conn })
}
@@ -95,7 +100,7 @@ impl AudioSettingsStore {
pub fn get_settings(&self) -> Result<AudioSettings, String> {
self.conn
.query_row(
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device FROM audio_settings WHERE id = 1",
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate FROM audio_settings WHERE id = 1",
[],
|row| {
// Parse backend_type from JSON string
@@ -120,6 +125,7 @@ impl AudioSettingsStore {
stream_buffer_seconds: row.get::<_, Option<i64>>(8)?.unwrap_or(3) as u8,
streaming_only: row.get::<_, Option<i64>>(9)?.unwrap_or(0) != 0,
limit_quality_to_device: row.get::<_, Option<i64>>(10)?.unwrap_or(1) != 0,
device_max_sample_rate: row.get::<_, Option<i64>>(11)?.map(|r| r as u32),
})
},
)
@@ -247,6 +253,16 @@ impl AudioSettingsStore {
.map_err(|e| format!("Failed to set limit quality to device: {}", e))?;
Ok(())
}
pub fn set_device_max_sample_rate(&self, rate: Option<u32>) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET device_max_sample_rate = ?1 WHERE id = 1",
params![rate.map(|r| r as i64)],
)
.map_err(|e| format!("Failed to set device max sample rate: {}", e))?;
Ok(())
}
}
/// Thread-safe wrapper
@@ -369,3 +385,12 @@ pub fn set_audio_limit_quality_to_device(
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.set_limit_quality_to_device(enabled)
}
#[tauri::command]
pub fn set_audio_device_max_sample_rate(
state: tauri::State<'_, AudioSettingsState>,
rate: Option<u32>,
) -> Result<(), String> {
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.set_device_max_sample_rate(rate)
}
+1
View File
@@ -786,6 +786,7 @@ pub fn run() {
config::audio_settings::set_audio_stream_buffer_seconds,
config::audio_settings::set_audio_streaming_only,
config::audio_settings::set_audio_limit_quality_to_device,
config::audio_settings::set_audio_device_max_sample_rate,
// Audio backend commands
commands::get_available_backends,
commands::get_devices_for_backend,