Merge branch 'main' of github.com:monochrome-music/monochrome
This commit is contained in:
commit
1108a44df9
8 changed files with 288 additions and 206 deletions
|
|
@ -4768,7 +4768,12 @@
|
|||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
<p style="text-align: center; color: grey;">made with ❤︎ by <a href="https://prigoana.com/" style="text-decoration: underline;">Edideaur</a>, <a href="https://samidy.com" style="text-decoration: underline;">Samidy</a> & <a href="https://github.com/JulienMaille" style="text-decoration: underline;">Julien</a></p>
|
||||
<p style="text-align: center; color: grey">
|
||||
made with ❤︎ by
|
||||
<a href="https://prigoana.com/" style="text-decoration: underline">Edideaur</a>,
|
||||
<a href="https://samidy.com" style="text-decoration: underline">Samidy</a> &
|
||||
<a href="https://github.com/JulienMaille" style="text-decoration: underline">Julien</a>
|
||||
</p>
|
||||
<div class="about-footer">
|
||||
<p class="version">Version 2.2.0</p>
|
||||
<p class="disclaimer">
|
||||
|
|
|
|||
|
|
@ -312,14 +312,33 @@ class AudioContextManager {
|
|||
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
this.audioContext = new AudioContext();
|
||||
|
||||
// "playback" latency hint maximizes buffer size to prevent audio glitches (stuttering),
|
||||
// which is critical for high-fidelity music listening.
|
||||
// We also attempt to request 192kHz sample rate for high-res audio support.
|
||||
const highResOptions = { sampleRate: 192000, latencyHint: 'playback' };
|
||||
|
||||
try {
|
||||
this.audioContext = new AudioContext(highResOptions);
|
||||
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
|
||||
} catch (e) {
|
||||
console.warn('[AudioContext] 192kHz/playback init failed, falling back to system defaults:', e);
|
||||
// Fallback: Try just playback latency preference without forcing sample rate
|
||||
try {
|
||||
this.audioContext = new AudioContext({ latencyHint: 'playback' });
|
||||
console.log(`[AudioContext] Created with system default rate: ${this.audioContext.sampleRate}Hz`);
|
||||
} catch (e2) {
|
||||
console.warn('[AudioContext] Playback latency hint failed, using defaults:', e2);
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
}
|
||||
|
||||
// Create the media element source
|
||||
this.source = this.audioContext.createMediaElementSource(audioElement);
|
||||
|
||||
// Create analyser for visualizer
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = 512;
|
||||
this.analyser.fftSize = 1024;
|
||||
this.analyser.smoothingTimeConstant = 0.7;
|
||||
|
||||
// Create biquad filters for EQ with dynamic band count
|
||||
|
|
@ -411,7 +430,6 @@ class AudioContextManager {
|
|||
lastNode.connect(this.analyser);
|
||||
this.analyser.connect(this.volumeNode);
|
||||
this.volumeNode.connect(this.audioContext.destination);
|
||||
console.log('[AudioContext] EQ bypassed');
|
||||
}
|
||||
|
||||
// Notify visualizers that graph has been reconnected
|
||||
|
|
|
|||
|
|
@ -387,7 +387,8 @@ function readID3Text(view) {
|
|||
|
||||
function getMimeType(data) {
|
||||
if (data.length >= 2 && data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg';
|
||||
if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) return 'image/png';
|
||||
if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
|
||||
return 'image/png';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -260,6 +260,8 @@ export const themeManager = {
|
|||
this.applyCustomTheme(customTheme);
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('theme-changed', { detail: { theme } }));
|
||||
},
|
||||
|
||||
getCustomTheme() {
|
||||
|
|
|
|||
5
js/ui.js
5
js/ui.js
|
|
@ -96,6 +96,11 @@ export class UIRenderer {
|
|||
window.addEventListener('reset-dynamic-color', () => {
|
||||
this.resetVibrantColor();
|
||||
});
|
||||
|
||||
// Listen for theme changes to re-apply vibrant colors
|
||||
window.addEventListener('theme-changed', () => {
|
||||
this.updateGlobalTheme();
|
||||
});
|
||||
}
|
||||
|
||||
// Helper for Heart Icon
|
||||
|
|
|
|||
|
|
@ -190,10 +190,24 @@ export class Visualizer {
|
|||
// ===== AUDIO ANALYSIS =====
|
||||
this.analyser.getByteFrequencyData(this.dataArray);
|
||||
|
||||
// Bass (first bins only — cheap)
|
||||
// Bass (dynamic bins based on sample rate)
|
||||
const volume = 10 * Math.max(this.audio.volume, 0.1);
|
||||
let bass =
|
||||
((this.dataArray[0] + this.dataArray[1] + this.dataArray[2] + this.dataArray[3]) * 0.000980392) / volume;
|
||||
|
||||
// Robust bass detection: sum bins up to ~250Hz
|
||||
const binSize = this.audioContext.sampleRate / this.analyser.fftSize;
|
||||
const startBin = 1; // Skip DC offset
|
||||
// Calculate how many bins cover the bass range (up to 250Hz)
|
||||
let numBins = Math.floor(250 / binSize);
|
||||
if (numBins < 1) numBins = 1; // Ensure at least one bin is checked
|
||||
|
||||
let maxVal = 0;
|
||||
for (let i = 0; i < numBins && startBin + i < this.dataArray.length; i++) {
|
||||
const val = this.dataArray[startBin + i];
|
||||
if (val > maxVal) maxVal = val;
|
||||
}
|
||||
|
||||
// Normalize: (Max / 255) / Volume
|
||||
let bass = maxVal / 255 / volume;
|
||||
|
||||
const intensity = bass * bass * 10;
|
||||
const stats = this.stats;
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export class LCDPreset {
|
|||
}
|
||||
|
||||
// --- Audio Data Processing ---
|
||||
const data = this.processAudio(dataArray);
|
||||
const data = this.processAudio(dataArray, analyser);
|
||||
|
||||
// --- Perspective Constants ---
|
||||
const centerX = width / 2;
|
||||
|
|
@ -279,29 +279,48 @@ export class LCDPreset {
|
|||
}
|
||||
|
||||
// Process audio with improved dynamics
|
||||
processAudio(dataArray) {
|
||||
processAudio(dataArray, analyser) {
|
||||
const result = new Float32Array(this.gridCols);
|
||||
const center = Math.floor(this.gridCols / 2);
|
||||
const totalBins = dataArray.length;
|
||||
let peakVal = 0;
|
||||
|
||||
// Sample rate and bin size
|
||||
const sampleRate = analyser?.context?.sampleRate || 48000;
|
||||
const binSize = sampleRate / (totalBins * 2);
|
||||
|
||||
// Define frequency range to map
|
||||
const minFreq = 40; // Start at 40Hz
|
||||
const maxFreq = 22000; // End at 22kHz
|
||||
|
||||
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)));
|
||||
// Logarithmic frequency mapping: F = min * (max/min)^p
|
||||
const targetStartFreq = minFreq * Math.pow(maxFreq / minFreq, p);
|
||||
// Calculate next frequency to determine bandwidth of this bar
|
||||
const pNext = (i + 1) / (center - 1);
|
||||
const targetEndFreq = minFreq * Math.pow(maxFreq / minFreq, pNext); // Use pNext for end freq
|
||||
|
||||
// Convert frequencies to bin indices
|
||||
const startBin = Math.max(1, Math.floor(targetStartFreq / binSize));
|
||||
const endBin = Math.max(startBin + 1, Math.floor(targetEndFreq / binSize));
|
||||
|
||||
let sum = 0,
|
||||
count = 0;
|
||||
|
||||
// Sum bins for this column
|
||||
for (let k = startBin; k < endBin && k < totalBins; k++) {
|
||||
sum += dataArray[k];
|
||||
count++;
|
||||
}
|
||||
let val = count > 0 ? sum / count : 0;
|
||||
|
||||
// Fallback: if range was too narrow (startBin >= endBin or count=0), sample the startBin directly
|
||||
if (count === 0 && startBin < totalBins) {
|
||||
val = dataArray[startBin];
|
||||
}
|
||||
|
||||
// Pink noise compensation (boost highs)
|
||||
val *= 1 + p * 1.8;
|
||||
if (val > peakVal) peakVal = val;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ export class UnknownPleasuresWebGL {
|
|||
static PROPAGATION_SPEED = 0.7;
|
||||
|
||||
// Glow intensity: controls how strong the glow effect is
|
||||
// Lower = subtler glow (0.5 = subtle, 1.0 = normal, 2.0 = strong)
|
||||
static GLOW_INTENSITY = 0.7;
|
||||
static GLOW_INTENSITY = 5.0;
|
||||
|
||||
static NOISE_STRENGTH = 0.04;
|
||||
|
||||
constructor() {
|
||||
this.name = 'Unknown Pleasures';
|
||||
|
|
@ -92,28 +93,34 @@ export class UnknownPleasuresWebGL {
|
|||
if (this.lineProgram) return;
|
||||
this.gl = gl;
|
||||
|
||||
// === LINE SHADER (draws thick colored lines as quads) ===
|
||||
// === LINE SHADER (draws thick colored lines as quads with AA edges) ===
|
||||
const lineVS = `
|
||||
attribute vec2 a_position;
|
||||
attribute vec3 a_posEdge; // xy = position, z = edge distance (-1 to +1)
|
||||
varying float v_edge;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
gl_Position = vec4(a_posEdge.xy, 0.0, 1.0);
|
||||
v_edge = a_posEdge.z;
|
||||
}
|
||||
`;
|
||||
|
||||
const lineFS = `
|
||||
precision mediump float;
|
||||
uniform vec3 u_color;
|
||||
varying float v_edge;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = vec4(u_color, 1.0);
|
||||
// Smooth antialiasing at edges
|
||||
float edge = abs(v_edge);
|
||||
float aa = 1.0 - smoothstep(0.6, 1.0, edge);
|
||||
gl_FragColor = vec4(u_color * aa, aa);
|
||||
}
|
||||
`;
|
||||
|
||||
this.lineProgram = this._createProgram(gl, lineVS, lineFS);
|
||||
if (!this.lineProgram) return;
|
||||
|
||||
this.line_a_position = gl.getAttribLocation(this.lineProgram, 'a_position');
|
||||
this.line_a_posEdge = gl.getAttribLocation(this.lineProgram, 'a_posEdge');
|
||||
this.line_u_color = gl.getUniformLocation(this.lineProgram, 'u_color');
|
||||
|
||||
// === BRIGHTNESS EXTRACTION SHADER ===
|
||||
|
|
@ -136,30 +143,10 @@ export class UnknownPleasuresWebGL {
|
|||
uniform float u_isDarkTheme;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(u_texture, v_uv);
|
||||
|
||||
float contribution;
|
||||
float outputMult;
|
||||
|
||||
if (u_isDarkTheme > 0.5) {
|
||||
// Dark mode: use brightness (bright lines on dark background)
|
||||
float brightness = max(color.r, max(color.g, color.b));
|
||||
contribution = max(0.0, brightness - u_threshold) / (1.0 - u_threshold);
|
||||
outputMult = 0.75;
|
||||
} else {
|
||||
// Light mode: use saturation (colored lines on gray background)
|
||||
float maxC = max(color.r, max(color.g, color.b));
|
||||
float minC = min(color.r, min(color.g, color.b));
|
||||
float saturation = maxC > 0.0 ? (maxC - minC) / maxC : 0.0;
|
||||
// Lower threshold to capture more of the line, boost output
|
||||
contribution = max(0.0, saturation - 0.15) / 0.85;
|
||||
// Boost contribution with power curve for stronger glow
|
||||
contribution = pow(contribution, 0.7);
|
||||
outputMult = 1.5;
|
||||
}
|
||||
|
||||
// Output the glowing parts
|
||||
gl_FragColor = vec4(color.rgb * contribution * outputMult, 1.0);
|
||||
// Since Pass 1 now clears to transparent, the scene texture only contains the isolated lines.
|
||||
// We don't need to extract brightness by darkening the background anymore.
|
||||
// Just pass the lines through so they can be blurred.
|
||||
gl_FragColor = texture2D(u_texture, v_uv);
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -190,27 +177,21 @@ export class UnknownPleasuresWebGL {
|
|||
uniform sampler2D u_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform vec2 u_direction;
|
||||
uniform float u_radius;
|
||||
uniform float u_spread; // Used instead of u_radius
|
||||
|
||||
// 9-tap Gaussian with expanding offsets
|
||||
void main() {
|
||||
vec2 texelSize = 1.0 / u_resolution;
|
||||
// Fixed small step (1.5 pixels) for smooth gradient
|
||||
// Multiple passes will extend the blur
|
||||
vec2 step = u_direction * texelSize * 1.5;
|
||||
// Expanding offsets for stronger glow (Thread Ripper Style)
|
||||
vec2 off1 = vec2(1.3846153846) * u_direction * u_spread;
|
||||
vec2 off2 = vec2(3.2307692308) * u_direction * u_spread;
|
||||
|
||||
// 9-tap Gaussian weights (sum = 1.0)
|
||||
vec4 result =
|
||||
texture2D(u_texture, v_uv - 4.0 * step) * 0.0162 +
|
||||
texture2D(u_texture, v_uv - 3.0 * step) * 0.0540 +
|
||||
texture2D(u_texture, v_uv - 2.0 * step) * 0.1216 +
|
||||
texture2D(u_texture, v_uv - 1.0 * step) * 0.1945 +
|
||||
texture2D(u_texture, v_uv) * 0.2270 +
|
||||
texture2D(u_texture, v_uv + 1.0 * step) * 0.1945 +
|
||||
texture2D(u_texture, v_uv + 2.0 * step) * 0.1216 +
|
||||
texture2D(u_texture, v_uv + 3.0 * step) * 0.0540 +
|
||||
texture2D(u_texture, v_uv + 4.0 * step) * 0.0162;
|
||||
vec4 color = texture2D(u_texture, v_uv) * 0.2270270270;
|
||||
color += texture2D(u_texture, v_uv + (off1 / u_resolution)) * 0.3162162162;
|
||||
color += texture2D(u_texture, v_uv - (off1 / u_resolution)) * 0.3162162162;
|
||||
color += texture2D(u_texture, v_uv + (off2 / u_resolution)) * 0.0702702703;
|
||||
color += texture2D(u_texture, v_uv - (off2 / u_resolution)) * 0.0702702703;
|
||||
|
||||
gl_FragColor = result;
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -221,39 +202,65 @@ export class UnknownPleasuresWebGL {
|
|||
this.blur_u_texture = gl.getUniformLocation(this.blurProgram, 'u_texture');
|
||||
this.blur_u_resolution = gl.getUniformLocation(this.blurProgram, 'u_resolution');
|
||||
this.blur_u_direction = gl.getUniformLocation(this.blurProgram, 'u_direction');
|
||||
this.blur_u_radius = gl.getUniformLocation(this.blurProgram, 'u_radius');
|
||||
this.blur_u_spread = gl.getUniformLocation(this.blurProgram, 'u_spread');
|
||||
|
||||
// === COMPOSITE SHADER (combines original + blurred glow) ===
|
||||
// === COMPOSITE SHADER (exact copy from Thread Ripper) ===
|
||||
const compositeFS = `
|
||||
precision mediump float;
|
||||
varying vec2 v_uv;
|
||||
uniform sampler2D u_scene;
|
||||
uniform sampler2D u_blur;
|
||||
uniform float u_glowStrength;
|
||||
uniform float u_isDarkTheme;
|
||||
uniform float u_noiseStrength;
|
||||
uniform float u_isDarkTheme; // Kept for compatibility but unused in logic below
|
||||
uniform float u_time;
|
||||
|
||||
float rand(vec2 co) {
|
||||
return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 original = texture2D(u_scene, v_uv);
|
||||
vec4 blur = texture2D(u_blur, v_uv);
|
||||
|
||||
vec3 finalColor;
|
||||
// Additive glow on top of original lines
|
||||
vec3 rgb = original.rgb + blur.rgb * u_glowStrength;
|
||||
|
||||
if (u_isDarkTheme > 0.5) {
|
||||
// Dark mode: additive glow (adds brightness to dark background)
|
||||
vec3 glow = blur.rgb * u_glowStrength;
|
||||
finalColor = original.rgb + glow;
|
||||
} else {
|
||||
// Light mode: TINT toward glow color instead of adding
|
||||
// This shifts the gray background toward the line color
|
||||
float glowIntensity = max(blur.r, max(blur.g, blur.b));
|
||||
float tintStrength = glowIntensity * u_glowStrength * 0.8; // Boosted from 0.4
|
||||
// Mix original with glow color based on intensity
|
||||
vec3 glowColor = blur.rgb / max(glowIntensity, 0.001); // Normalize to get pure color
|
||||
finalColor = mix(original.rgb, glowColor, tintStrength);
|
||||
}
|
||||
// Vignette: blur edges for depth
|
||||
float dist = distance(v_uv, vec2(0.5));
|
||||
float vignette = smoothstep(0.4, 0.8, dist);
|
||||
// We handle scaling in the final mix later to avoid breaking the HDR mapping above.
|
||||
// The rgb here is the base scene before the final exponential glow math.
|
||||
|
||||
float noise = rand(v_uv * 10.0);
|
||||
float noiseStrength = 0.06;
|
||||
rgb += (noise - 0.5) * noiseStrength;
|
||||
|
||||
// In light mode (u_isDarkTheme == 0.0), the additive glow effect naturally appears weaker
|
||||
// against the bright background. We apply a 1.5x perceptual boost to match dark mode intensity.
|
||||
float themeBoost = mix(1.5, 1.0, u_isDarkTheme);
|
||||
// Using 1.0 - exp(-x) gives butter-smooth HDR-like falloff, eliminating harsh banding.
|
||||
// We square the intensity (gamma 2.0) to dramatically increase the "core" opacity of the glow
|
||||
// making it much more visible while preserving the smooth edges.
|
||||
vec3 rawGlow = blur.rgb * (u_glowStrength * themeBoost);
|
||||
float glowIntensity = max(rawGlow.r, max(rawGlow.g, rawGlow.b));
|
||||
|
||||
// Preserve alpha from scene (needed for semi-transparent backgrounds)
|
||||
gl_FragColor = vec4(finalColor, original.a);
|
||||
// Boost density significantly before applying HDR curve
|
||||
float density = glowIntensity * glowIntensity * 1.5;
|
||||
float smoothGlowAlpha = 1.0 - exp(-density);
|
||||
|
||||
// Keep the color strictly within valid premultiplied alpha bounds (rgb <= alpha)
|
||||
vec3 safeGlowRgb = glowIntensity > 0.0 ? (rawGlow / glowIntensity) * smoothGlowAlpha : vec3(0.0);
|
||||
|
||||
// Additive over the core lines
|
||||
rgb = original.rgb + safeGlowRgb;
|
||||
|
||||
// Final alpha is the line's alpha plus the glow's alpha
|
||||
float finalAlpha = clamp(original.a + smoothGlowAlpha, 0.0, 1.0);
|
||||
|
||||
// Output RGB and Alpha for PREMULTIPLIED alpha blending
|
||||
gl_FragColor = vec4(rgb, finalAlpha);
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -264,7 +271,9 @@ export class UnknownPleasuresWebGL {
|
|||
this.composite_u_scene = gl.getUniformLocation(this.compositeProgram, 'u_scene');
|
||||
this.composite_u_blur = gl.getUniformLocation(this.compositeProgram, 'u_blur');
|
||||
this.composite_u_glowStrength = gl.getUniformLocation(this.compositeProgram, 'u_glowStrength');
|
||||
this.composite_u_noiseStrength = gl.getUniformLocation(this.compositeProgram, 'u_noiseStrength');
|
||||
this.composite_u_isDarkTheme = gl.getUniformLocation(this.compositeProgram, 'u_isDarkTheme');
|
||||
this.composite_u_time = gl.getUniformLocation(this.compositeProgram, 'u_time');
|
||||
|
||||
// === FULLSCREEN QUAD BUFFER ===
|
||||
this.quadBuffer = gl.createBuffer();
|
||||
|
|
@ -333,7 +342,7 @@ export class UnknownPleasuresWebGL {
|
|||
this.blurTexture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.blurTexture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // LINEAR!
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
|
@ -354,6 +363,11 @@ export class UnknownPleasuresWebGL {
|
|||
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.blurFinalTexture, 0);
|
||||
|
||||
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
||||
if (status !== gl.FRAMEBUFFER_COMPLETE) {
|
||||
console.error('Framebuffer incomplete:', status);
|
||||
}
|
||||
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
}
|
||||
|
||||
|
|
@ -393,62 +407,70 @@ export class UnknownPleasuresWebGL {
|
|||
}
|
||||
|
||||
/**
|
||||
* Generate quad vertices for a thick line segment with round joints
|
||||
* Returns triangles for each segment + circles at joints
|
||||
* Generate quad vertices for a thick line with proper miter joints.
|
||||
* Precomputes averaged normals at shared vertices so segments connect seamlessly.
|
||||
*/
|
||||
_generateLineQuads(points, thickness, width, height) {
|
||||
if (points.length < 2) return new Float32Array(0);
|
||||
|
||||
const vertices = [];
|
||||
|
||||
// Convert to clip space helper
|
||||
const toClip = (x, y) => [(x / width) * 2 - 1, 1 - (y / height) * 2];
|
||||
const n = points.length;
|
||||
|
||||
// Generate circle at a point (for round joints/caps)
|
||||
const addCircle = (px, py, radius, segments = 8) => {
|
||||
const [cx, cy] = toClip(px, py);
|
||||
const rw = (radius / width) * 2;
|
||||
const rh = (radius / height) * 2;
|
||||
|
||||
for (let s = 0; s < segments; s++) {
|
||||
const a1 = (s / segments) * Math.PI * 2;
|
||||
const a2 = ((s + 1) / segments) * Math.PI * 2;
|
||||
|
||||
vertices.push(cx, cy);
|
||||
vertices.push(cx + Math.cos(a1) * rw, cy + Math.sin(a1) * rh);
|
||||
vertices.push(cx + Math.cos(a2) * rw, cy + Math.sin(a2) * rh);
|
||||
// Precompute per-segment normals
|
||||
const segNx = new Float32Array(n - 1);
|
||||
const segNy = new Float32Array(n - 1);
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const dx = points[i + 1].x - points[i].x;
|
||||
const dy = points[i + 1].y - points[i].y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len < 0.001) {
|
||||
segNx[i] = 0;
|
||||
segNy[i] = -1;
|
||||
} else {
|
||||
segNx[i] = -dy / len;
|
||||
segNy[i] = dx / len;
|
||||
}
|
||||
};
|
||||
|
||||
// Add start cap
|
||||
if (points.length > 0) {
|
||||
addCircle(points[0].x, points[0].y, thickness);
|
||||
}
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
// Compute miter normals at each point (average of adjacent segment normals)
|
||||
const miterNx = new Float32Array(n);
|
||||
const miterNy = new Float32Array(n);
|
||||
// First point: use first segment normal
|
||||
miterNx[0] = segNx[0];
|
||||
miterNy[0] = segNy[0];
|
||||
// Last point: use last segment normal
|
||||
miterNx[n - 1] = segNx[n - 2];
|
||||
miterNy[n - 1] = segNy[n - 2];
|
||||
// Interior points: average
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
let mx = segNx[i - 1] + segNx[i];
|
||||
let my = segNy[i - 1] + segNy[i];
|
||||
const ml = Math.sqrt(mx * mx + my * my);
|
||||
if (ml < 0.001) {
|
||||
mx = segNx[i];
|
||||
my = segNy[i];
|
||||
} else {
|
||||
mx /= ml;
|
||||
my /= ml;
|
||||
}
|
||||
miterNx[i] = mx;
|
||||
miterNy[i] = my;
|
||||
}
|
||||
|
||||
// Build quads using miter normals
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
|
||||
// Direction vector
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len < 0.001) continue;
|
||||
const [x1a, y1a] = toClip(p1.x - miterNx[i] * thickness, p1.y - miterNy[i] * thickness);
|
||||
const [x1b, y1b] = toClip(p1.x + miterNx[i] * thickness, p1.y + miterNy[i] * thickness);
|
||||
const [x2a, y2a] = toClip(p2.x - miterNx[i + 1] * thickness, p2.y - miterNy[i + 1] * thickness);
|
||||
const [x2b, y2b] = toClip(p2.x + miterNx[i + 1] * thickness, p2.y + miterNy[i + 1] * thickness);
|
||||
|
||||
// Perpendicular (normal) vector
|
||||
const nx = (-dy / len) * thickness;
|
||||
const ny = (dx / len) * thickness;
|
||||
|
||||
const [x1a, y1a] = toClip(p1.x - nx, p1.y - ny);
|
||||
const [x1b, y1b] = toClip(p1.x + nx, p1.y + ny);
|
||||
const [x2a, y2a] = toClip(p2.x - nx, p2.y - ny);
|
||||
const [x2b, y2b] = toClip(p2.x + nx, p2.y + ny);
|
||||
|
||||
// Triangle 1
|
||||
vertices.push(x1a, y1a, x1b, y1b, x2a, y2a);
|
||||
// Triangle 2
|
||||
vertices.push(x1b, y1b, x2b, y2b, x2a, y2a);
|
||||
|
||||
// Add round joint at p2 (connection point)
|
||||
addCircle(p2.x, p2.y, thickness);
|
||||
// Each vertex: [x, y, edge] where edge = -1 (bottom) or +1 (top)
|
||||
vertices.push(x1a, y1a, -1.0, x1b, y1b, 1.0, x2a, y2a, -1.0);
|
||||
vertices.push(x1b, y1b, 1.0, x2b, y2b, 1.0, x2a, y2a, -1.0);
|
||||
}
|
||||
|
||||
return new Float32Array(vertices);
|
||||
|
|
@ -459,15 +481,8 @@ export class UnknownPleasuresWebGL {
|
|||
const { width, height } = canvas;
|
||||
const isDark = document.documentElement.getAttribute('data-theme') !== 'white';
|
||||
|
||||
// Set CSS blend mode based on mode and theme
|
||||
// Solid: normal (opaque background)
|
||||
// Blended + Dark: screen (black=transparent, bright=visible)
|
||||
// Blended + Light: normal (semi-transparent background overlay)
|
||||
if (params.mode === 'blended' && isDark) {
|
||||
canvas.style.mixBlendMode = 'screen';
|
||||
} else {
|
||||
canvas.style.mixBlendMode = 'normal';
|
||||
}
|
||||
// FORCE Normal blending as requested - no more screen blend tricks
|
||||
canvas.style.mixBlendMode = 'normal';
|
||||
|
||||
// Initialize WebGL on first draw
|
||||
if (!this.lineProgram) {
|
||||
|
|
@ -491,7 +506,12 @@ export class UnknownPleasuresWebGL {
|
|||
if (this._propagationAccum >= 1.0) {
|
||||
this._propagationAccum -= 1.0;
|
||||
|
||||
const len = dataArray.length | 0;
|
||||
const sampleRate = analyser.context.sampleRate;
|
||||
const nyquist = sampleRate / 2;
|
||||
const targetFreq = 22000; // Visualizing up to 22kHz
|
||||
const scale = Math.min(1.0, targetFreq / nyquist);
|
||||
const len = Math.floor(dataArray.length * scale);
|
||||
|
||||
const line = this.history[this.writeIndex];
|
||||
if (line) {
|
||||
for (let i = 0; i < pts; i++) {
|
||||
|
|
@ -511,24 +531,11 @@ export class UnknownPleasuresWebGL {
|
|||
const rotatedH = Math.abs(width * this._sin) + Math.abs(height * this._cos);
|
||||
const size = Math.max(rotatedW, rotatedH) * 1.15;
|
||||
|
||||
// === PASS 1: Render lines to framebuffer ===
|
||||
// === PASS 1: Scene ===
|
||||
// We render lines to a transparent texture so we can composite them properly later
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
|
||||
gl.viewport(0, 0, width, height);
|
||||
|
||||
// Clear color based on mode and theme
|
||||
// Solid: dark/light solid background
|
||||
// Blended + Dark (screen): black (black=transparent in screen blend)
|
||||
// Blended + Light: semi-transparent light (album art shows through)
|
||||
if (params.mode !== 'blended') {
|
||||
const bg = isDark ? [0.02, 0.02, 0.02, 1] : [0.9, 0.9, 0.9, 1];
|
||||
gl.clearColor(bg[0], bg[1], bg[2], bg[3]);
|
||||
} else if (isDark) {
|
||||
// Dark: black for screen blend (black=transparent)
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
} else {
|
||||
// Light: semi-transparent white overlay (frosted glass effect)
|
||||
gl.clearColor(0.92, 0.92, 0.92, 0.85);
|
||||
}
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
// Perspective constants - extended for better corner coverage
|
||||
|
|
@ -539,9 +546,15 @@ export class UnknownPleasuresWebGL {
|
|||
const B = totalH / (1 - 1 / (1 + depth));
|
||||
const A = frontY - B;
|
||||
|
||||
// Enable blending for anti-aliased edges and proper alpha
|
||||
// Lines output premultiplied alpha (color * aa, aa).
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
if (isDark) {
|
||||
// Additive premultiplied
|
||||
gl.blendFunc(gl.ONE, gl.ONE);
|
||||
} else {
|
||||
// Standard premultiplied
|
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
gl.useProgram(this.lineProgram);
|
||||
|
||||
|
|
@ -591,18 +604,21 @@ export class UnknownPleasuresWebGL {
|
|||
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW);
|
||||
|
||||
gl.enableVertexAttribArray(this.line_a_position);
|
||||
gl.vertexAttribPointer(this.line_a_position, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.enableVertexAttribArray(this.line_a_posEdge);
|
||||
gl.vertexAttribPointer(this.line_a_posEdge, 3, gl.FLOAT, false, 0, 0);
|
||||
|
||||
// Set color
|
||||
// Set raw palette color
|
||||
const color = this._paletteRGB[i] || [1, 1, 1];
|
||||
gl.uniform3f(this.line_u_color, color[0], color[1], color[2]);
|
||||
|
||||
// Draw
|
||||
gl.drawArrays(gl.TRIANGLES, 0, vertices.length / 2);
|
||||
// Draw (vertices.length / 3 because each vertex is [x, y, edge])
|
||||
gl.drawArrays(gl.TRIANGLES, 0, vertices.length / 3);
|
||||
}
|
||||
|
||||
// === PASS 2: Extract bright pixels ===
|
||||
// MUST DISABLE BLEND for post-processing passes so we strictly overwrite FBO contents!
|
||||
gl.disable(gl.BLEND);
|
||||
|
||||
// === PASS 2: Bloom ===
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFramebuffer);
|
||||
gl.viewport(0, 0, width, height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
|
|
@ -613,7 +629,8 @@ export class UnknownPleasuresWebGL {
|
|||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.sceneTexture);
|
||||
gl.uniform1i(this.brightness_u_texture, 0);
|
||||
gl.uniform1f(this.brightness_u_threshold, 0.1); // Low threshold for dark mode
|
||||
// NO THRESHOLD - EVERYTHING GLOWS (Thread Ripper Style)
|
||||
gl.uniform1f(this.brightness_u_threshold, 0.0);
|
||||
gl.uniform1f(this.brightness_u_isDarkTheme, isDark ? 1.0 : 0.0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
|
||||
|
|
@ -621,74 +638,72 @@ export class UnknownPleasuresWebGL {
|
|||
gl.vertexAttribPointer(this.brightness_a_position, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
|
||||
// === PASS 3: Horizontal Gaussian blur ===
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFinalFramebuffer);
|
||||
gl.viewport(0, 0, width, height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
// === PASS 3: Gaussian Blur (Ping Pong) ===
|
||||
gl.useProgram(this.blurProgram);
|
||||
|
||||
// Multiple blur passes with increasing step sizes for smooth wide blur
|
||||
// This prevents banding by using overlapping samples
|
||||
const numPasses = 3;
|
||||
for (let pass = 0; pass < numPasses; pass++) {
|
||||
const stepMultiplier = Math.pow(2, pass); // 1, 2, 4
|
||||
// More iterations for wider, smoother glow (Thread Ripper uses 8 * 2 passes)
|
||||
// We have 2 framebuffers: blurFramebuffer (holds brightness extract), blurFinalFramebuffer (temp)
|
||||
// thread_ripper uses ping-pong. Let's adapt.
|
||||
|
||||
// Horizontal blur
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFinalFramebuffer);
|
||||
// We start with 'blurFramebuffer' having the bright pixels.
|
||||
// We want to ping-pong between blurFramebuffer and blurFinalFramebuffer.
|
||||
|
||||
const iterations = 8;
|
||||
let horizontal = true;
|
||||
|
||||
for (let i = 0; i < iterations * 2; i++) {
|
||||
// Thread Ripper ping-pong: horizontal toggles each iteration
|
||||
const destFBO = horizontal ? this.blurFinalFramebuffer : this.blurFramebuffer;
|
||||
const srcTex = horizontal ? this.blurTexture : this.blurFinalTexture;
|
||||
|
||||
// Thread Ripper spread: grows linearly with i (not i/2)
|
||||
// Increased by 50% from 0.375 to 0.5625 for wider glow
|
||||
const spread = 1.0 + i * 0.5625;
|
||||
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, destFBO);
|
||||
gl.viewport(0, 0, width, height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.blurTexture);
|
||||
gl.bindTexture(gl.TEXTURE_2D, srcTex);
|
||||
gl.uniform1i(this.blur_u_texture, 0);
|
||||
gl.uniform2f(this.blur_u_resolution, width, height);
|
||||
gl.uniform2f(this.blur_u_direction, stepMultiplier, 0.0); // Horizontal with scaled step
|
||||
gl.uniform1f(this.blur_u_radius, 32.0);
|
||||
|
||||
gl.uniform2f(this.blur_u_direction, horizontal ? 1.0 : 0.0, horizontal ? 0.0 : 1.0);
|
||||
gl.uniform1f(this.blur_u_spread, spread);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
|
||||
gl.enableVertexAttribArray(this.blur_a_position);
|
||||
gl.vertexAttribPointer(this.blur_a_position, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
|
||||
// Vertical blur
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFramebuffer);
|
||||
gl.viewport(0, 0, width, height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.blurFinalTexture);
|
||||
gl.uniform1i(this.blur_u_texture, 0);
|
||||
gl.uniform2f(this.blur_u_direction, 0.0, stepMultiplier); // Vertical with scaled step
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
horizontal = !horizontal;
|
||||
}
|
||||
|
||||
// === PASS 4: Composite original + blur ===
|
||||
// Final result is in the LAST written framebuffer.
|
||||
// iter 0 -> writes Final
|
||||
// iter 1 -> writes Blur
|
||||
// ...
|
||||
// iter 15 -> writes Blur
|
||||
|
||||
// So 'blurTexture' holds the final blurred result.
|
||||
|
||||
// === PASS 4: Composite ===
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.viewport(0, 0, width, height);
|
||||
|
||||
// Clear color based on mode and theme
|
||||
// Solid: opaque background
|
||||
// Blended + Dark: black (for screen blend)
|
||||
// Blended + Light: transparent (composite has the semi-transparent background in it)
|
||||
// Clear color for MAIN canvas
|
||||
if (params.mode !== 'blended') {
|
||||
const bg = isDark ? [0.02, 0.02, 0.02, 1] : [0.9, 0.9, 0.9, 1];
|
||||
gl.clearColor(bg[0], bg[1], bg[2], bg[3]);
|
||||
} else if (isDark) {
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clearColor(0, 0, 0, 0.4); // Dark blended
|
||||
} else {
|
||||
// Light blended: composite will output semi-transparent, clear is transparent
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clearColor(0.95, 0.95, 0.95, 0.4); // Light frosted
|
||||
}
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
// Blending for final composite
|
||||
// Classic normal blending for the final composite quad over the canvas background!
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
gl.useProgram(this.compositeProgram);
|
||||
|
||||
|
|
@ -697,13 +712,16 @@ export class UnknownPleasuresWebGL {
|
|||
gl.uniform1i(this.composite_u_scene, 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.blurTexture); // V-blur result
|
||||
// Use last output: horizontal toggles, so pick the right texture (Thread Ripper pattern)
|
||||
gl.bindTexture(gl.TEXTURE_2D, horizontal ? this.blurTexture : this.blurFinalTexture);
|
||||
gl.uniform1i(this.composite_u_blur, 1);
|
||||
|
||||
// Glow strength reacts to kick, scaled by GLOW_INTENSITY
|
||||
const baseGlow = 1.8 + params.kick * 2.5;
|
||||
const glowStrength = baseGlow * UnknownPleasuresWebGL.GLOW_INTENSITY;
|
||||
// Glow strength - EXACT Thread Ripper formula
|
||||
const glowBoost = 1.0 + params.kick; // Pulse with kick
|
||||
const glowStrength = UnknownPleasuresWebGL.GLOW_INTENSITY * glowBoost;
|
||||
|
||||
gl.uniform1f(this.composite_u_glowStrength, glowStrength);
|
||||
gl.uniform1f(this.composite_u_noiseStrength, UnknownPleasuresWebGL.NOISE_STRENGTH);
|
||||
gl.uniform1f(this.composite_u_isDarkTheme, isDark ? 1.0 : 0.0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
|
||||
|
|
|
|||
Loading…
Reference in a new issue