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:
1 parent
9a817a5203
commit
3e8fe0771a
7 files changed
+442
-6
No files matched your search
@@ -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
|
||||
Executable
+44
@@ -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"
|
||||
Executable
+52
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1411,6 +1411,10 @@
|
||||
} else {
|
||||
stopActiveLineUpdates();
|
||||
}
|
||||
// Cleanup on effect re-run or component unmount
|
||||
return () => {
|
||||
stopActiveLineUpdates();
|
||||
};
|
||||
});
|
||||
|
||||
// Derived values for NowPlayingBar
|
||||
|
||||
Reference in new issue
Block a user