(beta) butterchurn visualizer

This commit is contained in:
Eduard Prigoana 2026-02-09 03:30:38 +02:00
parent c484148078
commit cc6c600817
6 changed files with 2981 additions and 3756 deletions

6367
index.html

File diff suppressed because one or more lines are too long

View file

@ -635,6 +635,7 @@ export const visualizerSettings = {
ENABLED_KEY: 'visualizer-enabled',
MODE_KEY: 'visualizer-mode', // 'solid' or 'blended'
PRESET_KEY: 'visualizer-preset',
BUTTERCHURN_CYCLE_KEY: 'butterchurn-cycle-duration',
getPreset() {
try {
@ -699,6 +700,20 @@ export const visualizerSettings = {
setSmartIntensity(enabled) {
localStorage.setItem(this.SMART_INTENSITY_KEY, enabled);
},
// Butterchurn preset cycle duration in seconds (0 = disabled)
getButterchurnCycleDuration() {
try {
const val = localStorage.getItem(this.BUTTERCHURN_CYCLE_KEY);
return val ? parseInt(val, 10) : 30;
} catch {
return 30;
}
},
setButterchurnCycleDuration(seconds) {
localStorage.setItem(this.BUTTERCHURN_CYCLE_KEY, seconds.toString());
},
};
export const equalizerSettings = {

View file

@ -3,6 +3,7 @@ import { visualizerSettings } from './storage.js';
import { LCDPreset } from './visualizers/lcd.js';
import { ParticlesPreset } from './visualizers/particles.js';
import { UnknownPleasuresWebGL } from './visualizers/unknown_pleasures_webgl.js';
import { ButterchurnPreset } from './visualizers/butterchurn.js';
import { audioContextManager } from './audio-context.js';
export class Visualizer {
@ -21,6 +22,7 @@ export class Visualizer {
lcd: new LCDPreset(),
particles: new ParticlesPreset(),
'unknown-pleasures': new UnknownPleasuresWebGL(),
butterchurn: new ButterchurnPreset(),
};
this.activePresetKey = visualizerSettings.getPreset();
@ -139,6 +141,15 @@ export class Visualizer {
this.audioContext.resume();
}
// Initialize Butterchurn if it's the active preset
if (this.activePresetKey === 'butterchurn' && this.activePreset.lazyInit) {
this.activePreset.lazyInit(
this.canvas,
this.audioContext,
audioContextManager.source
);
}
this.resize();
window.addEventListener('resize', this._resizeBound);
this.canvas.style.display = 'block';
@ -258,5 +269,14 @@ export class Visualizer {
this.activePresetKey = key;
this.initContext();
this.resize();
// Initialize Butterchurn if switching to it
if (key === 'butterchurn' && this.presets[key].lazyInit && this.audioContext) {
this.presets[key].lazyInit(
this.canvas,
this.audioContext,
audioContextManager.source
);
}
}
}

View file

@ -0,0 +1,259 @@
/**
* Butterchurn (Milkdrop) Visualizer Preset
* WebGL-based audio visualization using the Butterchurn library
*/
import butterchurn from 'butterchurn';
import butterchurnPresets from 'butterchurn-presets';
import { visualizerSettings } from '../storage.js';
export class ButterchurnPreset {
constructor() {
this.name = 'Butterchurn';
this.contextType = 'webgl';
this.visualizer = null;
this.canvas = null;
this.audioContext = null;
this.presets = null;
this.presetKeys = [];
this.currentPresetIndex = 0;
this.lastPresetChange = 0;
this.isInitialized = false;
// Transition settings
this.blendProgress = 0;
this.blendDuration = 2.7; // seconds for preset transitions
}
/**
* Get the preset cycle duration from settings (in milliseconds)
*/
getPresetDuration() {
const seconds = visualizerSettings.getButterchurnCycleDuration();
return seconds * 1000; // Convert to milliseconds
}
/**
* Initialize Butterchurn with the given WebGL context
*/
init(canvas, gl, audioContext, sourceNode) {
if (this.isInitialized) return;
try {
this.canvas = canvas;
this.audioContext = audioContext;
// Load presets
this.presets = butterchurnPresets.getPresets();
this.presetKeys = Object.keys(this.presets);
// Filter to get a good selection of presets (some are better than others)
this.presetKeys = this.presetKeys.filter(key => {
// Skip some problematic or less visually appealing presets
const skipPatterns = ['flexi', 'empty', 'test', '_'];
return !skipPatterns.some(pattern => key.toLowerCase().includes(pattern));
});
if (this.presetKeys.length === 0) {
this.presetKeys = Object.keys(this.presets);
}
// Shuffle presets for variety
this.shufflePresets();
// Create Butterchurn visualizer
this.visualizer = butterchurn.createVisualizer(audioContext, canvas, {
width: canvas.width,
height: canvas.height,
pixelRatio: window.devicePixelRatio || 1,
textureRatio: 1,
});
// Connect audio source
if (sourceNode) {
this.visualizer.connectAudio(sourceNode);
}
// Load initial preset
this.loadRandomPreset();
this.lastPresetChange = performance.now();
this.isInitialized = true;
console.log('[Butterchurn] Initialized with', this.presetKeys.length, 'presets');
} catch (error) {
console.error('[Butterchurn] Initialization failed:', error);
}
}
/**
* Shuffle the preset keys for random variety
*/
shufflePresets() {
for (let i = this.presetKeys.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.presetKeys[i], this.presetKeys[j]] = [this.presetKeys[j], this.presetKeys[i]];
}
}
/**
* Load a random preset with smooth transition
*/
loadRandomPreset() {
if (!this.visualizer || this.presetKeys.length === 0) return;
this.currentPresetIndex = (this.currentPresetIndex + 1) % this.presetKeys.length;
const presetKey = this.presetKeys[this.currentPresetIndex];
const preset = this.presets[presetKey];
if (preset) {
try {
this.visualizer.loadPreset(preset, this.blendDuration);
console.log('[Butterchurn] Loaded preset:', presetKey);
} catch (error) {
console.warn('[Butterchurn] Failed to load preset:', presetKey, error);
// Try next preset
if (this.presetKeys.length > 1) {
this.presetKeys.splice(this.currentPresetIndex, 1);
this.loadRandomPreset();
}
}
}
}
/**
* Load a specific preset by name
*/
loadPreset(presetName) {
if (!this.visualizer || !this.presets) return;
const preset = this.presets[presetName];
if (preset) {
this.visualizer.loadPreset(preset, this.blendDuration);
console.log('[Butterchurn] Loaded preset:', presetName);
}
}
/**
* Get list of available preset names
*/
getPresetNames() {
return this.presetKeys;
}
/**
* Get current preset name
*/
getCurrentPresetName() {
return this.presetKeys[this.currentPresetIndex] || 'Unknown';
}
/**
* Skip to next preset
*/
nextPreset() {
this.loadRandomPreset();
this.lastPresetChange = performance.now();
}
/**
* Resize handler
*/
resize(width, height) {
if (this.visualizer) {
this.visualizer.setRendererSize(width, height);
}
}
/**
* Main draw function called each animation frame
*/
draw(ctx, canvas, analyser, dataArray, params) {
if (!this.isInitialized) {
// Lazy initialization - need audio context and source node
// This will be handled by the visualizer.js main class
return;
}
if (!this.visualizer) return;
const { mode } = params;
const now = performance.now();
// Auto-cycle presets (if cycle duration > 0)
const cycleDuration = this.getPresetDuration();
if (cycleDuration > 0 && now - this.lastPresetChange > cycleDuration) {
this.loadRandomPreset();
this.lastPresetChange = now;
}
// Render the visualization
try {
this.visualizer.render();
} catch (error) {
console.warn('[Butterchurn] Render error:', error);
}
// Handle blended mode - we need to composite with cover art
// Butterchurn renders directly to the canvas, so for blended mode
// we need to adjust the canvas opacity/blend
if (mode === 'blended') {
// The canvas will be composited by CSS in the parent
canvas.style.opacity = '0.85';
canvas.style.mixBlendMode = 'screen';
} else {
canvas.style.opacity = '1';
canvas.style.mixBlendMode = 'normal';
}
}
/**
* Connect audio source to the visualizer
*/
connectAudio(sourceNode) {
if (this.visualizer && sourceNode) {
try {
this.visualizer.connectAudio(sourceNode);
console.log('[Butterchurn] Audio connected');
} catch (error) {
console.warn('[Butterchurn] Failed to connect audio:', error);
}
}
}
/**
* Lazy initialization helper for when audio context becomes available
*/
lazyInit(canvas, audioContext, sourceNode) {
if (!this.isInitialized && canvas && audioContext) {
const gl = canvas.getContext('webgl2', {
alpha: true,
antialias: true,
preserveDrawingBuffer: true,
}) || canvas.getContext('webgl', {
alpha: true,
antialias: true,
preserveDrawingBuffer: true,
});
if (gl) {
this.init(canvas, gl, audioContext, sourceNode);
}
}
}
/**
* Cleanup resources
*/
destroy() {
if (this.visualizer) {
// Butterchurn doesn't have an explicit cleanup method
// but we can null our references
this.visualizer = null;
}
this.isInitialized = false;
this.canvas = null;
this.audioContext = null;
console.log('[Butterchurn] Destroyed');
}
}

70
package-lock.json generated
View file

@ -9,6 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"dashjs": "^5.1.1",
"pocketbase": "^0.26.5"
},
@ -74,6 +76,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -1513,7 +1516,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -1603,6 +1605,7 @@
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@ -1644,6 +1647,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -1687,6 +1691,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -3138,6 +3143,7 @@
"resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz",
"integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=20"
},
@ -3186,6 +3192,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3209,6 +3216,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -3397,6 +3405,16 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
"license": "MIT",
"dependencies": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -3496,6 +3514,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3517,6 +3536,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/butterchurn": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/butterchurn/-/butterchurn-2.6.7.tgz",
"integrity": "sha512-BJiRA8L0L2+84uoG2SSfkp0kclBuN+vQKf217pK7pMlwEO2ZEg3MtO2/o+l8Qpr8Nbejg8tmL1ZHD1jmhiaaqg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0",
"ecma-proposal-math-extensions": "0.0.2"
}
},
"node_modules/butterchurn-presets": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/butterchurn-presets/-/butterchurn-presets-2.4.7.tgz",
"integrity": "sha512-4MdM8ripz/VfH1BCldrIKdAc/1ryJFBDvqlyow6Ivo1frwj0H3duzvSMFC7/wIjAjxb1QpwVHVqGqS9uAFKhpg==",
"license": "MIT",
"dependencies": {
"babel-runtime": "^6.26.0",
"ecma-proposal-math-extensions": "0.0.2",
"lodash": "^4.17.4"
}
},
"node_modules/cacheable": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.1.tgz",
@ -3719,6 +3759,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/core-js-compat": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz",
@ -4006,6 +4054,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/ecma-proposal-math-extensions": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/ecma-proposal-math-extensions/-/ecma-proposal-math-extensions-0.0.2.tgz",
"integrity": "sha512-80BnDp2Fn7RxXlEr5HHZblniY4aQ97MOAicdWWpSo0vkQiISSE9wLR4SqxKsu4gCtXFBIPPzy8JMhay4NWRg/Q==",
"license": "MIT"
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@ -4280,6 +4334,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -6124,7 +6179,6 @@
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.debounce": {
@ -6636,6 +6690,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -6719,6 +6774,7 @@
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -6870,6 +6926,12 @@
"node": ">=4"
}
},
"node_modules/regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@ -7668,6 +7730,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
@ -8082,6 +8145,7 @@
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@ -8406,6 +8470,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -8793,6 +8858,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},

View file

@ -43,7 +43,9 @@
"source-map": "^0.7.4"
},
"dependencies": {
"pocketbase": "^0.26.5",
"dashjs": "^5.1.1"
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"dashjs": "^5.1.1",
"pocketbase": "^0.26.5"
}
}