feat(qbz-player): L1+L2 playback cache for instant replay

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
This commit is contained in:
vicrodh committed 2026-05-19 01:25:08 -06:00
1 parent 835474d5b0
commit 7b84fa9a7b
3 files changed
+99

No files matched your search

+1
View File
@@ -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 }
+97
View File
@@ -309,6 +309,27 @@ fn extract_audio_metadata_full(data: &[u8]) -> Result<AudioMetadata, String> {
})
}
/// 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<Box<dyn Source<Item = f32> + Send>, String> {
if is_isomp4(data) {
return decode_with_symphonia(data).map(|specs| {
@@ -1191,6 +1212,9 @@ pub struct Player {
visualizer_tap: Option<VisualizerTap>,
/// 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<qbz_cache::AudioCache>,
}
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<u8>,
writer: BufferWriter,
track_id: u64,
cache: Arc<qbz_cache::AudioCache>,
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<u8> = 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(())
}