From 3e8fe0771a2d42c03e8f730883be4ce5e971d9c3 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:38:48 -0600 Subject: [PATCH] perf: add adaptive polling and fix lyrics interval leak - Playback state polling: 1000ms when paused, 250ms when playing - Audio thread timeout: 500ms when idle, 100ms when playing - Lyrics interval: self-check to auto-stop when conditions not met - Add cleanup return to lyrics in +page.svelte - Add build-and-run.sh and monitor-resources.sh scripts - Add OPTIMIZATION_PLAN.md documentation --- qbz-nix-docs/OPTIMIZATION_PLAN.md | 306 ++++++++++++++++++++++++++++++ scripts/build-and-run.sh | 44 +++++ scripts/monitor-resources.sh | 52 +++++ src-tauri/src/lib.rs | 12 +- src-tauri/src/player/mod.rs | 9 +- src/lib/stores/lyricsStore.ts | 21 +- src/routes/+page.svelte | 4 + 7 files changed, 442 insertions(+), 6 deletions(-) create mode 100644 qbz-nix-docs/OPTIMIZATION_PLAN.md create mode 100755 scripts/build-and-run.sh create mode 100755 scripts/monitor-resources.sh diff --git a/qbz-nix-docs/OPTIMIZATION_PLAN.md b/qbz-nix-docs/OPTIMIZATION_PLAN.md new file mode 100644 index 000000000..7010ba2a9 --- /dev/null +++ b/qbz-nix-docs/OPTIMIZATION_PLAN.md @@ -0,0 +1,306 @@ +# QBZ Performance Optimization Plan + +## Problem Statement +QBZ consumes excessive resources when idle: +- **~934MB RAM** (583MB main process + 351MB WebKitWebProcess) +- **51 threads** +- **~1.5% CPU** without playing audio +- User was viewing ArtistDetailView (many images, long page) + +## Root Cause Analysis + +### Backend (Rust/Tauri) + +| Issue | Location | Impact | +|-------|----------|--------| +| Playback polling loop | `src-tauri/src/lib.rs:170-206` | 250ms interval, runs ALWAYS | +| Audio thread polling | `src-tauri/src/player/mod.rs:498` | 100ms timeout, runs ALWAYS | +| Media server HTTP polling | `src-tauri/src/cast/media_server.rs:62` | 250ms timeout, runs ALWAYS | +| Audio cache in memory | `src-tauri/src/cache/mod.rs` | 500MB default limit | + +### Frontend (Svelte/WebKit) + +| Issue | Location | Impact | +|-------|----------|--------| +| **Lyrics 50ms interval** | `src/lib/stores/lyricsStore.ts:389` | 20 updates/sec even when hidden | +| IntersectionObserver (4 thresholds) | `src/lib/components/views/ArtistDetailView.svelte:300` | Fires on every scroll | +| No lazy loading for images | Multiple components | All images load immediately | +| Transform scale on hover (JS) | `src/lib/components/AlbumCard.svelte:37` | Reflows on every hover | +| 7 store subscriptions cascade | `src/routes/+page.svelte:1238-1336` | All fire on playback updates | +| No list virtualization | QueuePanel, Favorites | Large DOM trees | +| backdrop-filter blur | `src/lib/components/glass/GlassSurface.svelte:122` | GPU intensive | + +--- + +## Phase 1: Critical Fixes (Idle CPU) + +### 1.1 Fix Lyrics Interval Leak +**File:** `src/lib/stores/lyricsStore.ts` + +**Problem:** 50ms setInterval runs continuously even when lyrics sidebar is hidden. + +**Fix:** +```typescript +// Ensure interval is ONLY running when lyrics are visible AND playing +export function startActiveLineUpdates(): void { + if (updateInterval !== null) return; + // Add visibility check here + updateInterval = window.setInterval(() => { + if (parsedLyrics.isSynced) { + updateActiveLine(); + } + }, 50); +} + +// Add explicit cleanup +export function stopActiveLineUpdates(): void { + if (updateInterval !== null) { + window.clearInterval(updateInterval); + updateInterval = null; + } +} +``` + +**Also fix in +page.svelte:** Ensure the $effect properly cleans up on all state changes. + +### 1.2 Conditional Playback Polling +**File:** `src-tauri/src/lib.rs:170-206` + +**Problem:** 250ms polling loop runs forever, even when paused. + +**Fix:** Only poll when playing: +```rust +loop { + thread::sleep(Duration::from_millis(250)); + + // Skip polling if not playing + if !player_handle.is_playing() { + thread::sleep(Duration::from_millis(1000)); // Slower poll when paused + continue; + } + + // ... rest of polling logic +} +``` + +### 1.3 Reduce Audio Thread Timeout When Idle +**File:** `src-tauri/src/player/mod.rs:498` + +**Problem:** 100ms recv_timeout even when not playing. + +**Fix:** Use longer timeout when idle: +```rust +let timeout = if is_playing.load(Ordering::Relaxed) { + Duration::from_millis(100) +} else { + Duration::from_millis(500) // Slower when paused +}; + +match rx.recv_timeout(timeout) { ... } +``` + +--- + +## Phase 2: Image Optimization + +### 2.1 Add Lazy Loading to All Images +**Files:** +- `src/lib/components/AlbumCard.svelte` +- `src/lib/components/views/ArtistDetailView.svelte` +- `src/lib/components/TrackRow.svelte` + +**Fix:** Add `loading="lazy"` to all `` tags: +```svelte +{title} +``` + +### 2.2 Use Smaller Image Sizes from Qobuz API +**Current:** Using full-size images (e.g., 600x600) +**Fix:** Request smaller sizes for thumbnails: +- Cards: `?w=300` +- Sidebar: `?w=100` +- Detail headers: `?w=600` + +### 2.3 Optimize IntersectionObserver in ArtistDetailView +**File:** `src/lib/components/views/ArtistDetailView.svelte:300-343` + +**Fix:** Reduce thresholds and debounce: +```typescript +jumpObserver = new IntersectionObserver( + (entries) => { + // Debounce callback + requestAnimationFrame(() => { + const visible = entries.find(entry => entry.isIntersecting); + if (visible) { + const targetId = sectionByElement.get(visible.target as HTMLDivElement); + if (targetId) activeJumpSection = targetId; + } + }); + }, + { + root: artistDetailEl, + rootMargin: '-20% 0px -60% 0px', + threshold: [0.5] // Single threshold instead of 4 + } +); +``` + +--- + +## Phase 3: CSS Performance + +### 3.1 Use CSS :hover Instead of JS State +**File:** `src/lib/components/AlbumCard.svelte` + +**Current (Bad):** +```svelte +
+``` + +**Fix (Good):** +```svelte +
+ + +``` + +### 3.2 Conditional Glass Effects +**File:** `src/lib/components/glass/GlassSurface.svelte` + +**Fix:** Add prop to disable blur when not needed: +```svelte + + + +``` + +--- + +## Phase 4: Memory Optimization + +### 4.1 Reduce Default Audio Cache Size +**File:** `src-tauri/src/cache/mod.rs` + +**Current:** 500MB default +**Fix:** Reduce to 200MB or make configurable: +```rust +const DEFAULT_CACHE_SIZE: usize = 200 * 1024 * 1024; // 200MB +``` + +### 4.2 Add Cache Size Setting +**New setting in Settings UI:** +- Audio cache size: 100MB / 200MB / 500MB / 1GB +- Store in settings, pass to AudioCache on init + +### 4.3 Lazy Initialize Cast Services +**File:** `src-tauri/src/lib.rs` + +**Current:** Media server starts immediately on app launch. +**Fix:** Only start when user opens cast panel: +```rust +// Don't start in build() +// Start on first cast_start_discovery() call instead +``` + +--- + +## Phase 5: List Virtualization (Future) + +### 5.1 Virtual Scrolling for QueuePanel +**File:** `src/lib/components/QueuePanel.svelte` + +**Implementation:** Use svelte-virtual-list or custom implementation: +- Only render visible items + buffer +- Recycle DOM nodes on scroll +- Target: Handle 1000+ tracks smoothly + +### 5.2 Virtual Scrolling for Favorites +Similar implementation for favorites list. + +--- + +## Implementation Order + +| Priority | Task | Impact | Effort | +|----------|------|--------|--------| +| P0 | Fix lyrics interval leak | HIGH | LOW | +| P0 | Conditional playback polling | HIGH | LOW | +| P1 | Lazy load images | HIGH | MEDIUM | +| P1 | Reduce IntersectionObserver thresholds | MEDIUM | LOW | +| P1 | CSS :hover instead of JS state | MEDIUM | LOW | +| P2 | Reduce audio thread timeout when idle | MEDIUM | LOW | +| P2 | Reduce default cache size | MEDIUM | LOW | +| P2 | Smaller image sizes from API | MEDIUM | MEDIUM | +| P3 | Lazy init cast services | LOW | MEDIUM | +| P3 | Conditional glass blur | LOW | LOW | +| P4 | List virtualization | MEDIUM | HIGH | + +--- + +## Verification + +### Before/After Metrics +1. Open btop or htop +2. Launch QBZ +3. Navigate to ArtistDetailView (same artist with many albums) +4. Pause playback +5. Wait 30 seconds +6. Record: CPU%, Memory, Thread count + +### Expected Results After Optimization +- **CPU (idle):** < 0.5% (down from 1.5%) +- **RAM:** < 600MB total (down from ~934MB) +- **Threads:** < 30 (down from 51) + +### Test Cases +1. [ ] App idle on home view - CPU should be ~0% +2. [ ] App idle on ArtistDetailView - CPU should be < 0.5% +3. [ ] Playback paused - CPU should drop to idle levels +4. [ ] Scroll through long artist page - no stuttering +5. [ ] Open/close lyrics sidebar - no CPU spike after close +6. [ ] Memory doesn't grow over time with normal use + +--- + +## Files to Modify + +| File | Changes | +|------|---------| +| `src/lib/stores/lyricsStore.ts` | Fix interval cleanup | +| `src-tauri/src/lib.rs` | Conditional polling | +| `src-tauri/src/player/mod.rs` | Dynamic recv_timeout | +| `src/lib/components/AlbumCard.svelte` | CSS hover, lazy img | +| `src/lib/components/views/ArtistDetailView.svelte` | Reduce thresholds, lazy img | +| `src/lib/components/TrackRow.svelte` | Lazy img | +| `src-tauri/src/cache/mod.rs` | Reduce default size | +| `src/lib/components/glass/GlassSurface.svelte` | Conditional blur | + +--- + +## Notes + +- WebKitWebProcess (351MB) is largely unavoidable - it's the WebView rendering engine +- Some memory is WebKit's internal caches and can't be controlled +- Focus on CPU optimization first as it's more noticeable to users +- Consider adding a "Low Resource Mode" toggle in settings for potato PCs diff --git a/scripts/build-and-run.sh b/scripts/build-and-run.sh new file mode 100755 index 000000000..f4accbced --- /dev/null +++ b/scripts/build-and-run.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Build QBZ in release mode and run the binary +# Usage: ./scripts/build-and-run.sh [--skip-build] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +BINARY_PATH="$PROJECT_DIR/src-tauri/target/release/qbz-nix" + +cd "$PROJECT_DIR" + +# Check for --skip-build flag +SKIP_BUILD=false +if [[ "$1" == "--skip-build" ]]; then + SKIP_BUILD=true +fi + +# Build if needed +if [[ "$SKIP_BUILD" == false ]] || [[ ! -f "$BINARY_PATH" ]]; then + echo "=== Building QBZ (release mode) ===" + echo "This may take a few minutes..." + + # Use npm run tauri build which handles frontend + backend properly + npm run tauri build -- --no-bundle + + echo "" + echo "=== Build complete ===" + echo "Binary: $BINARY_PATH" + echo "Size: $(du -h "$BINARY_PATH" | cut -f1)" + echo "" +fi + +# Kill any existing instance +pkill -f "qbz-nix" 2>/dev/null || true +sleep 0.5 + +echo "=== Running QBZ ===" +echo "Monitor with: btop or htop" +echo "Press Ctrl+C to stop" +echo "" + +# Run the binary +exec "$BINARY_PATH" diff --git a/scripts/monitor-resources.sh b/scripts/monitor-resources.sh new file mode 100755 index 000000000..7da693f91 --- /dev/null +++ b/scripts/monitor-resources.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Monitor QBZ resource usage +# Usage: ./scripts/monitor-resources.sh + +echo "=== QBZ Resource Monitor ===" +echo "Waiting for QBZ process..." +echo "" + +# Wait for process to start +while ! pgrep -f "qbz-nix" > /dev/null 2>&1; do + sleep 0.5 +done + +PID=$(pgrep -f "qbz-nix" | head -1) +echo "Found QBZ PID: $PID" +echo "" +echo "Press Ctrl+C to stop monitoring" +echo "===========================================" +echo "" + +# Monitor loop +while true; do + if ! ps -p "$PID" > /dev/null 2>&1; then + echo "QBZ process ended" + exit 0 + fi + + # Get stats + CPU=$(ps -p "$PID" -o %cpu= 2>/dev/null | tr -d ' ') + MEM=$(ps -p "$PID" -o rss= 2>/dev/null | tr -d ' ') + MEM_MB=$((MEM / 1024)) + THREADS=$(ps -p "$PID" -o nlwp= 2>/dev/null | tr -d ' ') + + # Get WebKit process if exists + WEBKIT_PID=$(pgrep -f "WebKitWebProcess" | head -1 2>/dev/null) + if [[ -n "$WEBKIT_PID" ]]; then + WEBKIT_MEM=$(ps -p "$WEBKIT_PID" -o rss= 2>/dev/null | tr -d ' ') + WEBKIT_MB=$((WEBKIT_MEM / 1024)) + WEBKIT_CPU=$(ps -p "$WEBKIT_PID" -o %cpu= 2>/dev/null | tr -d ' ') + else + WEBKIT_MB=0 + WEBKIT_CPU=0 + fi + + TOTAL_MB=$((MEM_MB + WEBKIT_MB)) + + # Print stats + printf "\r[QBZ] CPU: %5s%% | RAM: %4dMB | Threads: %2s | [WebKit] CPU: %5s%% | RAM: %4dMB | TOTAL: %4dMB" \ + "$CPU" "$MEM_MB" "$THREADS" "$WEBKIT_CPU" "$WEBKIT_MB" "$TOTAL_MB" + + sleep 1 +done diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 135ef7a73..27beae47c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -173,8 +173,18 @@ pub fn run() { let mut last_track_id: u64 = 0; loop { - std::thread::sleep(std::time::Duration::from_millis(250)); + // Check playing state first to determine sleep duration + let is_playing = player_state.is_playing(); + // Adaptive polling: fast (250ms) when playing, slow (1000ms) when idle + let sleep_duration = if is_playing { + std::time::Duration::from_millis(250) + } else { + std::time::Duration::from_millis(1000) + }; + std::thread::sleep(sleep_duration); + + // Re-check after sleep (state might have changed) let is_playing = player_state.is_playing(); let position = player_state.current_position(); let duration = player_state.duration(); diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index f24d561da..e4d1f561d 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -494,8 +494,13 @@ impl Player { log::info!("Audio thread ready and waiting for commands"); loop { - // Use recv_timeout so we can periodically check if sink is empty - match rx.recv_timeout(Duration::from_millis(100)) { + // Adaptive timeout: fast (100ms) when playing, slow (500ms) when idle + let timeout = if thread_state.is_playing.load(Ordering::SeqCst) { + Duration::from_millis(100) + } else { + Duration::from_millis(500) + }; + match rx.recv_timeout(timeout) { Ok(AudioCommand::Play { data, track_id, duration_secs }) => { log::info!("Audio thread: playing track {}", track_id); diff --git a/src/lib/stores/lyricsStore.ts b/src/lib/stores/lyricsStore.ts index 07472e42d..80b9968f6 100644 --- a/src/lib/stores/lyricsStore.ts +++ b/src/lib/stores/lyricsStore.ts @@ -382,6 +382,14 @@ export function reset(): void { // ============ Auto-update ============ let updateInterval: number | null = null; +let isUpdatesActive = false; + +/** + * Check if active line updates are currently running + */ +export function isActiveLineUpdatesRunning(): boolean { + return updateInterval !== null; +} /** * Start auto-updating active line (call when lyrics are synced and playing) @@ -389,11 +397,16 @@ let updateInterval: number | null = null; export function startActiveLineUpdates(): void { if (updateInterval !== null) return; - console.log('[Lyrics] Starting active line updates, isSynced:', parsedLyrics.isSynced); + isUpdatesActive = true; + console.log('[Lyrics] Starting active line updates'); updateInterval = window.setInterval(() => { - if (parsedLyrics.isSynced) { - updateActiveLine(); + // Self-check: stop if no longer needed + if (!isUpdatesActive || !parsedLyrics.isSynced) { + console.log('[Lyrics] Auto-stopping interval (conditions no longer met)'); + stopActiveLineUpdates(); + return; } + updateActiveLine(); }, 50); // 50ms for smooth progress } @@ -401,7 +414,9 @@ export function startActiveLineUpdates(): void { * Stop auto-updating active line */ export function stopActiveLineUpdates(): void { + isUpdatesActive = false; if (updateInterval !== null) { + console.log('[Lyrics] Stopping active line updates'); clearInterval(updateInterval); updateInterval = null; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 006f93bc5..4c5da0f09 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1411,6 +1411,10 @@ } else { stopActiveLineUpdates(); } + // Cleanup on effect re-run or component unmount + return () => { + stopActiveLineUpdates(); + }; }); // Derived values for NowPlayingBar