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
This commit is contained in:
vicrodh committed 2026-02-01 18:44:52 -06:00
1 parent 4b74e1c490
commit bb4fcfd6b8
4 files changed
+219 -119

No files matched your search

+4 -3
View File
@@ -175,7 +175,7 @@ mod tests {
#[test] #[test]
fn test_log_frequency_distribution() { fn test_log_frequency_distribution() {
// Verify that frequency bars are logarithmically distributed // 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 min_log = 20.0_f32.ln();
let max_log = 20000.0_f32.ln(); let max_log = 20000.0_f32.ln();
@@ -189,8 +189,9 @@ mod tests {
// First bar should be around 20Hz // First bar should be around 20Hz
assert!(freqs[0] > 19.0 && freqs[0] < 25.0); assert!(freqs[0] > 19.0 && freqs[0] < 25.0);
// Middle bar (~32) should be around 630Hz (geometric mean of 20 and 20000) // Middle bar (~16 for 32 bars) should be around 630Hz (geometric mean of 20 and 20000)
assert!(freqs[32] > 500.0 && freqs[32] < 800.0); let mid = num_bars / 2;
assert!(freqs[mid] > 500.0 && freqs[mid] < 800.0);
// Last bar should approach 20000Hz // Last bar should approach 20000Hz
assert!(freqs[num_bars - 1] > 15000.0); assert!(freqs[num_bars - 1] > 15000.0);
+5 -4
View File
@@ -17,14 +17,15 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use tauri::AppHandle; use tauri::AppHandle;
/// Number of frequency bins to send to frontend /// 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) /// FFT size (must be power of 2)
/// 2048 gives good frequency resolution at ~21Hz per bin at 44.1kHz /// 1024 is faster than 2048 and still gives ~43Hz resolution at 44.1kHz
pub const FFT_SIZE: usize = 2048; pub const FFT_SIZE: usize = 1024;
/// Target frames per second for visualization updates /// 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 /// Shared state for visualization that can be passed to the audio thread
#[derive(Clone)] #[derive(Clone)]
@@ -376,6 +376,9 @@
{trackTitle} {trackTitle}
{artist} {artist}
{album} {album}
{quality}
{bitDepth}
{samplingRate}
/> />
{:else if activeFocusTab === 'lyrics-focus'} {:else if activeFocusTab === 'lyrics-focus'}
<!-- Lyrics Focus: Single line, large, centered --> <!-- Lyrics Focus: Single line, large, centered -->
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import QualityBadge from '$lib/components/QualityBadge.svelte';
interface Props { interface Props {
enabled?: boolean; enabled?: boolean;
@@ -9,9 +10,21 @@
trackTitle?: string; trackTitle?: string;
artist?: string; artist?: string;
album?: 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 canvasRef: HTMLCanvasElement | null = $state(null);
let ctx: CanvasRenderingContext2D | null = null; let ctx: CanvasRenderingContext2D | null = null;
@@ -19,12 +32,76 @@
let unlisten: UnlistenFn | null = null; let unlisten: UnlistenFn | null = null;
let isInitialized = false; 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 frequencyData = new Float32Array(NUM_BARS);
const smoothedData = new Float32Array(NUM_BARS); const smoothedData = new Float32Array(NUM_BARS);
// Smoothing for visual continuity // 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() { async function init() {
if (!canvasRef || isInitialized) return; if (!canvasRef || isInitialized) return;
@@ -61,91 +138,75 @@
} }
}); });
render(); render(0);
} }
function render() { function render(timestamp: number = 0) {
if (!ctx || !canvasRef) return; 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 rect = canvasRef.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
canvasRef.width = rect.width * dpr;
canvasRef.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const width = rect.width; const width = rect.width;
const height = rect.height; 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 // Clear with black
ctx.fillStyle = '#000000'; ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
// Draw wave - top half // Bar visualization with cubes - mirrored from center
ctx.beginPath(); const visualBars = NUM_BARS * 2; // 16 real + 16 mirrored = 32 visual
ctx.moveTo(0, centerY); 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++) { for (let i = 0; i < NUM_BARS; i++) {
const x = (i / (NUM_BARS - 1)) * width; const amplitude = frequencyData[i];
const amplitude = frequencyData[i] * maxAmplitude; const barHeight = amplitude * maxBarHeight;
const y = centerY - amplitude; const numCubes = Math.floor(barHeight / (cubeHeight + cubeGap));
if (i === 0) { // Left side (from left edge toward center)
ctx.moveTo(x, y); const xLeft = i * totalBarWidth + barGap / 2;
} else { // Right side (from right edge toward center)
// Smooth curve using quadratic bezier const xRight = width - (i + 1) * totalBarWidth + barGap / 2;
const prevX = ((i - 1) / (NUM_BARS - 1)) * width;
const cpX = (prevX + x) / 2; for (let j = 0; j < numCubes; j++) {
ctx.quadraticCurveTo(prevX, centerY - frequencyData[i - 1] * maxAmplitude, cpX, (centerY - frequencyData[i - 1] * maxAmplitude + y) / 2); 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); animationFrame = requestAnimationFrame(render);
} }
@@ -189,17 +250,23 @@
<div class="visualizer-panel" class:visible={enabled}> <div class="visualizer-panel" class:visible={enabled}>
<canvas bind:this={canvasRef} class="visualizer-canvas"></canvas> <canvas bind:this={canvasRef} class="visualizer-canvas"></canvas>
<div class="track-info"> <div class="center-content">
<div class="text">
<p class="artist">{artist}</p>
{#if album}
<p class="album">{album}</p>
{/if}
<h1 class="title">{trackTitle}</h1>
</div>
{#if artwork} {#if artwork}
<img src={artwork} alt={trackTitle} class="artwork" /> <div class="artwork-container">
<img src={artwork} alt={trackTitle} class="artwork" />
</div>
{/if} {/if}
<div class="track-info">
<h1 class="track-title">{trackTitle}</h1>
<p class="track-artist">{artist}</p>
{#if album}
<p class="track-album">{album}</p>
{/if}
<div class="quality-badge-wrapper">
<QualityBadge {quality} {bitDepth} {samplingRate} />
</div>
</div>
</div> </div>
</div> </div>
@@ -228,59 +295,87 @@
height: 100%; height: 100%;
} }
.track-info { .center-content {
position: absolute; position: relative;
bottom: 140px;
right: 40px;
z-index: 10; z-index: 10;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
align-items: flex-end; align-items: center;
gap: 16px; gap: 20px;
padding-top: 70px;
padding-bottom: 120px;
}
.artwork-container {
width: min(45vh, 360px);
height: min(45vh, 360px);
border-radius: 8px;
overflow: hidden;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 20px 60px rgba(0, 0, 0, 0.3);
} }
.artwork { .artwork {
width: 108px; width: 100%;
height: 108px; height: 100%;
border-radius: 8px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6);
object-fit: cover; object-fit: cover;
flex-shrink: 0;
} }
.text { .track-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: center;
text-align: right; text-align: center;
gap: 2px; gap: 6px;
max-width: 600px;
} }
.title { .track-title {
font-size: 13px; font-size: clamp(20px, 3vw, 28px);
font-weight: 500; font-weight: 700;
color: rgba(255, 255, 255, 0.8); color: var(--text-primary, white);
margin: 0; margin: 0;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8); text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.artist { .track-artist {
font-size: 14px; font-size: clamp(14px, 2vw, 18px);
font-weight: 600; color: var(--alpha-70, rgba(255, 255, 255, 0.7));
color: rgba(255, 255, 255, 0.9);
margin: 0; margin: 0;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.8);
} }
.album { .track-album {
font-size: 12px; font-size: clamp(12px, 1.5vw, 14px);
color: rgba(255, 255, 255, 0.6); color: var(--alpha-50, rgba(255, 255, 255, 0.5));
margin: 0; margin: 0;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.8);
font-style: italic; font-style: italic;
} }
.quality-badge-wrapper {
margin-top: 12px;
}
@media (max-width: 768px) {
.center-content {
padding: 70px 24px 130px;
gap: 16px;
}
.artwork-container {
width: min(55vw, 280px);
height: min(55vw, 280px);
}
}
@media (max-height: 600px) {
.artwork-container {
width: min(32vh, 220px);
height: min(32vh, 220px);
}
.track-info {
gap: 4px;
}
}
</style> </style>