From 7b84fa9a7b6e4bdb18a3a4c5dcc6cf64fd1294b6 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 19 May 2026 01:25:08 -0600 Subject: [PATCH] feat(qbz-player): L1+L2 playback cache for instant replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit play_track now checks the qbz-cache two-level cache (L1 memory ~400 MB + L2 disk ~800 MB) before fetching: a hit replays through play_data, skipped only when the cached copy is a lower quality than requested (cached_quality_below_requested, ported from Tauri). After a play the track is stored — the CMAF path accumulates the assembled FLAC while streaming, the legacy path stores the download. streaming_only suppresses the write. cc --- crates/Cargo.lock | 1 + crates/qbz-player/Cargo.toml | 1 + crates/qbz-player/src/player/mod.rs | 97 +++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index b0736d86f..3744d7a2b 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -5298,6 +5298,7 @@ dependencies = [ "futures-util", "log", "qbz-audio", + "qbz-cache", "qbz-cmaf", "qbz-models", "qbz-qobuz", diff --git a/crates/qbz-player/Cargo.toml b/crates/qbz-player/Cargo.toml index 8ecd95693..aa32a85f9 100644 --- a/crates/qbz-player/Cargo.toml +++ b/crates/qbz-player/Cargo.toml @@ -10,6 +10,7 @@ qbz-models = { path = "../qbz-models" } qbz-audio = { path = "../qbz-audio" } qbz-qobuz = { path = "../qbz-qobuz" } qbz-cmaf = { path = "../qbz-cmaf" } +qbz-cache = { path = "../qbz-cache" } # Serialization serde = { workspace = true } diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 5a4e167cf..c2a4c0b66 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -309,6 +309,27 @@ fn extract_audio_metadata_full(data: &[u8]) -> Result { }) } +/// True when a cached FLAC is a lower quality than `requested`, so the +/// cache entry should be bypassed and the track re-fetched. Ported from +/// the Tauri `cached_quality_below_requested` helper: an unparseable +/// buffer is assumed compatible. +fn cached_quality_below_requested(data: &[u8], requested: Quality) -> bool { + let meta = match extract_audio_metadata_full(data) { + Ok(m) => m, + Err(_) => return false, + }; + let sample_rate = meta.sample_rate; + let bit_depth = meta.bit_depth.unwrap_or(16); + match requested { + // Hi-Res+: expect 24-bit AND > 96 kHz. + Quality::UltraHiRes => bit_depth < 24 || sample_rate <= 96000, + // Hi-Res: expect 24-bit. + Quality::HiRes => bit_depth < 24, + // Lossless / Mp3: any FLAC satisfies the request. + _ => false, + } +} + fn decode_with_fallback(data: &[u8]) -> Result + Send>, String> { if is_isomp4(data) { return decode_with_symphonia(data).map(|specs| { @@ -1191,6 +1212,9 @@ pub struct Player { visualizer_tap: Option, /// Bit-depth diagnostic capture (always available, zero-cost when idle) pub diagnostic: AudioDiagnostic, + /// Two-level playback cache (L1 memory + optional L2 disk). A track is + /// cached after its first play so replays start instantly. + audio_cache: Arc, } impl Default for Player { @@ -3269,12 +3293,27 @@ impl Player { } }); + // Two-level playback cache: L1 in memory (~400 MB), L2 on disk + // (~800 MB). A disk-cache failure degrades to L1-only rather than + // aborting player creation. + let audio_cache = match qbz_cache::PlaybackCache::new(800 * 1024 * 1024) { + Ok(pc) => Arc::new(qbz_cache::AudioCache::with_playback_cache( + 400 * 1024 * 1024, + Arc::new(pc), + )), + Err(e) => { + log::warn!("Playback disk cache unavailable: {e}; memory cache only"); + Arc::new(qbz_cache::AudioCache::new(400 * 1024 * 1024)) + } + }; + Self { tx, state, audio_settings: settings, visualizer_tap, diagnostic, + audio_cache, } } @@ -3298,6 +3337,32 @@ impl Player { quality ); + // Cache hit: replay instantly from L1/L2 unless the cached copy is + // a lower quality than now requested. + if let Some(cached) = self.audio_cache.get(track_id) { + if cached_quality_below_requested(&cached.data, quality) { + log::info!( + "[CACHE] Track {} cached below requested {:?} — re-fetching", + track_id, + quality + ); + } else { + log::info!( + "[CACHE HIT] Track {} ({} bytes) — playing from cache", + track_id, + cached.size_bytes + ); + return self.play_data(cached.data, track_id); + } + } + + // `streaming_only` suppresses writing the track into the cache. + let skip_cache = self + .audio_settings + .lock() + .map(|s| s.streaming_only) + .unwrap_or(false); + // Try CMAF streaming pipeline first. // Only the init segment is fetched synchronously; audio segments // stream in a background task. @@ -3368,6 +3433,7 @@ impl Player { let content_key = cmaf_info.content_key; let flac_header = cmaf_info.flac_header; let n_segments = cmaf_info.n_segments; + let cache = self.audio_cache.clone(); tokio::spawn(async move { match Self::cmaf_stream_segments( @@ -3377,6 +3443,8 @@ impl Player { flac_header, buffer_writer, track_id, + cache, + skip_cache, ) .await { @@ -3427,6 +3495,11 @@ impl Player { })?; log::info!("Player: Cached {} bytes of audio data", audio_data.len()); + // Store the legacy download in the cache for instant replay. + if !skip_cache { + self.audio_cache.insert(track_id, audio_data.clone()); + } + // Send to audio thread self.play_data(audio_data, track_id) } @@ -3444,6 +3517,8 @@ impl Player { flac_header: Vec, writer: BufferWriter, track_id: u64, + cache: Arc, + skip_cache: bool, ) -> Result<(), String> { let client = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) @@ -3456,6 +3531,14 @@ impl Player { } let mut total_written: u64 = flac_header.len() as u64; + // Accumulate the assembled FLAC (header + decrypted frames) so the + // finished track can be cached for instant replay. Empty when + // `skip_cache` (streaming_only) is set. + let mut cache_data: Vec = if skip_cache { + Vec::new() + } else { + flac_header.clone() + }; let start = Instant::now(); for seg_idx in 1..=n_segments { @@ -3488,6 +3571,9 @@ impl Player { if let Err(e) = writer.push_chunk(&frame) { log::error!("[CMAF-STREAM] Failed to push frame: {}", e); } + if !skip_cache { + cache_data.extend_from_slice(&frame); + } total_written += frame.len() as u64; data_pos = frame_end; } @@ -3498,6 +3584,9 @@ impl Player { if let Err(e) = writer.push_chunk(trailing) { log::error!("[CMAF-STREAM] Failed to push trailing data: {}", e); } + if !skip_cache { + cache_data.extend_from_slice(trailing); + } total_written += trailing.len() as u64; } @@ -3531,6 +3620,14 @@ impl Player { n_segments - 1 ); + // Cache the assembled FLAC (header + decrypted frames) for instant + // replay on the next play of this track. + if !skip_cache && !cache_data.is_empty() { + let bytes = cache_data.len(); + cache.insert(track_id, cache_data); + log::info!("[CMAF-STREAM] Track {} cached ({} bytes)", track_id, bytes); + } + Ok(()) }