51ec1503f4
Lint & Test / test (push) Successful in 10s
Frontend hot path (player.js, background.js):
- visualizer rAF: drop per-frame getComputedStyle('--accent') (cached on
applyAccentColor), build canvas LinearGradient once per accent change
instead of 32× per frame, batch all bars into a single beginPath/fill
- FPS-gate canvas redraw via frequencyDataVersion so 60-144 Hz monitors
stop re-rendering identical frames produced at 30 Hz on the backend
- editorial spectrum bars: replace style.height (layout) with
transform: scaleY (compositor-only); cache bar refs, pre-compute
per-bar gain/range, dedup writes at 1/1000 quantization
- coalesce VU needle into the visualizer rAF; cache vuNeedle ref;
dedup angle writes at 0.1°
- updateUI: status-payload fingerprint short-circuits the redundant
status_update broadcasts that fire during a track change
- swapArtworkSrc: only force layout reflow when keyframe is in flight;
drop the ?_=Date.now() cache-buster so identical artwork URLs reuse
the decoded bitmap; mini/glow imgs only re-set src when changed
- drop the fullscreen MutationObserver — fs-bloom-art is mirrored
directly from the artwork-swap path, eliminating the second blur paint
- updateProgress: skip text writes when the rounded second hasn't moved;
POSITION_INTERPOLATION_MS 100 → 250
- background.js: lift resizeBackgroundCanvas out of the rAF body, cache
step, accept new int-scaled wire format
CSS:
- spectrum bars use transform: scaleY(var(--bar-h-scale)) + transition
on transform; will-change updated to transform
- album-art-glow and fs-bloom-art switched to small-source-blur trick
(render at 20-25% size, scale 4-6×, lower blur radius) — visually
equivalent, ~10-25× cheaper repaint on track change
- drop unused transition: filter on .vinyl-stage #album-art
Backend (audio_analyzer.py, websocket_manager.py):
- pre-allocate windowed and cumsum buffers; replace
np.concatenate(([0.0], np.cumsum(...))) with cumsum[0]=0 +
np.cumsum(out=cumsum[1:]); float32 hanning window
- RMS via np.dot(mono, mono) — no astype copy, no ** temp
- int16 wire format (scale=1000) — smaller JSON, no Python float boxing
- versioned data + threading.Event so _audio_broadcast_loop is event-
driven (ev.wait + monotonic seq dedup) instead of polling on a timer
with the always-false `data is _last_data` identity check
ruff clean, pytest 7 passed / 3 numpy-skipped, esbuild bundle 113.6 kB.
343 lines
11 KiB
JavaScript
343 lines
11 KiB
JavaScript
// ============================================================
|
||
// Background: WebGL shader-based dynamic background
|
||
// ============================================================
|
||
|
||
import { frequencyData } from './player.js';
|
||
|
||
let bgCanvas = null;
|
||
let bgGL = null;
|
||
let bgProgram = null;
|
||
let bgUniforms = null; // Cached uniform locations
|
||
let bgAnimFrame = null;
|
||
let bgEnabled = localStorage.getItem('dynamicBackground') === 'true';
|
||
let bgStartTime = 0;
|
||
let bgSmoothedBands = new Float32Array(16);
|
||
let bgSmoothedBass = 0;
|
||
let bgAccentRGB = [0.114, 0.725, 0.329]; // Cached accent color (default green)
|
||
let bgBgColorRGB = [0.071, 0.071, 0.071]; // Cached page background (#121212)
|
||
|
||
const BG_BAND_COUNT = 16;
|
||
const BG_SMOOTHING = 0.12;
|
||
|
||
// ---- Shaders ----
|
||
|
||
const BG_VERT_SRC = `
|
||
attribute vec2 a_position;
|
||
void main() {
|
||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||
}
|
||
`;
|
||
|
||
const BG_FRAG_SRC = `
|
||
precision mediump float;
|
||
|
||
uniform vec2 u_resolution;
|
||
uniform float u_time;
|
||
uniform float u_bass;
|
||
uniform float u_bands[16];
|
||
uniform vec3 u_accent;
|
||
uniform vec3 u_bgColor;
|
||
|
||
// Smooth noise
|
||
float hash(vec2 p) {
|
||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||
}
|
||
|
||
float noise(vec2 p) {
|
||
vec2 i = floor(p);
|
||
vec2 f = fract(p);
|
||
f = f * f * (3.0 - 2.0 * f);
|
||
float a = hash(i);
|
||
float b = hash(i + vec2(1.0, 0.0));
|
||
float c = hash(i + vec2(0.0, 1.0));
|
||
float d = hash(i + vec2(1.0, 1.0));
|
||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||
}
|
||
|
||
void main() {
|
||
vec2 uv = gl_FragCoord.xy / u_resolution;
|
||
float aspect = u_resolution.x / u_resolution.y;
|
||
|
||
// Center coordinates for radial effects
|
||
vec2 center = (uv - 0.5) * vec2(aspect, 1.0);
|
||
float dist = length(center);
|
||
float angle = atan(center.y, center.x);
|
||
|
||
// Slow base animation
|
||
float t = u_time * 0.15;
|
||
|
||
// === Layer 1: Flowing wave field ===
|
||
float waves = 0.0;
|
||
for (int i = 0; i < 5; i++) {
|
||
float fi = float(i);
|
||
float freq = 1.5 + fi * 0.8;
|
||
float speed = t * (0.6 + fi * 0.15);
|
||
// Sample a band for this wave layer
|
||
int bandIdx = i * 3;
|
||
float bandVal = 0.0;
|
||
// Manual indexing (GLSL ES doesn't allow variable array index in some drivers)
|
||
for (int j = 0; j < 16; j++) {
|
||
if (j == bandIdx) bandVal = u_bands[j];
|
||
}
|
||
float amp = 0.015 + bandVal * 0.06;
|
||
waves += amp * sin(uv.x * freq * 6.2832 + speed + sin(uv.y * 3.0 + t) * 2.0);
|
||
waves += amp * 0.5 * sin(uv.y * freq * 4.0 - speed * 0.7 + cos(uv.x * 2.5 + t) * 1.5);
|
||
}
|
||
|
||
// === Layer 2: Radial pulse (bass-driven) ===
|
||
float pulse = smoothstep(0.6 + u_bass * 0.3, 0.0, dist) * (0.08 + u_bass * 0.15);
|
||
|
||
// === Layer 3: Frequency ring arcs ===
|
||
float rings = 0.0;
|
||
for (int i = 0; i < 8; i++) {
|
||
float fi = float(i);
|
||
float bandVal = 0.0;
|
||
for (int j = 0; j < 16; j++) {
|
||
if (j == i * 2) bandVal = u_bands[j];
|
||
}
|
||
float radius = 0.15 + fi * 0.1;
|
||
float ringWidth = 0.008 + bandVal * 0.025;
|
||
float ring = smoothstep(ringWidth, 0.0, abs(dist - radius - bandVal * 0.05));
|
||
// Fade ring by angle sector for variety
|
||
float angleFade = 0.5 + 0.5 * sin(angle * (2.0 + fi) + t * (1.0 + fi * 0.3));
|
||
rings += ring * angleFade * (0.3 + bandVal * 0.7);
|
||
}
|
||
|
||
// === Layer 4: Subtle noise texture ===
|
||
float n = noise(uv * 4.0 + t * 0.5) * 0.03;
|
||
|
||
// Combine layers
|
||
float intensity = waves + pulse + rings * 0.5 + n;
|
||
|
||
// Color: accent color with varying brightness
|
||
vec3 col = u_accent * intensity;
|
||
|
||
// Subtle secondary hue shift for depth
|
||
vec3 shifted = u_accent.gbr; // Rotated accent
|
||
col += shifted * rings * 0.15;
|
||
|
||
// Vignette
|
||
float vignette = 1.0 - smoothstep(0.3, 1.2, dist);
|
||
col *= vignette;
|
||
|
||
// Blend over page background
|
||
col = clamp(col, 0.0, 1.0);
|
||
float colBright = (col.r + col.g + col.b) / 3.0;
|
||
float bgLum = dot(u_bgColor, vec3(0.299, 0.587, 0.114));
|
||
// Dark bg: add accent light. Light bg: tint white toward accent via multiply.
|
||
vec3 darkResult = u_bgColor + col;
|
||
vec3 lightResult = u_bgColor * mix(vec3(1.0), u_accent, colBright * 2.0);
|
||
vec3 finalColor = clamp(mix(darkResult, lightResult, bgLum), 0.0, 1.0);
|
||
|
||
gl_FragColor = vec4(finalColor, 1.0);
|
||
}
|
||
`;
|
||
|
||
// ---- WebGL setup ----
|
||
|
||
function initBackgroundGL() {
|
||
bgCanvas = document.getElementById('bg-shader-canvas');
|
||
if (!bgCanvas) return false;
|
||
|
||
bgGL = bgCanvas.getContext('webgl', { alpha: false, antialias: false, depth: false, stencil: false });
|
||
if (!bgGL) {
|
||
console.warn('WebGL not available for background shader');
|
||
return false;
|
||
}
|
||
|
||
const gl = bgGL;
|
||
|
||
// Compile shaders
|
||
const vs = gl.createShader(gl.VERTEX_SHADER);
|
||
gl.shaderSource(vs, BG_VERT_SRC);
|
||
gl.compileShader(vs);
|
||
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
|
||
console.error('BG vertex shader:', gl.getShaderInfoLog(vs));
|
||
return false;
|
||
}
|
||
|
||
const fs = gl.createShader(gl.FRAGMENT_SHADER);
|
||
gl.shaderSource(fs, BG_FRAG_SRC);
|
||
gl.compileShader(fs);
|
||
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
|
||
console.error('BG fragment shader:', gl.getShaderInfoLog(fs));
|
||
return false;
|
||
}
|
||
|
||
bgProgram = gl.createProgram();
|
||
gl.attachShader(bgProgram, vs);
|
||
gl.attachShader(bgProgram, fs);
|
||
gl.linkProgram(bgProgram);
|
||
if (!gl.getProgramParameter(bgProgram, gl.LINK_STATUS)) {
|
||
console.error('BG program link:', gl.getProgramInfoLog(bgProgram));
|
||
return false;
|
||
}
|
||
|
||
// Fullscreen quad
|
||
const buf = gl.createBuffer();
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||
-1, -1, 1, -1, -1, 1,
|
||
-1, 1, 1, -1, 1, 1
|
||
]), gl.STATIC_DRAW);
|
||
|
||
const aPos = gl.getAttribLocation(bgProgram, 'a_position');
|
||
gl.enableVertexAttribArray(aPos);
|
||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||
|
||
gl.useProgram(bgProgram);
|
||
|
||
// Cache uniform locations once (avoids per-frame lookups)
|
||
bgUniforms = {
|
||
resolution: gl.getUniformLocation(bgProgram, 'u_resolution'),
|
||
time: gl.getUniformLocation(bgProgram, 'u_time'),
|
||
bass: gl.getUniformLocation(bgProgram, 'u_bass'),
|
||
bands: gl.getUniformLocation(bgProgram, 'u_bands'),
|
||
accent: gl.getUniformLocation(bgProgram, 'u_accent'),
|
||
bgColor: gl.getUniformLocation(bgProgram, 'u_bgColor'),
|
||
};
|
||
|
||
bgStartTime = performance.now() / 1000;
|
||
updateBackgroundColors();
|
||
resizeBackgroundCanvas();
|
||
window.addEventListener('resize', resizeBackgroundCanvas);
|
||
|
||
return true;
|
||
}
|
||
|
||
function resizeBackgroundCanvas() {
|
||
if (!bgCanvas) return;
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5); // Cap DPR for performance
|
||
const w = Math.floor(window.innerWidth * dpr);
|
||
const h = Math.floor(window.innerHeight * dpr);
|
||
if (bgCanvas.width !== w || bgCanvas.height !== h) {
|
||
bgCanvas.width = w;
|
||
bgCanvas.height = h;
|
||
}
|
||
}
|
||
|
||
// ---- Cached color/theme updates (called on accent or theme change, not per-frame) ----
|
||
|
||
export function updateBackgroundColors() {
|
||
const style = getComputedStyle(document.documentElement);
|
||
const accentHex = style.getPropertyValue('--accent').trim();
|
||
if (accentHex && accentHex.length >= 7) {
|
||
bgAccentRGB[0] = parseInt(accentHex.slice(1, 3), 16) / 255;
|
||
bgAccentRGB[1] = parseInt(accentHex.slice(3, 5), 16) / 255;
|
||
bgAccentRGB[2] = parseInt(accentHex.slice(5, 7), 16) / 255;
|
||
}
|
||
const bgHex = style.getPropertyValue('--bg-primary').trim();
|
||
if (bgHex && bgHex.length >= 7) {
|
||
bgBgColorRGB[0] = parseInt(bgHex.slice(1, 3), 16) / 255;
|
||
bgBgColorRGB[1] = parseInt(bgHex.slice(3, 5), 16) / 255;
|
||
bgBgColorRGB[2] = parseInt(bgHex.slice(5, 7), 16) / 255;
|
||
}
|
||
}
|
||
|
||
// ---- Render loop ----
|
||
|
||
// Cached step into the bins array; recomputed only when bins.length
|
||
// changes (which happens at most once after the first audio frame
|
||
// arrives or when num_bins is reconfigured).
|
||
let bgBinsLength = -1;
|
||
let bgBinsStep = 1;
|
||
// Last applied resolution — drawing with stale viewport is harmless,
|
||
// but we still need to refresh the uniform after the resize listener
|
||
// has updated the canvas.
|
||
let bgLastResW = -1;
|
||
let bgLastResH = -1;
|
||
|
||
function renderBackgroundFrame() {
|
||
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
|
||
|
||
const gl = bgGL;
|
||
if (!gl || !bgUniforms) return;
|
||
|
||
// Resize listener already keeps canvas dimensions in sync — only
|
||
// touch the viewport when the canvas actually changed size, so the
|
||
// per-frame path doesn't read window.innerWidth (a layout-flushing
|
||
// property).
|
||
if (bgCanvas.width !== bgLastResW || bgCanvas.height !== bgLastResH) {
|
||
bgLastResW = bgCanvas.width;
|
||
bgLastResH = bgCanvas.height;
|
||
gl.viewport(0, 0, bgLastResW, bgLastResH);
|
||
gl.uniform2f(bgUniforms.resolution, bgLastResW, bgLastResH);
|
||
}
|
||
|
||
const time = performance.now() / 1000 - bgStartTime;
|
||
|
||
// Smooth audio data from the imported frequencyData (shared with visualizer).
|
||
// Backend may send float bins (legacy) or int×1000 (new); .scale tells us which.
|
||
if (frequencyData && frequencyData.frequencies) {
|
||
const bins = frequencyData.frequencies;
|
||
const scale = frequencyData.scale && frequencyData.scale > 0
|
||
? 1.0 / frequencyData.scale : 1.0;
|
||
if (bins.length !== bgBinsLength) {
|
||
bgBinsLength = bins.length;
|
||
bgBinsStep = Math.max(1, Math.floor(bgBinsLength / BG_BAND_COUNT));
|
||
}
|
||
const step = bgBinsStep;
|
||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||
let idx = i * step;
|
||
if (idx >= bgBinsLength) idx = bgBinsLength - 1;
|
||
const target = (bins[idx] || 0) * scale;
|
||
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
|
||
}
|
||
const targetBass = (frequencyData.bass || 0) * scale;
|
||
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
|
||
} else {
|
||
// Gentle decay when no audio
|
||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||
bgSmoothedBands[i] *= 0.95;
|
||
}
|
||
bgSmoothedBass *= 0.95;
|
||
}
|
||
|
||
// Set uniforms (locations cached at init, colors cached on change)
|
||
gl.uniform1f(bgUniforms.time, time);
|
||
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
|
||
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
|
||
gl.uniform3f(bgUniforms.accent, bgAccentRGB[0], bgAccentRGB[1], bgAccentRGB[2]);
|
||
gl.uniform3f(bgUniforms.bgColor, bgBgColorRGB[0], bgBgColorRGB[1], bgBgColorRGB[2]);
|
||
|
||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||
}
|
||
|
||
function startBackground() {
|
||
if (bgAnimFrame) return;
|
||
if (!bgGL && !initBackgroundGL()) return;
|
||
bgCanvas.classList.add('visible');
|
||
document.body.classList.add('dynamic-bg-active');
|
||
renderBackgroundFrame();
|
||
}
|
||
|
||
function stopBackground() {
|
||
if (bgAnimFrame) {
|
||
cancelAnimationFrame(bgAnimFrame);
|
||
bgAnimFrame = null;
|
||
}
|
||
if (bgCanvas) {
|
||
bgCanvas.classList.remove('visible');
|
||
}
|
||
document.body.classList.remove('dynamic-bg-active');
|
||
}
|
||
|
||
// ---- Public API ----
|
||
|
||
export function toggleDynamicBackground() {
|
||
bgEnabled = !bgEnabled;
|
||
localStorage.setItem('dynamicBackground', bgEnabled);
|
||
applyDynamicBackground();
|
||
}
|
||
|
||
export function applyDynamicBackground() {
|
||
const btn = document.getElementById('bgToggle');
|
||
if (bgEnabled) {
|
||
startBackground();
|
||
if (btn) btn.classList.add('active');
|
||
} else {
|
||
stopBackground();
|
||
if (btn) btn.classList.remove('active');
|
||
}
|
||
}
|