Merge branch 'main' of github.com:SamidyFR/monochrome

This commit is contained in:
Samidy 2026-01-29 03:35:20 +03:00
commit 384f5717f8
9 changed files with 833 additions and 151 deletions

23
Dockerfile Normal file
View file

@ -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"]

View file

@ -1908,6 +1908,17 @@
<span class="slider"></span>
</label>
</div>
<div class="setting-item" id="visualizer-preset-setting">
<div class="info">
<span class="label">Visualizer Style</span>
<span class="description">Select the visualization style</span>
</div>
<select id="visualizer-preset-select">
<option value="lcd">LCD Pixels</option>
<option value="particles">Particles</option>
<option value="unknown-pleasures">Unknown Pleasures</option>
</select>
</div>
<div class="setting-item" id="visualizer-mode-setting">
<div class="info">
<span class="label">Visualizer Mode</span>

View file

@ -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) {

View file

@ -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 {

View file

@ -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();
}
}

386
js/visualizers/lcd.js Normal file
View file

@ -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;
}
}

102
js/visualizers/particles.js Normal file
View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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 {