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
+240 -91
+240 -91
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 /// Download audio from URL
async fn download_audio(url: &str) -> Result<Vec<u8>, String> { async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
use std::time::Duration; use std::time::Duration;
@@ -1444,6 +1507,20 @@ fn spawn_v2_prefetch(
upcoming_tracks: Vec<CoreQueueTrack>, upcoming_tracks: Vec<CoreQueueTrack>,
quality: Quality, quality: Quality,
streaming_only: bool, 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 // Skip prefetch entirely in streaming_only mode
if streaming_only { if streaming_only {
@@ -1499,6 +1576,7 @@ fn spawn_v2_prefetch(
let bridge_clone = bridge.clone(); let bridge_clone = bridge.clone();
let cache_clone = cache.clone(); let cache_clone = cache.clone();
let hw_device_clone = hw_device_id.clone();
log::info!( log::info!(
"[V2/PREFETCH] Prefetching track: {} - {}", "[V2/PREFETCH] Prefetching track: {} - {}",
@@ -1524,7 +1602,30 @@ fn spawn_v2_prefetch(
let result = async { let result = async {
let bridge_guard = bridge_clone.read().await; let bridge_guard = bridge_clone.read().await;
let bridge = bridge_guard.as_ref().ok_or("CoreBridge not initialized")?; let bridge = bridge_guard.as_ref().ok_or("CoreBridge not initialized")?;
let stream_url = bridge.get_stream_url(track_id, quality).await?; 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); drop(bridge_guard);
let data = download_audio(&stream_url.url).await?; let data = download_audio(&stream_url.url).await?;
@@ -6604,6 +6705,29 @@ pub async fn v2_play_track(
.unwrap_or(false) .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!( log::info!(
"[V2] play_track {} (quality_str={:?}, parsed={:?}, final={:?}, format_id={})", "[V2] play_track {} (quality_str={:?}, parsed={:?}, final={:?}, format_id={})",
track_id, track_id,
@@ -6642,21 +6766,50 @@ pub async fn v2_play_track(
let audio_data = std::fs::read(path).map_err(|e| { let audio_data = std::fs::read(path).map_err(|e| {
RuntimeError::Internal(format!("Failed to read cached file: {}", e)) RuntimeError::Internal(format!("Failed to read cached file: {}", e))
})?; })?;
player
.play_data(audio_data, track_id)
.map_err(RuntimeError::Internal)?;
// Prefetch next tracks in background (using CoreBridge queue) // Check if cached audio is compatible with hardware
let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await; #[cfg(target_os = "linux")]
drop(bridge_guard); if cached_audio_incompatible_with_hw(&audio_data, &audio_settings) {
spawn_v2_prefetch( log::info!(
bridge.0.clone(), "[V2/Quality] Skipping OFFLINE cache for track {} - incompatible sample rate",
app_state.audio_cache.clone(), track_id
upcoming_tracks, );
final_quality, // Fall through to network path which will request compatible quality
streaming_only, } else {
); player
return Ok(V2PlayTrackResult { format_id: None }); .play_data(audio_data, track_id)
.map_err(RuntimeError::Internal)?;
// 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(
bridge.0.clone(),
app_state.audio_cache.clone(),
upcoming_tracks,
final_quality,
streaming_only,
);
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,34 +6823,22 @@ pub async fn v2_play_track(
track_id, track_id,
cached.size_bytes cached.size_bytes
); );
player
.play_data(cached.data, track_id)
.map_err(RuntimeError::Internal)?;
// Prefetch next tracks in background (using CoreBridge queue) // Check if cached audio is compatible with hardware
let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await; #[cfg(target_os = "linux")]
drop(bridge_guard); let skip_cache = cached_audio_incompatible_with_hw(&cached.data, &audio_settings);
spawn_v2_prefetch( #[cfg(not(target_os = "linux"))]
bridge.0.clone(), let skip_cache = false;
cache.clone(),
upcoming_tracks,
final_quality,
streaming_only,
);
return Ok(V2PlayTrackResult { format_id: None });
}
// Check playback cache (L2 - disk) if skip_cache {
if let Some(playback_cache) = cache.get_playback_cache() {
if let Some(audio_data) = playback_cache.get(track_id) {
log::info!( log::info!(
"[V2/CACHE HIT] Track {} from DISK cache ({} bytes)", "[V2/Quality] Skipping MEMORY cache for track {} - incompatible sample rate",
track_id, track_id
audio_data.len()
); );
cache.insert(track_id, audio_data.clone()); // Fall through to network path which will request compatible quality
} else {
player player
.play_data(audio_data, track_id) .play_data(cached.data, track_id)
.map_err(RuntimeError::Internal)?; .map_err(RuntimeError::Internal)?;
// Prefetch next tracks in background (using CoreBridge queue) // Prefetch next tracks in background (using CoreBridge queue)
@@ -6714,6 +6855,48 @@ pub async fn v2_play_track(
} }
} }
// Check playback cache (L2 - disk)
if let Some(playback_cache) = cache.get_playback_cache() {
if let Some(audio_data) = playback_cache.get(track_id) {
log::info!(
"[V2/CACHE HIT] Track {} from DISK cache ({} bytes)",
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)
.map_err(RuntimeError::Internal)?;
// 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(
bridge.0.clone(),
cache.clone(),
upcoming_tracks,
final_quality,
streaming_only,
);
return Ok(V2PlayTrackResult { format_id: None });
}
}
}
// Not in any cache - get stream URL from Qobuz via CoreBridge // Not in any cache - get stream URL from Qobuz via CoreBridge
log::info!( log::info!(
"[V2] Track {} not in cache, fetching from network...", "[V2] Track {} not in cache, fetching from network...",
@@ -6733,59 +6916,24 @@ pub async fn v2_play_track(
// Smart quality downgrade for ALSA Direct: if the hardware doesn't support // 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. // the track's sample rate, re-request at a lower quality that IS supported.
// This avoids resampling and keeps bit-perfect playback. // This avoids resampling and keeps bit-perfect playback.
#[cfg(target_os = "linux")] if let Some(ref device_id) = hw_device_id {
{ if final_quality == Quality::UltraHiRes {
let needs_downgrade = { let track_rate = (stream_url.sampling_rate * 1000.0) as u32;
let guard = audio_settings if qbz_audio::device_supports_sample_rate(device_id, track_rate) == Some(false) {
.store log::info!(
.lock() "[V2/Quality] Hardware doesn't support {}Hz, downgrading to Hi-Res",
.map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?; track_rate
if let Some(store) = guard.as_ref() { );
if let Ok(settings) = store.get_settings() { stream_url = bridge_guard
let is_alsa = matches!( .get_stream_url(track_id, Quality::HiRes)
settings.backend_type, .await
Some(qbz_audio::AudioBackendType::Alsa) .map_err(RuntimeError::Internal)?;
); log::info!(
if is_alsa { "[V2/Quality] Got fallback stream URL (format_id={}, rate={}kHz)",
if let Some(ref device_id) = settings.output_device { stream_url.format_id,
let track_rate = (stream_url.sampling_rate * 1000.0) as u32; stream_url.sampling_rate
match 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",
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)",
stream_url.format_id,
stream_url.sampling_rate
);
} }
} }
@@ -6815,12 +6963,13 @@ pub async fn v2_play_track(
// Prefetch next tracks in background (using CoreBridge queue) // Prefetch next tracks in background (using CoreBridge queue)
let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await; let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await;
drop(bridge_guard); drop(bridge_guard);
spawn_v2_prefetch( spawn_v2_prefetch_with_hw_check(
bridge.0.clone(), bridge.0.clone(),
cache, cache,
upcoming_tracks, upcoming_tracks,
final_quality, final_quality,
streaming_only, streaming_only,
hw_device_id,
); );
Ok(V2PlayTrackResult { Ok(V2PlayTrackResult {