fix(offline-cache): warm L1 memory cache after v2 CMAF decrypt

Live testing showed v2 offline tracks had a 3-6s gap on gapless
transitions even after the track_id + spawn_blocking fixes, because
the decrypt itself takes 5-7 seconds for a typical 60-200MB FLAC
(disk read of encrypted segments.bin + AES-CTR per-frame decrypt is
slower than the track's natural tail). By the time load_cmaf_bundle
returns, the previous track has ended and the player's gapless engine
has been dropped.

Workable only second time: once a track's decrypted bytes are in L1
memory cache, subsequent accesses are ~60ms. That's what happened on
the second transition we observed — prefetched tracks hit L1 for
free, so gapless landed in time.

Fix: after successfully decrypting a v2 bundle, insert the resulting
plain-FLAC bytes into the AudioCache (L1) before handing them to the
player. Applies to three call sites:

- v2_play_track offline cache branch → warm on first play
- v2_play_next_gapless CMAF v2 branch → warm on gapless success
- v2_library_play_track CMAF v2 branch → warm on Library click

Next access to the same track (replay, scrub, gapless re-queue,
upcoming-track peek) is now a <100ms L1 hit regardless of the v2
cache_format the row has. The decrypt cost is paid once per track
per session, not once per access.

This still leaves the "first play of an offline track" taking 5-7s
before audio starts. That's a separate optimization — candidates are
(a) offline-track prefetch that pre-decrypts upcoming tracks in the
background, (b) storing decrypted FLAC in L2 disk cache so cold
restarts don't have to decrypt, (c) speeding up the decrypt itself.

cc
This commit is contained in:
vicrodh committed 2026-04-17 10:26:13 -06:00
1 parent 70deae6722
commit 49d132b6c2
2 files changed
+16

No files matched your search

+3
View File
@@ -480,6 +480,7 @@ pub async fn v2_library_play_track(
library_state: State<'_, LibraryState>, library_state: State<'_, LibraryState>,
bridge: State<'_, CoreBridgeState>, bridge: State<'_, CoreBridgeState>,
offline_cache: State<'_, crate::offline_cache::OfflineCacheState>, offline_cache: State<'_, crate::offline_cache::OfflineCacheState>,
app_state: State<'_, AppState>,
runtime: State<'_, RuntimeManagerState>, runtime: State<'_, RuntimeManagerState>,
) -> Result<(), String> { ) -> Result<(), String> {
runtime runtime
@@ -551,6 +552,8 @@ pub async fn v2_library_play_track(
qobuz_track_id qobuz_track_id
) )
})?; })?;
// Warm L1 so subsequent access (replay, gapless) is instant.
app_state.audio_cache.insert(qobuz_track_id as u64, audio_data.clone());
let bridge = bridge.get().await; let bridge = bridge.get().await;
bridge bridge
.player() .player()
+13
View File
@@ -473,6 +473,12 @@ pub async fn v2_play_next_gapless(
"[V2/GAPLESS] Track {} from OFFLINE cache (CMAF v2)", "[V2/GAPLESS] Track {} from OFFLINE cache (CMAF v2)",
track_id track_id
); );
// Warm L1 with the decrypted bytes so subsequent
// accesses (re-gapless, replay, scrub) skip the
// disk-read + decrypt and hit memory directly.
// Without this, every offline-cache gapless attempt
// re-does 5-7s of I/O + AES work.
app_state.audio_cache.insert(track_id, audio_data.clone());
player player
.play_next(audio_data, track_id) .play_next(audio_data, track_id)
.map_err(RuntimeError::Internal)?; .map_err(RuntimeError::Internal)?;
@@ -925,6 +931,13 @@ pub async fn v2_play_track(
// Keep as fallback — don't discard, network might fail // Keep as fallback — don't discard, network might fail
low_quality_fallback = Some(audio_data); low_quality_fallback = Some(audio_data);
} else { } else {
// Warm L1 with the decrypted bytes so the next
// access (replay, gapless re-queue, scrub) skips
// the 5-7s disk + AES decrypt round-trip. Without
// this, every offline-cache play_next_gapless fails
// to land in time and the player's gapless engine
// has already been dropped.
app_state.audio_cache.insert(track_id, audio_data.clone());
player player
.play_data(audio_data, track_id) .play_data(audio_data, track_id)
.map_err(RuntimeError::Internal)?; .map_err(RuntimeError::Internal)?;