Switch to Canvas 2D wave visualizer for lower CPU usage

- Replace WebGL2 instanced bars with simple Canvas 2D waves
- Mirror wave from center (top + bottom)
- Quadratic bezier curves for smooth lines
- Gradient coloring: cyan -> blue -> purple
- Black #000000 background
- Much lighter than 128 WebGL instances
This commit is contained in:
vicrodh committed 2026-02-01 15:38:44 -06:00
1 parent 283d17b5b8
commit 55c3e80bbc
1 file changed
+90 -216
@@ -13,180 +13,27 @@
let { enabled = true, artwork = '', trackTitle = '', artist = '' }: Props = $props();
let canvasRef: HTMLCanvasElement | null = $state(null);
let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let frequencyTexture: WebGLTexture | null = null;
let vao: WebGLVertexArrayObject | null = null;
let ctx: CanvasRenderingContext2D | null = null;
let animationFrame: number | null = null;
let unlisten: UnlistenFn | null = null;
let isInitialized = false;
const NUM_BARS = 64;
const frequencyData = new Float32Array(NUM_BARS);
const smoothedData = new Float32Array(NUM_BARS);
// Vertex shader - MIRROR mode: bars extend from center up and down
const vertexSource = `#version 300 es
precision highp float;
// Smoothing for visual continuity
const SMOOTHING = 0.7;
in vec2 a_position;
in float a_mirror; // 0 = top half, 1 = bottom half (mirrored)
uniform sampler2D u_frequencies;
uniform vec2 u_resolution;
uniform float u_barWidth;
uniform float u_gap;
uniform float u_maxHeight;
uniform float u_centerY;
out float v_height;
out float v_barIndex;
out float v_verticalPos;
void main() {
int barIndex = gl_InstanceID / 2; // Each bar has 2 instances (top + bottom)
int isBottom = gl_InstanceID % 2;
float freq = texelFetch(u_frequencies, ivec2(barIndex, 0), 0).r;
float totalWidth = u_barWidth + u_gap;
float totalBarsWidth = float(${NUM_BARS}) * totalWidth - u_gap;
float offsetX = (u_resolution.x - totalBarsWidth) / 2.0;
float x = offsetX + float(barIndex) * totalWidth + a_position.x * u_barWidth;
// Mirror: top bars go up from center, bottom bars go down
float barHeight = freq * u_maxHeight;
float y;
if (isBottom == 0) {
// Top half: y goes from centerY to centerY + height
y = u_centerY + a_position.y * barHeight;
} else {
// Bottom half: y goes from centerY to centerY - height (mirrored)
y = u_centerY - a_position.y * barHeight;
}
vec2 clipSpace = (vec2(x, y) / u_resolution) * 2.0 - 1.0;
gl_Position = vec4(clipSpace * vec2(1, 1), 0, 1);
v_height = freq;
v_barIndex = float(barIndex) / float(${NUM_BARS});
v_verticalPos = a_position.y;
}
`;
// Fragment shader with enhanced visuals
const fragmentSource = `#version 300 es
precision highp float;
in float v_height;
in float v_barIndex;
in float v_verticalPos;
out vec4 fragColor;
uniform vec3 u_colorLow;
uniform vec3 u_colorMid;
uniform vec3 u_colorHigh;
void main() {
// Three-color gradient based on vertical position
vec3 color;
if (v_verticalPos < 0.5) {
color = mix(u_colorLow, u_colorMid, v_verticalPos * 2.0);
} else {
color = mix(u_colorMid, u_colorHigh, (v_verticalPos - 0.5) * 2.0);
}
// Intensity boost based on frequency height
color *= 0.7 + 0.5 * v_height;
// Glow effect for peaks
float glow = smoothstep(0.5, 1.0, v_height) * 0.3;
color += glow;
// Slight transparency fade at edges
float alpha = 0.9 - 0.1 * v_verticalPos;
fragColor = vec4(color, alpha);
}
`;
function createShader(glCtx: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
const shader = glCtx.createShader(type);
if (!shader) return null;
glCtx.shaderSource(shader, source);
glCtx.compileShader(shader);
if (!glCtx.getShaderParameter(shader, glCtx.COMPILE_STATUS)) {
console.error('Shader compile error:', glCtx.getShaderInfoLog(shader));
glCtx.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(glCtx: WebGL2RenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram | null {
const prog = glCtx.createProgram();
if (!prog) return null;
glCtx.attachShader(prog, vs);
glCtx.attachShader(prog, fs);
glCtx.linkProgram(prog);
if (!glCtx.getProgramParameter(prog, glCtx.LINK_STATUS)) {
console.error('Program link error:', glCtx.getProgramInfoLog(prog));
glCtx.deleteProgram(prog);
return null;
}
return prog;
}
async function initWebGL() {
async function init() {
if (!canvasRef || isInitialized) return;
gl = canvasRef.getContext('webgl2', { alpha: true, premultipliedAlpha: false });
if (!gl) {
console.warn('WebGL2 not available for visualizer');
ctx = canvasRef.getContext('2d');
if (!ctx) {
console.warn('Canvas 2D not available');
return;
}
const vs = createShader(gl, gl.VERTEX_SHADER, vertexSource);
const fs = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vs || !fs) return;
program = createProgram(gl, vs, fs);
if (!program) return;
// Create VAO with quad vertices
vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// Quad: bottom-left origin, extends up and right
const positions = new Float32Array([
0, 0, 1, 0, 0, 1,
0, 1, 1, 0, 1, 1,
]);
const posBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const posLoc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
// Create frequency texture
frequencyTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, frequencyTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, NUM_BARS, 1, 0, gl.RED, gl.FLOAT, frequencyData);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
isInitialized = true;
// Enable backend
@@ -201,12 +48,14 @@
unlisten = await listen<number[]>('viz:data', (event) => {
const payload = event.payload;
if (Array.isArray(payload)) {
// Binary data came as array of numbers (bytes)
const bytes = new Uint8Array(payload);
const floats = new Float32Array(bytes.buffer);
if (floats.length === NUM_BARS) {
frequencyData.set(floats);
updateFrequencyTexture();
// Apply smoothing
for (let i = 0; i < NUM_BARS; i++) {
smoothedData[i] = smoothedData[i] * SMOOTHING + floats[i] * (1 - SMOOTHING);
}
frequencyData.set(smoothedData);
}
}
});
@@ -214,56 +63,87 @@
render();
}
function updateFrequencyTexture() {
if (!gl || !frequencyTexture) return;
gl.bindTexture(gl.TEXTURE_2D, frequencyTexture);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, NUM_BARS, 1, gl.RED, gl.FLOAT, frequencyData);
}
function render() {
if (!gl || !program || !frequencyTexture || !canvasRef || !vao) return;
if (!ctx || !canvasRef) return;
const rect = canvasRef.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvasRef.width = rect.width * dpr;
canvasRef.height = rect.height * dpr;
ctx.scale(dpr, dpr);
gl.viewport(0, 0, canvasRef.width, canvasRef.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
const width = rect.width;
const height = rect.height;
const centerY = height / 2;
const maxAmplitude = height * 0.35;
gl.useProgram(program);
gl.bindVertexArray(vao);
// Clear with black
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, width, height);
// Calculate dimensions
const barWidth = (rect.width / NUM_BARS) * 0.65;
const gap = (rect.width / NUM_BARS) * 0.35;
const maxHeight = rect.height * 0.35; // Each half gets 35% of height
const centerY = rect.height * 0.5;
// Draw wave - top half
ctx.beginPath();
ctx.moveTo(0, centerY);
// Set uniforms
gl.uniform2f(gl.getUniformLocation(program, 'u_resolution'), rect.width, rect.height);
gl.uniform1f(gl.getUniformLocation(program, 'u_barWidth'), barWidth);
gl.uniform1f(gl.getUniformLocation(program, 'u_gap'), gap);
gl.uniform1f(gl.getUniformLocation(program, 'u_maxHeight'), maxHeight);
gl.uniform1f(gl.getUniformLocation(program, 'u_centerY'), centerY);
for (let i = 0; i < NUM_BARS; i++) {
const x = (i / (NUM_BARS - 1)) * width;
const amplitude = frequencyData[i] * maxAmplitude;
const y = centerY - amplitude;
// Colors: teal -> purple -> pink
gl.uniform3f(gl.getUniformLocation(program, 'u_colorLow'), 0.0, 0.7, 0.7);
gl.uniform3f(gl.getUniformLocation(program, 'u_colorMid'), 0.5, 0.2, 0.8);
gl.uniform3f(gl.getUniformLocation(program, 'u_colorHigh'), 0.9, 0.3, 0.6);
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);
}
}
// Bind frequency texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, frequencyTexture);
gl.uniform1i(gl.getUniformLocation(program, 'u_frequencies'), 0);
// 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)');
// Enable blending
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
ctx.strokeStyle = gradientTop;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
// Draw: 2 instances per bar (top + bottom mirror)
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, NUM_BARS * 2);
// 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);
}
@@ -286,25 +166,19 @@
console.error('[Visualizer] Failed to disable backend:', e);
}
if (gl) {
if (frequencyTexture) gl.deleteTexture(frequencyTexture);
if (program) gl.deleteProgram(program);
if (vao) gl.deleteVertexArray(vao);
}
isInitialized = false;
}
onMount(() => {
if (enabled) {
initWebGL();
init();
}
return cleanup;
});
$effect(() => {
if (enabled && !isInitialized) {
initWebGL();
init();
} else if (!enabled && isInitialized) {
cleanup();
}
@@ -314,7 +188,6 @@
<div class="visualizer-panel" class:visible={enabled}>
<canvas bind:this={canvasRef} class="visualizer-canvas"></canvas>
<!-- Track info overlay -->
<div class="track-info">
{#if artwork}
<img src={artwork} alt={trackTitle} class="artwork" />
@@ -337,6 +210,7 @@
opacity: 0;
transition: opacity 300ms ease;
z-index: 5;
background: #000000;
}
.visualizer-panel.visible {
@@ -361,10 +235,10 @@
}
.artwork {
width: min(200px, 25vw);
height: min(200px, 25vw);
width: min(180px, 22vw);
height: min(180px, 22vw);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
object-fit: cover;
}
@@ -375,17 +249,17 @@
}
.title {
font-size: clamp(18px, 2.5vw, 28px);
font-size: clamp(16px, 2.2vw, 24px);
font-weight: 700;
color: white;
margin: 0;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.8);
}
.artist {
font-size: clamp(14px, 1.8vw, 18px);
font-size: clamp(12px, 1.5vw, 16px);
color: rgba(255, 255, 255, 0.8);
margin: 0;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
}
</style>