fix: check hardware rate compatibility on cached audio before playing

The smart quality downgrade was only in the network fetch path, but
tracks served from cache (memory L1, disk L2, offline) bypassed it
entirely. Now all cache paths probe the FLAC header for sample rate
and skip the cache if the hardware doesn't support it, falling through
to the network path which re-requests at Hi-Res quality.

Also applies quality downgrade to prefetch so upcoming tracks are
fetched at compatible quality instead of caching incompatible data.
This commit is contained in:
vicrodh committed 2026-02-28 07:48:03 -06:00
1 parent 7086110569
commit 56b0e562a1
1 file changed
+191 -42
+191 -42
View File
@@ -248,6 +248,69 @@ fn limit_quality_for_device(quality: Quality, max_sample_rate: Option<u32>) -> Q
}
}
/// Probe sample rate from FLAC audio data by reading the STREAMINFO header.
/// Returns None for non-FLAC data or data too short to parse.
fn probe_flac_sample_rate(data: &[u8]) -> Option<u32> {
// FLAC format: "fLaC" magic + metadata blocks
// First block is always STREAMINFO (34 bytes)
// Bytes 18-20 of STREAMINFO contain: sample_rate (20 bits) | channels (3 bits) | bps (5 bits) | ...
if data.len() < 22 || &data[0..4] != b"fLaC" {
return None;
}
let sr = ((data[18] as u32) << 12) | ((data[19] as u32) << 4) | ((data[20] as u32) >> 4);
if sr > 0 { Some(sr) } else { None }
}
/// Check if cached audio data has a sample rate that the current ALSA hardware
/// doesn't support. Returns true if the audio should NOT be played from cache
/// (needs re-fetch at lower quality).
#[cfg(target_os = "linux")]
fn cached_audio_incompatible_with_hw(
audio_data: &[u8],
audio_settings: &AudioSettingsState,
) -> bool {
let sample_rate = match probe_flac_sample_rate(audio_data) {
Some(rate) => rate,
None => return false, // Can't determine format, assume compatible
};
let guard = match audio_settings.store.lock() {
Ok(g) => g,
Err(_) => return false,
};
let store = match guard.as_ref() {
Some(s) => s,
None => return false,
};
let settings = match store.get_settings() {
Ok(s) => s,
Err(_) => return false,
};
let is_alsa = matches!(
settings.backend_type,
Some(qbz_audio::AudioBackendType::Alsa)
);
if !is_alsa {
return false;
}
if let Some(ref device_id) = settings.output_device {
match qbz_audio::device_supports_sample_rate(device_id, sample_rate) {
Some(false) => {
log::info!(
"[V2/Quality] Cached audio at {}Hz incompatible with hardware, will re-fetch at lower quality",
sample_rate
);
true
}
_ => false,
}
} else {
false
}
}
/// Download audio from URL
async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
use std::time::Duration;
@@ -1444,6 +1507,20 @@ fn spawn_v2_prefetch(
upcoming_tracks: Vec<CoreQueueTrack>,
quality: Quality,
streaming_only: bool,
) {
spawn_v2_prefetch_with_hw_check(bridge, cache, upcoming_tracks, quality, streaming_only, None);
}
/// Prefetch with optional hardware rate checking.
/// If `hw_device_id` is Some, checks each track's sample rate against hardware
/// and downgrades quality if needed.
fn spawn_v2_prefetch_with_hw_check(
bridge: Arc<RwLock<Option<crate::core_bridge::CoreBridge>>>,
cache: Arc<AudioCache>,
upcoming_tracks: Vec<CoreQueueTrack>,
quality: Quality,
streaming_only: bool,
hw_device_id: Option<String>,
) {
// Skip prefetch entirely in streaming_only mode
if streaming_only {
@@ -1499,6 +1576,7 @@ fn spawn_v2_prefetch(
let bridge_clone = bridge.clone();
let cache_clone = cache.clone();
let hw_device_clone = hw_device_id.clone();
log::info!(
"[V2/PREFETCH] Prefetching track: {} - {}",
@@ -1524,7 +1602,30 @@ fn spawn_v2_prefetch(
let result = async {
let bridge_guard = bridge_clone.read().await;
let bridge = bridge_guard.as_ref().ok_or("CoreBridge not initialized")?;
let mut effective_quality = quality;
// Smart quality downgrade: check if hardware supports the track's sample rate
#[cfg(target_os = "linux")]
if let Some(ref device_id) = hw_device_clone {
if quality == Quality::UltraHiRes {
let stream_url = bridge.get_stream_url(track_id, quality).await?;
let track_rate = (stream_url.sampling_rate * 1000.0) as u32;
if qbz_audio::device_supports_sample_rate(device_id, track_rate) == Some(false) {
log::info!(
"[V2/PREFETCH] Track {} at {}Hz incompatible with hardware, prefetching at Hi-Res",
track_id, track_rate
);
effective_quality = Quality::HiRes;
} else {
// Rate is supported, use the URL we already got
drop(bridge_guard);
let data = download_audio(&stream_url.url).await?;
return Ok::<Vec<u8>, String>(data);
}
}
}
let stream_url = bridge.get_stream_url(track_id, effective_quality).await?;
drop(bridge_guard);
let data = download_audio(&stream_url.url).await?;
@@ -6604,6 +6705,29 @@ pub async fn v2_play_track(
.unwrap_or(false)
};
// Determine hardware device ID for sample rate compatibility checks (ALSA only)
#[cfg(target_os = "linux")]
let hw_device_id: Option<String> = {
let guard = audio_settings.store.lock().ok();
guard
.as_ref()
.and_then(|g| g.as_ref())
.and_then(|store| store.get_settings().ok())
.and_then(|settings| {
let is_alsa = matches!(
settings.backend_type,
Some(qbz_audio::AudioBackendType::Alsa)
);
if is_alsa {
settings.output_device.clone()
} else {
None
}
})
};
#[cfg(not(target_os = "linux"))]
let hw_device_id: Option<String> = None;
log::info!(
"[V2] play_track {} (quality_str={:?}, parsed={:?}, final={:?}, format_id={})",
track_id,
@@ -6642,6 +6766,16 @@ pub async fn v2_play_track(
let audio_data = std::fs::read(path).map_err(|e| {
RuntimeError::Internal(format!("Failed to read cached file: {}", e))
})?;
// Check if cached audio is compatible with hardware
#[cfg(target_os = "linux")]
if cached_audio_incompatible_with_hw(&audio_data, &audio_settings) {
log::info!(
"[V2/Quality] Skipping OFFLINE cache for track {} - incompatible sample rate",
track_id
);
// Fall through to network path which will request compatible quality
} else {
player
.play_data(audio_data, track_id)
.map_err(RuntimeError::Internal)?;
@@ -6658,6 +6792,25 @@ pub async fn v2_play_track(
);
return Ok(V2PlayTrackResult { format_id: None });
}
#[cfg(not(target_os = "linux"))]
{
player
.play_data(audio_data, track_id)
.map_err(RuntimeError::Internal)?;
let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await;
drop(bridge_guard);
spawn_v2_prefetch(
bridge.0.clone(),
app_state.audio_cache.clone(),
upcoming_tracks,
final_quality,
streaming_only,
);
return Ok(V2PlayTrackResult { format_id: None });
}
}
}
}
@@ -6670,6 +6823,20 @@ pub async fn v2_play_track(
track_id,
cached.size_bytes
);
// Check if cached audio is compatible with hardware
#[cfg(target_os = "linux")]
let skip_cache = cached_audio_incompatible_with_hw(&cached.data, &audio_settings);
#[cfg(not(target_os = "linux"))]
let skip_cache = false;
if skip_cache {
log::info!(
"[V2/Quality] Skipping MEMORY cache for track {} - incompatible sample rate",
track_id
);
// Fall through to network path which will request compatible quality
} else {
player
.play_data(cached.data, track_id)
.map_err(RuntimeError::Internal)?;
@@ -6686,6 +6853,7 @@ pub async fn v2_play_track(
);
return Ok(V2PlayTrackResult { format_id: None });
}
}
// Check playback cache (L2 - disk)
if let Some(playback_cache) = cache.get_playback_cache() {
@@ -6695,6 +6863,20 @@ pub async fn v2_play_track(
track_id,
audio_data.len()
);
// Check if cached audio is compatible with hardware
#[cfg(target_os = "linux")]
let skip_cache = cached_audio_incompatible_with_hw(&audio_data, &audio_settings);
#[cfg(not(target_os = "linux"))]
let skip_cache = false;
if skip_cache {
log::info!(
"[V2/Quality] Skipping DISK cache for track {} - incompatible sample rate",
track_id
);
// Fall through to network path which will request compatible quality
} else {
cache.insert(track_id, audio_data.clone());
player
.play_data(audio_data, track_id)
@@ -6713,6 +6895,7 @@ pub async fn v2_play_track(
return Ok(V2PlayTrackResult { format_id: None });
}
}
}
// Not in any cache - get stream URL from Qobuz via CoreBridge
log::info!(
@@ -6733,61 +6916,26 @@ pub async fn v2_play_track(
// Smart quality downgrade for ALSA Direct: if the hardware doesn't support
// the track's sample rate, re-request at a lower quality that IS supported.
// This avoids resampling and keeps bit-perfect playback.
#[cfg(target_os = "linux")]
{
let needs_downgrade = {
let guard = audio_settings
.store
.lock()
.map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?;
if let Some(store) = guard.as_ref() {
if let Ok(settings) = store.get_settings() {
let is_alsa = matches!(
settings.backend_type,
Some(qbz_audio::AudioBackendType::Alsa)
);
if is_alsa {
if let Some(ref device_id) = settings.output_device {
if let Some(ref device_id) = hw_device_id {
if final_quality == Quality::UltraHiRes {
let track_rate = (stream_url.sampling_rate * 1000.0) as u32;
match qbz_audio::device_supports_sample_rate(device_id, track_rate) {
Some(false) => {
if qbz_audio::device_supports_sample_rate(device_id, track_rate) == Some(false) {
log::info!(
"[V2/Quality] Hardware doesn't support {}Hz natively, will request lower quality",
"[V2/Quality] Hardware doesn't support {}Hz, downgrading to Hi-Res",
track_rate
);
true
}
_ => false,
}
} else {
false
}
} else {
false
}
} else {
false
}
} else {
false
}
};
if needs_downgrade && final_quality == Quality::UltraHiRes {
log::info!(
"[V2/Quality] Downgrading from Hi-Res+ to Hi-Res for hardware compatibility"
);
stream_url = bridge_guard
.get_stream_url(track_id, Quality::HiRes)
.await
.map_err(RuntimeError::Internal)?;
log::info!(
"[V2/Quality] Got fallback stream URL (format_id={}, rate={}Hz)",
"[V2/Quality] Got fallback stream URL (format_id={}, rate={}kHz)",
stream_url.format_id,
stream_url.sampling_rate
);
}
}
}
// Download the audio
let audio_data = download_audio(&stream_url.url)
@@ -6815,12 +6963,13 @@ pub async fn v2_play_track(
// Prefetch next tracks in background (using CoreBridge queue)
let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await;
drop(bridge_guard);
spawn_v2_prefetch(
spawn_v2_prefetch_with_hw_check(
bridge.0.clone(),
cache,
upcoming_tracks,
final_quality,
streaming_only,
hw_device_id,
);
Ok(V2PlayTrackResult {