diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..602f9fb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Use Bun canary on Alpine +FROM oven/bun:canary-alpine + +# Set working directory +WORKDIR /app + +# Copy package files first for caching +COPY package.json bun.lock ./ + +# Install all dependencies (including devDeps) +RUN bun install + +# Copy the rest of the project +COPY . . + +# Build the project +RUN bun run build + +# Expose Vite preview port +EXPOSE 4173 + +# Run the built project +CMD ["bun", "run", "preview", "--", "--host", "0.0.0.0"] diff --git a/index.html b/index.html index 8f4329a..7c06a05 100644 --- a/index.html +++ b/index.html @@ -1908,6 +1908,17 @@ +
+
+ Visualizer Style + Select the visualization style +
+ +
Visualizer Mode diff --git a/js/settings.js b/js/settings.js index 7d1cb45..1ce4ec4 100644 --- a/js/settings.js +++ b/js/settings.js @@ -452,12 +452,14 @@ export function initializeSettings(scrobbler, player, api, ui) { const visualizerModeSetting = document.getElementById('visualizer-mode-setting'); const visualizerSmartIntensitySetting = document.getElementById('visualizer-smart-intensity-setting'); const visualizerSensitivitySetting = document.getElementById('visualizer-sensitivity-setting'); + const visualizerPresetSetting = document.getElementById('visualizer-preset-setting'); const updateVisualizerSettingsVisibility = (enabled) => { const display = enabled ? 'flex' : 'none'; if (visualizerModeSetting) visualizerModeSetting.style.display = display; if (visualizerSmartIntensitySetting) visualizerSmartIntensitySetting.style.display = display; if (visualizerSensitivitySetting) visualizerSensitivitySetting.style.display = display; + if (visualizerPresetSetting) visualizerPresetSetting.style.display = display; }; if (visualizerEnabledToggle) { @@ -470,6 +472,22 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } + // Visualizer Preset Select + const visualizerPresetSelect = document.getElementById('visualizer-preset-select'); + if (visualizerPresetSelect) { + visualizerPresetSelect.value = visualizerSettings.getPreset(); + visualizerPresetSelect.addEventListener('change', (e) => { + const val = e.target.value; + visualizerSettings.setPreset(val); + // Assuming 'ui' has access to 'visualizer' instance or we need to find it + // 'ui' is passed to initializeSettings. + // In ui.js, 'visualizer' is a property of UIRenderer. + if (ui && ui.visualizer) { + ui.visualizer.setPreset(val); + } + }); + } + // Visualizer Mode Select const visualizerModeSelect = document.getElementById('visualizer-mode-select'); if (visualizerModeSelect) { diff --git a/js/storage.js b/js/storage.js index 96efb2a..beb95c3 100644 --- a/js/storage.js +++ b/js/storage.js @@ -627,6 +627,19 @@ export const visualizerSettings = { SMART_INTENSITY_KEY: 'visualizer-smart-intensity', ENABLED_KEY: 'visualizer-enabled', MODE_KEY: 'visualizer-mode', // 'solid' or 'blended' + PRESET_KEY: 'visualizer-preset', + + getPreset() { + try { + return localStorage.getItem(this.PRESET_KEY) || 'lcd'; + } catch { + return 'lcd'; + } + }, + + setPreset(preset) { + localStorage.setItem(this.PRESET_KEY, preset); + }, isEnabled() { try { diff --git a/js/visualizer.js b/js/visualizer.js index 0bab0da..d53ec94 100644 --- a/js/visualizer.js +++ b/js/visualizer.js @@ -1,23 +1,54 @@ -//js/visualizer.js +// js/visualizer.js import { visualizerSettings } from './storage.js'; +import { LCDPreset } from './visualizers/lcd.js'; +import { ParticlesPreset } from './visualizers/particles.js'; +import { UnknownPleasuresPreset } from './visualizers/unknown_pleasures.js'; export class Visualizer { constructor(canvas, audio) { this.canvas = canvas; - this.ctx = canvas.getContext('2d'); + this.ctx = null; this.audio = audio; + this.audioContext = null; this.analyser = null; this.source = null; + this.isActive = false; this.animationId = null; - this.particles = []; - this.kick = 0; - this.lastIntensity = 0; - this.lastBeatTime = 0; - this.energyAverage = 0.3; - this.upbeatSmoother = 0; + this.presets = { + lcd: new LCDPreset(), + particles: new ParticlesPreset(), + 'unknown-pleasures': new UnknownPleasuresPreset(), + }; + + this.activePresetKey = visualizerSettings.getPreset(); + + // ---- AUDIO BUFFERS (REUSED) ---- + this.bufferLength = 0; + this.dataArray = null; + + // ---- STATS (REUSED OBJECT) ---- + this.stats = { + kick: 0, + intensity: 0, + energyAverage: 0.3, + lastBeatTime: 0, + lastIntensity: 0, + upbeatSmoother: 0, + sensitivity: 0.5, + primaryColor: '#ffffff', + mode: '', + }; + + // ---- CACHED STATE ---- + this._lastPrimaryColor = ''; + this._resizeBound = () => this.resize(); + } + + get activePreset() { + return this.presets[this.activePresetKey] || this.presets['lcd']; } init() { @@ -26,210 +57,165 @@ export class Visualizer { try { const AudioContext = window.AudioContext || window.webkitAudioContext; this.audioContext = new AudioContext(); + this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 512; this.analyser.smoothingTimeConstant = 0.7; + this.bufferLength = this.analyser.frequencyBinCount; + this.dataArray = new Uint8Array(this.bufferLength); + this.source = this.audioContext.createMediaElementSource(this.audio); this.source.connect(this.analyser); this.analyser.connect(this.audioContext.destination); } catch (e) { - console.warn('Visualizer init failed (likely CORS or already connected):', e); + console.warn('Visualizer init failed:', e); + } + } + + initContext() { + if (this.ctx) return; + + const preset = this.activePreset; + const type = preset.contextType || '2d'; + + if (type === 'webgl') { + this.ctx = + this.canvas.getContext('webgl2', { alpha: true, antialias: false }) || + this.canvas.getContext('webgl', { alpha: true, antialias: false }); + } else { + this.ctx = this.canvas.getContext('2d'); } } start() { if (this.isActive) return; + + if (!this.ctx) this.initContext(); if (!this.audioContext) this.init(); if (!this.analyser) return; this.isActive = true; + if (this.audioContext.state === 'suspended') { this.audioContext.resume(); } this.resize(); - window.addEventListener('resize', this.resizeBound); + window.addEventListener('resize', this._resizeBound); this.canvas.style.display = 'block'; - this.particles = []; - this.energyAverage = 0.3; - this.kick = 0; - this.upbeatSmoother = 0; this.animate(); } stop() { this.isActive = false; + if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } - window.removeEventListener('resize', this.resizeBound); - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + window.removeEventListener('resize', this._resizeBound); + + if (this.ctx && this.ctx.clearRect) { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + this.canvas.style.display = 'none'; } resize() { - this.canvas.width = window.innerWidth; - this.canvas.height = window.innerHeight; + const w = window.innerWidth; + const h = window.innerHeight; + + if (this.canvas.width !== w) this.canvas.width = w; + if (this.canvas.height !== h) this.canvas.height = h; + + if (this.activePreset?.resize) { + this.activePreset.resize(w, h); + } } - resizeBound = () => this.resize(); - - animate() { + animate = () => { if (!this.isActive) return; - this.animationId = requestAnimationFrame(() => this.animate()); + this.animationId = requestAnimationFrame(this.animate); - const w = this.canvas.width; - const h = this.canvas.height; - const ctx = this.ctx; + // ===== AUDIO ANALYSIS ===== + this.analyser.getByteFrequencyData(this.dataArray); - let sensitivity = visualizerSettings.getSensitivity(); - const mode = visualizerSettings.getMode(); - const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + // Bass (first bins only — cheap) + let bass = (this.dataArray[0] + this.dataArray[1] + this.dataArray[2] + this.dataArray[3]) * 0.000980392; // 1 / (4 * 255) - if (mode === 'blended') { - ctx.clearRect(0, 0, w, h); - } else { - // Match background to theme if in solid mode - if (isDark) { - ctx.fillStyle = 'rgba(10, 10, 10, 0.3)'; - } else { - ctx.fillStyle = 'rgba(240, 240, 240, 0.3)'; - } - ctx.fillRect(0, 0, w, h); - } - - const bufferLength = this.analyser.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - this.analyser.getByteFrequencyData(dataArray); - - let bassSum = 0; - for (let i = 0; i < 4; i++) bassSum += dataArray[i]; - const bass = bassSum / 4 / 255; const intensity = bass * bass; + const stats = this.stats; - this.energyAverage = this.energyAverage * 0.99 + intensity * 0.01; - this.upbeatSmoother = this.upbeatSmoother * 0.92 + intensity * 0.08; + stats.energyAverage = stats.energyAverage * 0.99 + intensity * 0.01; + stats.upbeatSmoother = stats.upbeatSmoother * 0.92 + intensity * 0.08; + // ===== SENSITIVITY ===== + let sensitivity = visualizerSettings.getSensitivity(); if (visualizerSettings.isSmartIntensityEnabled()) { - let target = 0.1; - if (this.energyAverage > 0.4) { - target = 0.7; - } else if (this.energyAverage > 0.2) { - const t = (this.energyAverage - 0.2) / 0.2; - target = 0.1 + t * 0.6; - } - sensitivity = target; - } - - let threshold = 0.5; - if (this.energyAverage < 0.3) { - threshold = 0.5 + (0.3 - this.energyAverage) * 2; - } - - const now = Date.now(); - if (intensity > threshold) { - if (intensity > this.lastIntensity + 0.05 && now - this.lastBeatTime > 50) { - this.kick = 1.0; - this.lastBeatTime = now; + if (stats.energyAverage > 0.4) { + sensitivity = 0.7; + } else if (stats.energyAverage > 0.2) { + sensitivity = 0.1 + ((stats.energyAverage - 0.2) / 0.2) * 0.6; } else { - if (this.upbeatSmoother > 0.6 && this.energyAverage > 0.4) { - const upbeatLevel = (this.upbeatSmoother - 0.6) / 0.4; - if (this.kick < upbeatLevel) { - this.kick = upbeatLevel; + sensitivity = 0.1; + } + } + + // ===== KICK DETECTION ===== + const now = performance.now(); + let threshold = stats.energyAverage < 0.3 ? 0.5 + (0.3 - stats.energyAverage) * 2 : 0.5; + + if (intensity > threshold) { + if (intensity > stats.lastIntensity + 0.05 && now - stats.lastBeatTime > 50) { + stats.kick = 1.0; + stats.lastBeatTime = now; + } else { + if (stats.upbeatSmoother > 0.6 && stats.energyAverage > 0.4) { + const upbeatLevel = (stats.upbeatSmoother - 0.6) / 0.4; + if (stats.kick < upbeatLevel) { + stats.kick = upbeatLevel; } else { - this.kick *= 0.95; + stats.kick *= 0.95; } } else { - this.kick *= 0.9; + stats.kick *= 0.9; } } } else { - this.kick *= 0.95; - } - this.lastIntensity = intensity; - - let shakeX = 0; - let shakeY = 0; - if (this.kick > 0.1) { - const shakeAmt = this.kick * 8 * sensitivity; - shakeX = (Math.random() - 0.5) * shakeAmt; - shakeY = (Math.random() - 0.5) * shakeAmt; + stats.kick *= 0.95; } - const primaryColor = - getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#ffffff'; + stats.lastIntensity = intensity; + stats.intensity = intensity; + stats.sensitivity = sensitivity; - const particleCount = 180; - if (this.particles.length !== particleCount) { - this.particles = []; - for (let i = 0; i < particleCount; i++) { - this.particles.push({ - x: Math.random() * w, - y: Math.random() * h, - vx: (Math.random() - 0.5) * 2, - vy: (Math.random() - 0.5) * 2, - baseSize: Math.random() * 3 + 1, - }); - } + // ===== COLORS (CACHED) ===== + const color = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#ffffff'; + + if (color !== this._lastPrimaryColor) { + stats.primaryColor = color; + this._lastPrimaryColor = color; } - ctx.save(); - ctx.translate(shakeX, shakeY); + stats.mode = visualizerSettings.getMode(); - ctx.fillStyle = primaryColor; - ctx.strokeStyle = primaryColor; + // ===== DRAW ===== + this.activePreset.draw(this.ctx, this.canvas, this.analyser, this.dataArray, stats); + }; - const maxDist = 150 + intensity * 50 + this.kick * 50 * sensitivity; - const maxDistSq = maxDist * maxDist; + setPreset(key) { + if (!this.presets[key]) return; - for (let i = 0; i < this.particles.length; i++) { - let p = this.particles[i]; - - const speedMult = 1 + intensity * 2 + this.kick * 8 * sensitivity; - p.x += p.vx * speedMult; - p.y += p.vy * speedMult; - - if (this.kick > 0.3) { - p.x += (Math.random() - 0.5) * this.kick * 2 * sensitivity; - p.y += (Math.random() - 0.5) * this.kick * 2 * sensitivity; - } - - if (p.x < 0) p.x = w; - if (p.x > w) p.x = 0; - if (p.y < 0) p.y = h; - if (p.y > h) p.y = 0; - - const size = p.baseSize * (1 + intensity * 0.5 + this.kick * 0.8 * sensitivity); - ctx.globalAlpha = 0.4 + intensity * 0.2 + this.kick * 0.15 * sensitivity; - ctx.beginPath(); - ctx.arc(p.x, p.y, size, 0, Math.PI * 2); - ctx.fill(); - - for (let j = i + 1; j < this.particles.length; j++) { - const p2 = this.particles[j]; - const dx = p.x - p2.x; - const dy = p.y - p2.y; - - // Optimization: Early exit for x distance - if (Math.abs(dx) > maxDist) continue; - - const distSq = dx * dx + dy * dy; - - if (distSq < maxDistSq) { - const dist = Math.sqrt(distSq); // Still need dist for alpha/linewidth, but now we only sqrt when necessary - ctx.beginPath(); - ctx.lineWidth = (1 - dist / maxDist) * (1 + this.kick * 1.5 * sensitivity); - ctx.globalAlpha = (1 - dist / maxDist) * (0.3 + intensity * 0.2 + this.kick * 0.3 * sensitivity); - ctx.moveTo(p.x, p.y); - ctx.lineTo(p2.x, p2.y); - ctx.stroke(); - } - } + if (this.activePreset?.destroy) { + this.activePreset.destroy(); } - ctx.restore(); + + this.activePresetKey = key; + this.initContext(); + this.resize(); } } diff --git a/js/visualizers/lcd.js b/js/visualizers/lcd.js new file mode 100644 index 0000000..492194c --- /dev/null +++ b/js/visualizers/lcd.js @@ -0,0 +1,386 @@ +export class LCDPreset { + constructor() { + this.name = 'LCD Pixels'; + this.gridCols = 48; + + // Auto-gain tracking + this.maxVol = 100; + this.volDecay = 0.995; + + // Smoothing state + this.prevData = new Float32Array(this.gridCols).fill(0); + this.peakData = new Float32Array(this.gridCols).fill(0); + + this.primaryColor = '#ffffff'; + this.disableShake = false; + + // WebGL grid overlay + this.glCanvas = null; + this.gl = null; + this.glProgram = null; + this.glInitialized = false; + } + + // Initialize WebGL grid overlay + initWebGL(width, height) { + if (this.glInitialized) return; + + // Create overlay canvas + this.glCanvas = document.createElement('canvas'); + this.glCanvas.width = width; + this.glCanvas.height = height; + this.glCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;mix-blend-mode:multiply;'; + + const gl = this.glCanvas.getContext('webgl', { alpha: true, premultipliedAlpha: false }); + if (!gl) { + console.warn('WebGL not available for grid overlay'); + return; + } + this.gl = gl; + + // Vertex shader (fullscreen quad) + const vsSource = ` + attribute vec2 a_position; + varying vec2 v_uv; + void main() { + v_uv = a_position * 0.5 + 0.5; + gl_Position = vec4(a_position, 0.0, 1.0); + } + `; + + // Fragment shader (LCD dot matrix with tilt-shift blur) + const fsSource = ` + precision highp float; + varying vec2 v_uv; + uniform vec2 u_resolution; + uniform float u_time; + + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + void main() { + vec2 uv = v_uv; + float aspect = u_resolution.x / u_resolution.y; + + // Skew transform + vec2 centered = uv - 0.5; + mat2 skewMatrix = mat2(1.0, 0.0, 0.20, 1.0); + vec2 skewed = skewMatrix * centered + 0.5; + + // Perspective: shrink towards right + float perspT = skewed.x; + float perspScale = mix(1.0, 0.5, perspT); + + // Tilt-shift: focus at 25%, blur both near (left) and far (right) + float focusPoint = 0.25; + float distFromFocus = abs(perspT - focusPoint); + float blurAmount = smoothstep(0.0, 0.6, distFromFocus); + + // Apply perspective + vec2 pUV = skewed; + pUV.y = (pUV.y - 0.5) * perspScale + 0.5; + pUV.x *= aspect; + + // Dot matrix grid + float cellSize = 0.0078 * perspScale; + vec2 gridUV = pUV / cellSize; + vec2 gv = fract(gridUV) - 0.5; + vec2 id = floor(gridUV); + + float d = length(gv); + float dotRadius = 0.35; + + // Dot edge with blur (pattern stays visible) + float sharpness = mix(0.08, 0.25, blurAmount); + float dotEdge = smoothstep(dotRadius - sharpness, dotRadius + sharpness * 0.3, d); + + // Per-cell noise + float noise = hash(id); + dotEdge *= 0.75 + noise * 0.25; + + // Subtle grain + float grain = hash(uv * u_resolution + u_time) * 0.015; + + // Output + float alpha = clamp(dotEdge * 0.5 + grain, 0.0, 0.5); + gl_FragColor = vec4(0.0, 0.0, 0.0, alpha); + } + `; + + // Compile shaders + const vs = this.compileShader(gl, gl.VERTEX_SHADER, vsSource); + const fs = this.compileShader(gl, gl.FRAGMENT_SHADER, fsSource); + if (!vs || !fs) return; + + // Link program + const program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error('Shader program failed to link'); + return; + } + + this.glProgram = program; + + // Create fullscreen quad + const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]); + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + + const posLoc = gl.getAttribLocation(program, 'a_position'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); + + // Store uniform locations + this.uResolution = gl.getUniformLocation(program, 'u_resolution'); + this.uTime = gl.getUniformLocation(program, 'u_time'); + + gl.useProgram(program); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + this.startTime = performance.now(); + this.glInitialized = true; + } + + compileShader(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error('Shader compile error:', gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + return shader; + } + + // Render WebGL grid overlay + renderHoneycomb(width, height) { + if (!this.gl || !this.glProgram) return; + + const gl = this.gl; + + // Resize if needed + if (this.glCanvas.width !== width || this.glCanvas.height !== height) { + this.glCanvas.width = width; + this.glCanvas.height = height; + gl.viewport(0, 0, width, height); + } + + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + // Pass uniforms + gl.uniform2f(this.uResolution, width, height); + gl.uniform1f(this.uTime, (performance.now() - this.startTime) / 1000.0); + + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + } + + resize() {} + + draw(ctx, canvas, analyser, dataArray, params) { + const { width, height } = canvas; + const { kick, intensity, primaryColor, mode } = params; + + this.primaryColor = primaryColor; + const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + + // --- Background --- + ctx.clearRect(0, 0, width, height); + if (mode !== 'blended') { + ctx.fillStyle = isDark ? '#050505' : '#e6e6e6'; + ctx.fillRect(0, 0, width, height); + } + + // --- Audio Data Processing --- + const data = this.processAudio(dataArray); + + // --- Perspective Constants --- + const centerX = width / 2; + const centerY = height * 0.35; + const startX = width * 0.05; + const endX = width * 0.95; + const totalW = endX - startX; + const maxBarH = height; + const startScale = 2.0; // Left (near) - increased + const endScale = 0.05; // Right (far) - decreased + + // --- Apply Global Skew Transform --- + ctx.save(); + ctx.translate(centerX, centerY); + ctx.transform(1, -0.08, 0.2, 1, 0, 0); + ctx.translate(-centerX, -centerY); + + // Shake on kick + if (!this.disableShake && kick > 0.3) { + const shake = kick * 40; + ctx.translate((Math.random() - 0.5) * 2 * shake, (Math.random() - 0.5) * shake); + } + + // --- Draw Bars --- + const baseBarW = (totalW / this.gridCols) * 0.7; // Base width + + for (let c = 0; c < this.gridCols; c++) { + const p = c / (this.gridCols - 1); + + // Simple perspective: scale goes from startScale (left) to endScale (right) + const scale = startScale + (endScale - startScale) * p; + + // Perspective spacing: gaps decrease linearly matching the scale + // Integral of linear scale function, normalized to 0-1 + const scaleDelta = endScale - startScale; + const pIntegral = startScale * p + 0.5 * scaleDelta * p * p; + const totalIntegral = startScale + 0.5 * scaleDelta; // Value at p=1 + const pPerspective = pIntegral / totalIntegral; + const cx = startX + pPerspective * totalW; + + // Width scales with perspective + const barW = baseBarW * scale; + + // Bar height - skip empty bars entirely + const normVal = data[c]; + if (normVal < 0.01) continue; + + const h = normVal * maxBarH * scale; + if (h < 1) continue; + + // Per-bar color variation + const variation = 0.75 + Math.abs(Math.sin(c * 127.1)) * 0.25; + ctx.fillStyle = this.adjustBrightness(primaryColor, variation); + + // Strong LCD light bleed effect + ctx.shadowBlur = 30 + normVal * 50; // Increased glow + ctx.shadowColor = primaryColor; + this.drawCapsule(ctx, cx, centerY, barW, h); + } + + ctx.restore(); + + // --- WebGL grid Overlay --- + // Initialize on first run + if (!this.glInitialized) { + this.initWebGL(width, height); + // Attach WebGL canvas to same parent as main canvas + if (this.glCanvas && canvas.parentElement) { + canvas.parentElement.style.position = 'relative'; + canvas.parentElement.appendChild(this.glCanvas); + } + } + + // Render and composite grid + this.renderHoneycomb(width, height); + } + + // Process audio with improved dynamics + processAudio(dataArray) { + const result = new Float32Array(this.gridCols); + const center = Math.floor(this.gridCols / 2); + const totalBins = dataArray.length; + let peakVal = 0; + + for (let i = 0; i < center; i++) { + const p = i / (center - 1); + + // Logarithmic frequency mapping + const minBin = 2; + const maxBin = totalBins * 0.65; + const startBin = Math.floor(minBin * Math.pow(maxBin / minBin, p)); + const endBin = Math.max(startBin + 1, Math.floor(minBin * Math.pow(maxBin / minBin, p + 1 / center))); + + let sum = 0, + count = 0; + for (let k = startBin; k < endBin && k < totalBins; k++) { + sum += dataArray[k]; + count++; + } + let val = count > 0 ? sum / count : 0; + + // Pink noise compensation (boost highs) + val *= 1 + p * 1.8; + if (val > peakVal) peakVal = val; + + // Mirror to left/right + const leftIdx = center - 1 - i; + const rightIdx = center + i; + + // Smooth with asymmetric rise/fall + const rise = 0.25; + const fall = 0.08; // Slower fall for smoother decay + + for (const idx of [leftIdx, rightIdx]) { + const prev = this.prevData[idx]; + const target = val; + this.prevData[idx] = prev + (target - prev) * (target > prev ? rise : fall); + } + } + + // Auto-gain with more headroom + this.maxVol = Math.max(this.maxVol * this.volDecay, peakVal, 40); + const normFactor = 200 / this.maxVol; + + // Normalize and apply contrast curve + for (let c = 0; c < this.gridCols; c++) { + let v = (this.prevData[c] * normFactor) / 255; + + // Noise gate: important to scale the bars + const gate = 0.5; + if (v < gate) v = 0; + else v = (v - gate) / (1 - gate); + + // Soft compression + contrast + v = Math.pow(Math.min(1, v), 2.2); + result[c] = v; + } + + return result; + } + + // Draw rounded capsule shape + drawCapsule(ctx, cx, cy, w, h) { + if (h < w) { + ctx.beginPath(); + ctx.arc(cx, cy, Math.max(0.5, h / 2), 0, Math.PI * 2); + ctx.fill(); + return; + } + const halfH = h / 2; + const r = w / 2; + ctx.beginPath(); + ctx.arc(cx, cy - halfH + r, r, Math.PI, 0); + ctx.lineTo(cx + r, cy + halfH - r); + ctx.arc(cx, cy + halfH - r, r, 0, Math.PI); + ctx.lineTo(cx - r, cy - halfH + r); + ctx.closePath(); + ctx.fill(); + } + + // Adjust hex color brightness + adjustBrightness(hex, factor) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const clamp = (v) => Math.min(255, Math.max(0, Math.round(v * factor))); + return `rgb(${clamp(r)},${clamp(g)},${clamp(b)})`; + } + + destroy() { + if (this.glCanvas) { + this.glCanvas.remove(); + this.glCanvas = null; + } + if (this.gl) { + const ext = this.gl.getExtension('WEBGL_lose_context'); + if (ext) ext.loseContext(); + this.gl = null; + } + this.glInitialized = false; + this.glProgram = null; + } +} diff --git a/js/visualizers/particles.js b/js/visualizers/particles.js new file mode 100644 index 0000000..bbe90a0 --- /dev/null +++ b/js/visualizers/particles.js @@ -0,0 +1,102 @@ +export class ParticlesPreset { + constructor() { + this.name = 'Particles'; + this.particles = []; + this.particleCount = 180; + } + + resize(width, height) { + // Particles don't need explicit resize logic unless we want to respawn them, + // but current logic handles boundaries in draw loop. + } + + destroy() { + // No cleanup needed + } + + draw(ctx, canvas, analyser, dataArray, params) { + const { width, height } = canvas; + const { kick, intensity, primaryColor } = params; + const sensitivity = params.sensitivity || 1.0; + + // Clear background + ctx.clearRect(0, 0, width, height); + + // Manage particle count + if (this.particles.length !== this.particleCount) { + this.particles = []; + for (let i = 0; i < this.particleCount; i++) { + this.particles.push({ + x: Math.random() * width, + y: Math.random() * height, + vx: (Math.random() - 0.5) * 2, + vy: (Math.random() - 0.5) * 2, + baseSize: Math.random() * 3 + 1, + }); + } + } + + ctx.save(); + + // Shake + let shakeX = 0; + let shakeY = 0; + if (kick > 0.1) { + const shakeAmt = kick * 8 * sensitivity; + shakeX = (Math.random() - 0.5) * shakeAmt; + shakeY = (Math.random() - 0.5) * shakeAmt; + } + ctx.translate(shakeX, shakeY); + + ctx.fillStyle = primaryColor; + ctx.strokeStyle = primaryColor; + + const maxDist = 150 + intensity * 50 + kick * 50 * sensitivity; + const maxDistSq = maxDist * maxDist; + + for (let i = 0; i < this.particles.length; i++) { + let p = this.particles[i]; + + const speedMult = 1 + intensity * 2 + kick * 8 * sensitivity; + p.x += p.vx * speedMult; + p.y += p.vy * speedMult; + + if (kick > 0.3) { + p.x += (Math.random() - 0.5) * kick * 2 * sensitivity; + p.y += (Math.random() - 0.5) * kick * 2 * sensitivity; + } + + if (p.x < 0) p.x = width; + if (p.x > width) p.x = 0; + if (p.y < 0) p.y = height; + if (p.y > height) p.y = 0; + + const size = p.baseSize * (1 + intensity * 0.5 + kick * 0.8 * sensitivity); + ctx.globalAlpha = 0.4 + intensity * 0.2 + kick * 0.15 * sensitivity; + ctx.beginPath(); + ctx.arc(p.x, p.y, size, 0, Math.PI * 2); + ctx.fill(); + + for (let j = i + 1; j < this.particles.length; j++) { + const p2 = this.particles[j]; + const dx = p.x - p2.x; + const dy = p.y - p2.y; + + if (Math.abs(dx) > maxDist) continue; + + const distSq = dx * dx + dy * dy; + + if (distSq < maxDistSq) { + const dist = Math.sqrt(distSq); + ctx.beginPath(); + ctx.lineWidth = (1 - dist / maxDist) * (1 + kick * 1.5 * sensitivity); + ctx.globalAlpha = (1 - dist / maxDist) * (0.3 + intensity * 0.2 + kick * 0.3 * sensitivity); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p2.x, p2.y); + ctx.stroke(); + } + } + } + ctx.restore(); + } +} diff --git a/js/visualizers/unknown_pleasures.js b/js/visualizers/unknown_pleasures.js new file mode 100644 index 0000000..a17528b --- /dev/null +++ b/js/visualizers/unknown_pleasures.js @@ -0,0 +1,141 @@ +export class UnknownPleasuresPreset { + constructor() { + this.name = 'Unknown Pleasures'; + this.historySize = 30; + this.dataPoints = 96; + + this.history = []; + this.writeIndex = 0; + + this.pLookup = new Float32Array(this.dataPoints); + this.xLookup = new Float32Array(this.dataPoints); + + // palette cache + this._palette = null; + this._paletteColor = ''; + + this.reset(); + this._precompute(); + } + + reset() { + this.history.length = 0; + for (let i = 0; i < this.historySize; i++) { + this.history.push(new Float32Array(this.dataPoints)); + } + this.writeIndex = 0; + } + + resize() {} + destroy() { + this.history.length = 0; + } + + _precompute() { + const pts = this.dataPoints; + const inv = 1 / (pts - 1); + for (let i = 0; i < pts; i++) { + const p = Math.abs(i * inv - 0.5) * 2; + this.pLookup[i] = 1 - p * p * p; + this.xLookup[i] = i * inv; + } + } + + _buildPalette(color) { + const r = parseInt(color.slice(1, 3), 16); + const g = parseInt(color.slice(3, 5), 16); + const b = parseInt(color.slice(5, 7), 16); + + // perceptual grayscale (same weights browsers use) + const gray = 0.299 * r + 0.587 * g + 0.114 * b; + + this._palette = new Array(this.historySize); + + for (let i = 0; i < this.historySize; i++) { + const p = i / (this.historySize - 1); + + // === Saturation gradient (HSL-like) === + const sat = 3.0 - 2 * p; + const rr = (gray + (r - gray) * sat) | 0; + const gg = (gray + (g - gray) * sat) | 0; + const bb = (gray + (b - gray) * sat) | 0; + + this._palette[i] = `rgba(${rr},${gg},${bb}, 1.0)`; + } + + this._paletteColor = color; + } + + draw(ctx, canvas, analyser, dataArray, params) { + // Init if empty (e.g. after destroy/switch) + if (this.history.length === 0) { + this.reset(); + } + + const pts = this.dataPoints; + const len = dataArray.length | 0; + + const line = this.history[this.writeIndex]; + if (line) { + for (let i = 0; i < pts; i++) { + line[i] = (dataArray[(this.xLookup[i] * len) | 0] / 255) * this.pLookup[i]; + } + } + this.writeIndex = (this.writeIndex + 1) % this.historySize; + + if (this._paletteColor !== params.primaryColor) { + this._buildPalette(params.primaryColor); + } + + const { width, height } = canvas; + ctx.clearRect(0, 0, width, height); + + const size = Math.hypot(width, height) * 1.42; + + ctx.save(); + ctx.translate((width + size) / 2, height / 2); + ctx.rotate(Math.PI / 6); + ctx.translate(-(width + size) / 2, -height / 2); + + // SINGLE shadow pass (cheap) + ctx.shadowColor = params.primaryColor; + ctx.shadowBlur = 32 * (1 + params.kick * 2); + ctx.lineJoin = 'round'; + ctx.shadowOffsetX = params.kick * 10; + ctx.shadowOffsetY = params.kick * 10; + const horizonY = size * 0.1; + const frontY = size * 0.8; + const depth = 2.0; + + const totalH = frontY - horizonY; + const B = totalH / (1 - 1 / (1 + depth)); + const A = frontY - B; + + for (let i = this.historySize - 1; i >= 0; i--) { + const idx = (this.writeIndex + i) % this.historySize; + const data = this.history[idx]; + + const p = 1 - i / (this.historySize - 1); + const z = 1 + p * depth; + const scale = 1 / z; + const y = A + B / z; + + ctx.strokeStyle = this._palette[i]; + ctx.lineWidth = Math.max(1, 8 * scale + params.kick * 6); + + const lw = size * scale * 1.5; + const margin = (size - lw) * 0.5; + const amp = 200 * scale + params.kick * 100; + + ctx.beginPath(); + ctx.moveTo(margin, y); + + for (let j = 0; j < pts; j++) { + ctx.lineTo(margin + this.xLookup[j] * lw, y - data[j] * amp); + } + ctx.stroke(); + } + + ctx.restore(); + } +} diff --git a/styles.css b/styles.css index 5ea73f5..81d2241 100644 --- a/styles.css +++ b/styles.css @@ -2126,10 +2126,12 @@ input:checked + .slider::before { text-align: center; z-index: 1; max-width: 90%; - background: color-mix(in srgb, var(--card), transparent 80%); + background-color: color-mix(in srgb, var(--card) 40%, transparent); + backdrop-filter: blur(20px); padding: 0.75rem 1.5rem; border-radius: var(--radius); - border: 1px solid color-mix(in srgb, var(--card), transparent 70%); + border: 1px solid var(--border); + box-shadow: 0 8px 32px rgb(0, 0, 0, 0.4); } #fullscreen-track-title {