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
This commit is contained in:
vicrodh committed 2026-01-12 20:38:48 -06:00
1 parent 9a817a5203
commit 3e8fe0771a
7 files changed
+442 -6

No files matched your search

+306
View File
@@ -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 `<img>` tags:
```svelte
<img
src={artworkUrl}
alt={title}
loading="lazy"
decoding="async"
/>
```
### 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
<div style="transform: scale({isHovered ? 1.02 : 1})">
```
**Fix (Good):**
```svelte
<div class="artwork-container">
<style>
.artwork-container {
transition: transform 150ms ease-out;
}
.artwork-container:hover {
transform: scale(1.02);
}
</style>
```
### 3.2 Conditional Glass Effects
**File:** `src/lib/components/glass/GlassSurface.svelte`
**Fix:** Add prop to disable blur when not needed:
```svelte
<script>
interface Props {
enableBlur?: boolean;
}
let { enableBlur = true }: Props = $props();
</script>
<style>
.glass-backdrop.blur-enabled {
backdrop-filter: blur(var(--glass-blur, 16px)) saturate(140%);
}
</style>
```
---
## 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
+44
View File
@@ -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"
+52
View File
@@ -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
+11 -1
View File
@@ -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();
+7 -2
View File
@@ -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);
+18 -3
View File
@@ -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;
}
+4
View File
@@ -1411,6 +1411,10 @@
} else {
stopActiveLineUpdates();
}
// Cleanup on effect re-run or component unmount
return () => {
stopActiveLineUpdates();
};
});
// Derived values for NowPlayingBar