{artist}
- {#if album} -{album}
- {/if} -From bb4fcfd6b8658dc833a9ad90fc80f150eb136a3e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:44:52 -0600 Subject: [PATCH] Optimize visualizer: 16 bins mirrored, Material You colors - Reduce FFT to 30fps with 16 bins (mirrored to 32 visual bars) - Use 1024 FFT size for faster processing - Extract dominant colors from album artwork - Center artwork with track info and quality badge - Bars render from edges inward with color gradient --- src-tauri/src/visualizer/fft_processor.rs | 7 +- src-tauri/src/visualizer/mod.rs | 9 +- .../immersive/ImmersivePlayer.svelte | 3 + .../immersive/panels/VisualizerPanel.svelte | 319 ++++++++++++------ 4 files changed, 219 insertions(+), 119 deletions(-) diff --git a/src-tauri/src/visualizer/fft_processor.rs b/src-tauri/src/visualizer/fft_processor.rs index b209b4371..be06c045c 100644 --- a/src-tauri/src/visualizer/fft_processor.rs +++ b/src-tauri/src/visualizer/fft_processor.rs @@ -175,7 +175,7 @@ mod tests { #[test] fn test_log_frequency_distribution() { // Verify that frequency bars are logarithmically distributed - let num_bars = 64; + let num_bars = NUM_BARS; // Use the actual constant let min_log = 20.0_f32.ln(); let max_log = 20000.0_f32.ln(); @@ -189,8 +189,9 @@ mod tests { // First bar should be around 20Hz assert!(freqs[0] > 19.0 && freqs[0] < 25.0); - // Middle bar (~32) should be around 630Hz (geometric mean of 20 and 20000) - assert!(freqs[32] > 500.0 && freqs[32] < 800.0); + // Middle bar (~16 for 32 bars) should be around 630Hz (geometric mean of 20 and 20000) + let mid = num_bars / 2; + assert!(freqs[mid] > 500.0 && freqs[mid] < 800.0); // Last bar should approach 20000Hz assert!(freqs[num_bars - 1] > 15000.0); diff --git a/src-tauri/src/visualizer/mod.rs b/src-tauri/src/visualizer/mod.rs index bb31f6ac7..5615991f2 100644 --- a/src-tauri/src/visualizer/mod.rs +++ b/src-tauri/src/visualizer/mod.rs @@ -17,14 +17,15 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use tauri::AppHandle; /// Number of frequency bins to send to frontend -pub const NUM_BARS: usize = 64; +/// 16 bins, mirrored on frontend for symmetric look +pub const NUM_BARS: usize = 16; /// FFT size (must be power of 2) -/// 2048 gives good frequency resolution at ~21Hz per bin at 44.1kHz -pub const FFT_SIZE: usize = 2048; +/// 1024 is faster than 2048 and still gives ~43Hz resolution at 44.1kHz +pub const FFT_SIZE: usize = 1024; /// Target frames per second for visualization updates -pub const TARGET_FPS: u64 = 60; +pub const TARGET_FPS: u64 = 30; /// Shared state for visualization that can be passed to the audio thread #[derive(Clone)] diff --git a/src/lib/components/immersive/ImmersivePlayer.svelte b/src/lib/components/immersive/ImmersivePlayer.svelte index 068ee1e13..66ead984a 100644 --- a/src/lib/components/immersive/ImmersivePlayer.svelte +++ b/src/lib/components/immersive/ImmersivePlayer.svelte @@ -376,6 +376,9 @@ {trackTitle} {artist} {album} + {quality} + {bitDepth} + {samplingRate} /> {:else if activeFocusTab === 'lyrics-focus'} diff --git a/src/lib/components/immersive/panels/VisualizerPanel.svelte b/src/lib/components/immersive/panels/VisualizerPanel.svelte index e9eb514c6..9e0ceb12e 100644 --- a/src/lib/components/immersive/panels/VisualizerPanel.svelte +++ b/src/lib/components/immersive/panels/VisualizerPanel.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; + import QualityBadge from '$lib/components/QualityBadge.svelte'; interface Props { enabled?: boolean; @@ -9,9 +10,21 @@ trackTitle?: string; artist?: string; album?: string; + quality?: string; + bitDepth?: number; + samplingRate?: number; } - let { enabled = true, artwork = '', trackTitle = '', artist = '', album = '' }: Props = $props(); + let { + enabled = true, + artwork = '', + trackTitle = '', + artist = '', + album = '', + quality, + bitDepth, + samplingRate + }: Props = $props(); let canvasRef: HTMLCanvasElement | null = $state(null); let ctx: CanvasRenderingContext2D | null = null; @@ -19,12 +32,76 @@ let unlisten: UnlistenFn | null = null; let isInitialized = false; - const NUM_BARS = 64; + const NUM_BARS = 16; // Backend sends 16, we mirror for 32 visual bars const frequencyData = new Float32Array(NUM_BARS); const smoothedData = new Float32Array(NUM_BARS); // Smoothing for visual continuity - const SMOOTHING = 0.7; + const SMOOTHING = 0.6; + + // Throttle rendering to 30fps max + let lastRenderTime = 0; + const FRAME_INTERVAL = 1000 / 30; // ~33ms + + // Colors extracted from artwork (Material You style) + let colorPrimary = $state({ r: 0, g: 220, b: 200 }); // Default cyan + let colorSecondary = $state({ r: 150, g: 50, b: 255 }); // Default purple + + // Extract dominant colors from artwork + function extractColors(imgSrc: string) { + if (!imgSrc) return; + + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + // Use tiny canvas for fast sampling + const sampleCanvas = document.createElement('canvas'); + const size = 10; + sampleCanvas.width = size; + sampleCanvas.height = size; + const sampleCtx = sampleCanvas.getContext('2d'); + if (!sampleCtx) return; + + sampleCtx.drawImage(img, 0, 0, size, size); + const data = sampleCtx.getImageData(0, 0, size, size).data; + + // Collect vibrant colors (avoid black/dark and very light) + const colors: { r: number; g: number; b: number; sat: number }[] = []; + for (let i = 0; i < data.length; i += 4) { + const r = data[i], g = data[i + 1], b = data[i + 2]; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const lum = (max + min) / 2; + const sat = max === min ? 0 : (max - min) / (lum > 127 ? 510 - max - min : max + min); + + // Skip black/near-black (lum > 60) and very light (lum < 220) + // Also require decent saturation for vibrant colors + if (lum > 60 && lum < 220 && sat > 0.15) { + colors.push({ r, g, b, sat }); + } + } + + if (colors.length >= 2) { + // Sort by saturation, pick most vibrant + colors.sort((a, b) => b.sat - a.sat); + colorPrimary = { r: colors[0].r, g: colors[0].g, b: colors[0].b }; + // Pick a contrasting color (from the other half) + const midIdx = Math.floor(colors.length / 2); + colorSecondary = { r: colors[midIdx].r, g: colors[midIdx].g, b: colors[midIdx].b }; + } else if (colors.length === 1) { + colorPrimary = { r: colors[0].r, g: colors[0].g, b: colors[0].b }; + // Create secondary by shifting hue + colorSecondary = { r: colors[0].b, g: colors[0].r, b: colors[0].g }; + } + }; + img.src = imgSrc; + } + + // Re-extract colors when artwork changes + $effect(() => { + if (artwork) { + extractColors(artwork); + } + }); async function init() { if (!canvasRef || isInitialized) return; @@ -61,91 +138,75 @@ } }); - render(); + render(0); } - function render() { + function render(timestamp: number = 0) { if (!ctx || !canvasRef) return; + // Throttle to 30fps + const delta = timestamp - lastRenderTime; + if (delta < FRAME_INTERVAL) { + animationFrame = requestAnimationFrame(render); + return; + } + lastRenderTime = timestamp; + const rect = canvasRef.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; - canvasRef.width = rect.width * dpr; - canvasRef.height = rect.height * dpr; - ctx.scale(dpr, dpr); - const width = rect.width; const height = rect.height; - const centerY = height / 2; - const maxAmplitude = height * 0.35; + + // Only resize canvas when needed (expensive operation) + const targetWidth = Math.floor(width * dpr); + const targetHeight = Math.floor(height * dpr); + if (canvasRef.width !== targetWidth || canvasRef.height !== targetHeight) { + canvasRef.width = targetWidth; + canvasRef.height = targetHeight; + ctx.scale(dpr, dpr); + } // Clear with black ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, width, height); - // Draw wave - top half - ctx.beginPath(); - ctx.moveTo(0, centerY); + // Bar visualization with cubes - mirrored from center + const visualBars = NUM_BARS * 2; // 16 real + 16 mirrored = 32 visual + const barGap = 4; + const totalBarWidth = width / visualBars; + const barWidth = totalBarWidth - barGap; + const cubeHeight = 6; + const cubeGap = 2; + const maxBarHeight = height * 0.7; + const baseY = height * 0.85; + const centerX = width / 2; + // Draw bars from edges inward (mirrored, bass at edges) for (let i = 0; i < NUM_BARS; i++) { - const x = (i / (NUM_BARS - 1)) * width; - const amplitude = frequencyData[i] * maxAmplitude; - const y = centerY - amplitude; + const amplitude = frequencyData[i]; + const barHeight = amplitude * maxBarHeight; + const numCubes = Math.floor(barHeight / (cubeHeight + cubeGap)); - if (i === 0) { - ctx.moveTo(x, y); - } else { - // Smooth curve using quadratic bezier - const prevX = ((i - 1) / (NUM_BARS - 1)) * width; - const cpX = (prevX + x) / 2; - ctx.quadraticCurveTo(prevX, centerY - frequencyData[i - 1] * maxAmplitude, cpX, (centerY - frequencyData[i - 1] * maxAmplitude + y) / 2); + // Left side (from left edge toward center) + const xLeft = i * totalBarWidth + barGap / 2; + // Right side (from right edge toward center) + const xRight = width - (i + 1) * totalBarWidth + barGap / 2; + + for (let j = 0; j < numCubes; j++) { + const cubeY = baseY - (j + 1) * (cubeHeight + cubeGap); + const t = Math.min(j / 20, 1); // Normalized 0-1 + + // Interpolate between primary (bottom) and secondary (top) + const r = Math.floor(colorPrimary.r + t * (colorSecondary.r - colorPrimary.r)); + const g = Math.floor(colorPrimary.g + t * (colorSecondary.g - colorPrimary.g)); + const b = Math.floor(colorPrimary.b + t * (colorSecondary.b - colorPrimary.b)); + + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + ctx.fillRect(xLeft, cubeY, barWidth, cubeHeight); + ctx.fillRect(xRight, cubeY, barWidth, cubeHeight); } } - // Create gradient for top wave - const gradientTop = ctx.createLinearGradient(0, centerY - maxAmplitude, 0, centerY); - gradientTop.addColorStop(0, 'rgba(0, 255, 200, 0.9)'); - gradientTop.addColorStop(0.5, 'rgba(100, 100, 255, 0.7)'); - gradientTop.addColorStop(1, 'rgba(150, 50, 200, 0.3)'); - - ctx.strokeStyle = gradientTop; - ctx.lineWidth = 3; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.stroke(); - - // Draw mirrored wave - bottom half - ctx.beginPath(); - for (let i = 0; i < NUM_BARS; i++) { - const x = (i / (NUM_BARS - 1)) * width; - const amplitude = frequencyData[i] * maxAmplitude; - const y = centerY + amplitude; - - if (i === 0) { - ctx.moveTo(x, y); - } else { - const prevX = ((i - 1) / (NUM_BARS - 1)) * width; - const cpX = (prevX + x) / 2; - ctx.quadraticCurveTo(prevX, centerY + frequencyData[i - 1] * maxAmplitude, cpX, (centerY + frequencyData[i - 1] * maxAmplitude + y) / 2); - } - } - - // Gradient for bottom wave (mirrored colors) - const gradientBottom = ctx.createLinearGradient(0, centerY, 0, centerY + maxAmplitude); - gradientBottom.addColorStop(0, 'rgba(150, 50, 200, 0.3)'); - gradientBottom.addColorStop(0.5, 'rgba(100, 100, 255, 0.7)'); - gradientBottom.addColorStop(1, 'rgba(0, 255, 200, 0.9)'); - - ctx.strokeStyle = gradientBottom; - ctx.stroke(); - - // Draw center line (subtle) - ctx.beginPath(); - ctx.moveTo(0, centerY); - ctx.lineTo(width, centerY); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; - ctx.lineWidth = 1; - ctx.stroke(); - animationFrame = requestAnimationFrame(render); } @@ -189,17 +250,23 @@
{artist}
- {#if album} -{album}
- {/if} -{artist}
+ {#if album} +{album}
+ {/if} +