diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index 475d7d1e4..1d2a4e4b0 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -248,6 +248,69 @@ fn limit_quality_for_device(quality: Quality, max_sample_rate: Option) -> 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 { + // 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, String> { use std::time::Duration; @@ -1444,6 +1507,20 @@ fn spawn_v2_prefetch( upcoming_tracks: Vec, 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>>, + cache: Arc, + upcoming_tracks: Vec, + quality: Quality, + streaming_only: bool, + hw_device_id: Option, ) { // 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 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::, 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 = { + 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 = None; + log::info!( "[V2] play_track {} (quality_str={:?}, parsed={:?}, final={:?}, format_id={})", track_id, @@ -6642,21 +6766,50 @@ 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)) })?; - 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(), - app_state.audio_cache.clone(), - upcoming_tracks, - final_quality, - streaming_only, - ); - return Ok(V2PlayTrackResult { format_id: None }); + // 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)?; + + // 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, cached.size_bytes ); - player - .play_data(cached.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 }); - } + // 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; - // Check playback cache (L2 - disk) - if let Some(playback_cache) = cache.get_playback_cache() { - if let Some(audio_data) = playback_cache.get(track_id) { + if skip_cache { log::info!( - "[V2/CACHE HIT] Track {} from DISK cache ({} bytes)", - track_id, - audio_data.len() + "[V2/Quality] Skipping MEMORY cache for track {} - incompatible sample rate", + track_id ); - cache.insert(track_id, audio_data.clone()); + // Fall through to network path which will request compatible quality + } else { player - .play_data(audio_data, track_id) + .play_data(cached.data, track_id) .map_err(RuntimeError::Internal)?; // 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 log::info!( "[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 // 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 { - let track_rate = (stream_url.sampling_rate * 1000.0) as u32; - 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 let Some(ref device_id) = hw_device_id { + if final_quality == Quality::UltraHiRes { + 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/Quality] Hardware doesn't support {}Hz, downgrading to Hi-Res", + track_rate + ); + 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={}kHz)", + stream_url.format_id, + stream_url.sampling_rate + ); } - }; - - 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) 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 {