WIP: viz
This commit is contained in:
parent
2e322ac8a6
commit
49c405216b
8 changed files with 2748 additions and 2889 deletions
4668
index.html
4668
index.html
File diff suppressed because one or more lines are too long
13
js/app.js
13
js/app.js
|
|
@ -342,6 +342,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
// Restore UI state for the current track (like button, theme)
|
||||
if (player.currentTrack) {
|
||||
ui.setCurrentTrack(player.currentTrack);
|
||||
|
||||
// DEV: Auto-open fullscreen mode if ?fullscreen=1 in URL
|
||||
if (new URLSearchParams(window.location.search).get('fullscreen') === '1') {
|
||||
const nextTrack = player.getNextTrack();
|
||||
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector('.now-playing-bar .cover').addEventListener('click', async () => {
|
||||
|
|
@ -457,6 +463,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const nextTrack = player.getNextTrack();
|
||||
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer);
|
||||
}
|
||||
|
||||
// DEV: Auto-open fullscreen mode if ?fullscreen=1 in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('fullscreen') === '1' && fullscreenOverlay && getComputedStyle(fullscreenOverlay).display === 'none') {
|
||||
const nextTrack = player.getNextTrack();
|
||||
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', async (e) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
303
js/visualizer.js
303
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,176 @@ 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;
|
||||
} else {
|
||||
this.kick *= 0.95;
|
||||
}
|
||||
} else {
|
||||
this.kick *= 0.9;
|
||||
}
|
||||
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 {
|
||||
stats.kick *= 0.95;
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
385
js/visualizers/lcd.js
Normal file
385
js/visualizers/lcd.js
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
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.20, 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.50
|
||||
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
102
js/visualizers/particles.js
Normal 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();
|
||||
}
|
||||
}
|
||||
135
js/visualizers/unknown_pleasures.js
Normal file
135
js/visualizers/unknown_pleasures.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
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) {
|
||||
const pts = this.dataPoints;
|
||||
const len = (dataArray.length) | 0;
|
||||
|
||||
const line = this.history[this.writeIndex];
|
||||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue