517 lines
16 KiB
JavaScript
517 lines
16 KiB
JavaScript
/**
|
|
* Butterchurn (Milkdrop) Visualizer Preset
|
|
* WebGL-based audio visualization using the Butterchurn library
|
|
*/
|
|
import butterchurn from 'butterchurn';
|
|
import { visualizerSettings } from '../storage.js';
|
|
import { audioContextManager } from '../audio-context.js';
|
|
|
|
// Module-level preset cache - loads immediately when this file is imported
|
|
let cachedPresets = null;
|
|
let cachedPresetKeys = [];
|
|
let isLoading = false;
|
|
let loadCallbacks = [];
|
|
|
|
/**
|
|
* Load presets at module level using dynamic import (lazy loaded)
|
|
*/
|
|
async function loadPresetsModule() {
|
|
if (cachedPresets || isLoading) return;
|
|
isLoading = true;
|
|
|
|
try {
|
|
// Load butterchurn-presets via script tag to avoid ES module issues
|
|
if (!window.butterchurnPresets) {
|
|
await new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = '/lib/butterchurnPresets.min.js';
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
const butterchurnPresets = window.butterchurnPresets;
|
|
console.log('[Butterchurn] Presets loaded, type:', typeof butterchurnPresets);
|
|
|
|
if (typeof butterchurnPresets?.getPresets !== 'function') {
|
|
console.error(
|
|
'[Butterchurn] butterchurnPresets.getPresets is not a function:',
|
|
typeof butterchurnPresets?.getPresets
|
|
);
|
|
isLoading = false;
|
|
return;
|
|
}
|
|
|
|
const allPresets = butterchurnPresets.getPresets();
|
|
cachedPresets = allPresets || {};
|
|
cachedPresetKeys = Object.keys(cachedPresets);
|
|
|
|
// Filter out unwanted presets
|
|
const skipPatterns = ['flexi', 'empty', 'test', '_'];
|
|
cachedPresetKeys = cachedPresetKeys.filter((key) => {
|
|
return !skipPatterns.some((pattern) => key.toLowerCase().includes(pattern));
|
|
});
|
|
|
|
// Sort alphabetically
|
|
cachedPresetKeys.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
|
|
console.log('[Butterchurn] Module-level presets loaded:', cachedPresetKeys.length);
|
|
|
|
// Notify all waiting callbacks
|
|
loadCallbacks.forEach((cb) => cb(cachedPresets, cachedPresetKeys));
|
|
loadCallbacks = [];
|
|
|
|
// Dispatch global event
|
|
window.dispatchEvent(new CustomEvent('butterchurn-presets-loaded'));
|
|
} catch (e) {
|
|
console.error('[Butterchurn] Failed to load presets:', e);
|
|
cachedPresets = {};
|
|
cachedPresetKeys = [];
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cached presets - available immediately after module loads
|
|
*/
|
|
export function getButterchurnPresets() {
|
|
return { presets: cachedPresets, keys: cachedPresetKeys };
|
|
}
|
|
|
|
/**
|
|
* Register callback for when presets are loaded
|
|
*/
|
|
export function onButterchurnPresetsLoaded(callback) {
|
|
if (cachedPresets) {
|
|
callback(cachedPresets, cachedPresetKeys);
|
|
} else {
|
|
loadCallbacks.push(callback);
|
|
}
|
|
}
|
|
|
|
// Start loading presets immediately when module is imported (lazy loaded)
|
|
loadPresetsModule().catch(console.error);
|
|
|
|
export class ButterchurnPreset {
|
|
constructor() {
|
|
this.name = 'Butterchurn';
|
|
this.contextType = 'webgl';
|
|
this.managesOwnContext = true;
|
|
|
|
this.visualizer = null;
|
|
this.canvas = null;
|
|
this.audioContext = null;
|
|
this.currentPresetIndex = 0;
|
|
this.lastPresetChange = 0;
|
|
this.isInitialized = false;
|
|
|
|
// Use cached presets if available
|
|
this.presets = cachedPresets || {};
|
|
this.presetKeys = cachedPresetKeys || [];
|
|
|
|
// Shuffled queue for random mode
|
|
this.shuffledQueue = [];
|
|
this.shuffledIndex = 0;
|
|
|
|
// Generate shuffled queue if presets are already loaded
|
|
if (this.presetKeys.length > 0) {
|
|
this.generateShuffledQueue();
|
|
}
|
|
|
|
// Transition settings
|
|
this.blendProgress = 0;
|
|
this.blendDuration = 2.7; // seconds for preset transitions
|
|
|
|
// Listen for presets if not loaded yet
|
|
if (!cachedPresets) {
|
|
onButterchurnPresetsLoaded((presets, keys) => {
|
|
this.presets = presets;
|
|
this.presetKeys = keys;
|
|
this.generateShuffledQueue();
|
|
|
|
// Notify system that presets are ready (for settings dropdown)
|
|
window.dispatchEvent(new CustomEvent('butterchurn-presets-loaded'));
|
|
|
|
// If visualizer already initialized, load a preset
|
|
if (this.isInitialized && this.visualizer) {
|
|
this.loadNextPreset();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a shuffled queue of preset indices
|
|
*/
|
|
generateShuffledQueue() {
|
|
const indices = this.presetKeys.map((_, i) => i);
|
|
// Fisher-Yates shuffle
|
|
for (let i = indices.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[indices[i], indices[j]] = [indices[j], indices[i]];
|
|
}
|
|
this.shuffledQueue = indices;
|
|
this.shuffledIndex = 0;
|
|
}
|
|
|
|
/**
|
|
* Get the current preset index based on mode
|
|
*/
|
|
getCurrentIndex() {
|
|
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
|
|
if (randomize && this.shuffledQueue.length > 0) {
|
|
return this.shuffledQueue[this.shuffledIndex];
|
|
}
|
|
return this.currentPresetIndex;
|
|
}
|
|
|
|
/**
|
|
* Set the current preset index based on mode
|
|
*/
|
|
setCurrentIndex(index) {
|
|
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
|
|
if (randomize && this.shuffledQueue.length > 0) {
|
|
this.shuffledIndex = this.shuffledQueue.indexOf(index);
|
|
if (this.shuffledIndex === -1) this.shuffledIndex = 0;
|
|
} else {
|
|
this.currentPresetIndex = index;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// 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.connectAudioWithDelay(sourceNode);
|
|
}
|
|
|
|
// Load initial preset
|
|
this.loadNextPreset();
|
|
|
|
this.lastPresetChange = performance.now();
|
|
this.isInitialized = true;
|
|
|
|
// Register for audio graph changes so we can reconnect when EQ is toggled
|
|
if (audioContextManager) {
|
|
this._unregisterGraphChange = audioContextManager.onGraphChange((sourceNode) => {
|
|
if (sourceNode && this.isInitialized) {
|
|
console.log('[Butterchurn] Audio graph changed, reconnecting...');
|
|
this.connectAudioWithDelay(sourceNode);
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log('[Butterchurn] Initialized with', this.presetKeys.length, 'presets');
|
|
} catch (error) {
|
|
console.error('[Butterchurn] Initialization failed:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect audio source to the visualizer (public API)
|
|
*/
|
|
connectAudio(sourceNode) {
|
|
if (sourceNode) {
|
|
this.connectAudioWithDelay(sourceNode);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect audio source with delay node for proper sync
|
|
* Like bc-demo.html: creates a delay node and connects visualizer to it
|
|
*/
|
|
connectAudioWithDelay(sourceNode) {
|
|
if (!this.audioContext || !this.visualizer) return;
|
|
|
|
try {
|
|
// Connect visualizer directly to the source node
|
|
this.visualizer.connectAudio(sourceNode);
|
|
console.log('[Butterchurn] Audio connected');
|
|
} catch (error) {
|
|
console.warn('[Butterchurn] Failed to connect audio:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load next preset based on settings (sequential or random)
|
|
*/
|
|
loadNextPreset() {
|
|
if (!this.visualizer || this.presetKeys.length === 0) return;
|
|
|
|
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
|
|
|
|
if (randomize) {
|
|
if (this.shuffledQueue.length === 0) {
|
|
this.generateShuffledQueue();
|
|
}
|
|
this.shuffledIndex = (this.shuffledIndex + 1) % this.shuffledQueue.length;
|
|
if (this.shuffledIndex === 0) {
|
|
// Re-shuffle when we've gone through all presets
|
|
this.generateShuffledQueue();
|
|
this.shuffledIndex = 0;
|
|
}
|
|
this.currentPresetIndex = this.shuffledQueue[this.shuffledIndex];
|
|
} else {
|
|
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);
|
|
} catch (error) {
|
|
console.warn('[Butterchurn] Failed to load preset:', presetKey, error);
|
|
// Try next preset
|
|
if (this.presetKeys.length > 1) {
|
|
this.loadNextPreset();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// Update current index if found
|
|
const index = this.presetKeys.indexOf(presetName);
|
|
if (index !== -1) {
|
|
this.setCurrentIndex(index);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list of available preset names
|
|
*/
|
|
getPresetNames() {
|
|
return this.presetKeys;
|
|
}
|
|
|
|
/**
|
|
* Get current preset name
|
|
*/
|
|
getCurrentPresetName() {
|
|
const index = this.getCurrentIndex();
|
|
return this.presetKeys[index] || 'Unknown';
|
|
}
|
|
|
|
/**
|
|
* Skip to next preset (manually triggered)
|
|
* Uses shuffled queue in random mode, sequential in normal mode
|
|
*/
|
|
nextPreset() {
|
|
if (!this.visualizer || this.presetKeys.length === 0) return;
|
|
|
|
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
|
|
|
|
if (randomize) {
|
|
this.shuffledIndex = (this.shuffledIndex + 1) % this.shuffledQueue.length;
|
|
if (this.shuffledIndex === 0) {
|
|
// Re-shuffle when we've gone through all presets
|
|
this.generateShuffledQueue();
|
|
this.shuffledIndex = 0;
|
|
}
|
|
this.currentPresetIndex = this.shuffledQueue[this.shuffledIndex];
|
|
} else {
|
|
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);
|
|
} catch (error) {
|
|
console.warn('[Butterchurn] Failed to load preset:', presetKey, error);
|
|
if (this.presetKeys.length > 1) {
|
|
this.nextPreset();
|
|
}
|
|
}
|
|
}
|
|
this.lastPresetChange = performance.now();
|
|
}
|
|
|
|
/**
|
|
* Skip to previous preset (manually triggered)
|
|
* Uses shuffled queue in random mode, sequential in normal mode
|
|
*/
|
|
prevPreset() {
|
|
if (!this.visualizer || this.presetKeys.length === 0) return;
|
|
|
|
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
|
|
|
|
if (randomize) {
|
|
this.shuffledIndex = (this.shuffledIndex - 1 + this.shuffledQueue.length) % this.shuffledQueue.length;
|
|
this.currentPresetIndex = this.shuffledQueue[this.shuffledIndex];
|
|
} else {
|
|
this.currentPresetIndex = (this.currentPresetIndex - 1 + this.presetKeys.length) % this.presetKeys.length;
|
|
}
|
|
|
|
const presetKey = this.presetKeys[this.currentPresetIndex];
|
|
const preset = this.presets[presetKey];
|
|
|
|
if (preset) {
|
|
try {
|
|
this.visualizer.loadPreset(preset, this.blendDuration);
|
|
} catch (error) {
|
|
console.warn('[Butterchurn] Failed to load preset:', presetKey, error);
|
|
}
|
|
}
|
|
this.lastPresetChange = performance.now();
|
|
}
|
|
|
|
/**
|
|
* Toggle auto-cycle on/off
|
|
*/
|
|
toggleCycle() {
|
|
const current = visualizerSettings.isButterchurnCycleEnabled();
|
|
visualizerSettings.setButterchurnCycleEnabled(!current);
|
|
return !current;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return;
|
|
}
|
|
|
|
if (!this.visualizer) return;
|
|
|
|
const { mode } = params;
|
|
const now = performance.now();
|
|
|
|
// Auto-cycle presets
|
|
const isCycleEnabled = visualizerSettings.isButterchurnCycleEnabled();
|
|
if (isCycleEnabled) {
|
|
const cycleDuration = this.getPresetDuration();
|
|
if (cycleDuration > 0 && now - this.lastPresetChange > cycleDuration) {
|
|
this.loadNextPreset();
|
|
this.lastPresetChange = now;
|
|
}
|
|
}
|
|
|
|
// Render the visualization
|
|
try {
|
|
this.visualizer.render();
|
|
} catch (error) {
|
|
console.warn('[Butterchurn] Render error:', error);
|
|
}
|
|
|
|
// Handle blended mode
|
|
if (mode === 'blended') {
|
|
canvas.style.opacity = '0.85';
|
|
canvas.style.mixBlendMode = 'screen';
|
|
} else {
|
|
canvas.style.opacity = '1';
|
|
canvas.style.mixBlendMode = 'normal';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lazy initialization helper for when audio context becomes available
|
|
*/
|
|
lazyInit(canvas, audioContext, sourceNode) {
|
|
return new Promise((resolve) => {
|
|
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, null);
|
|
|
|
// Connect audio if sourceNode is provided
|
|
if (sourceNode) {
|
|
this.connectAudioWithDelay(sourceNode);
|
|
}
|
|
}
|
|
} else if (this.isInitialized && sourceNode) {
|
|
// Reconnect if source changed
|
|
this.connectAudioWithDelay(sourceNode);
|
|
}
|
|
resolve();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cleanup resources
|
|
*/
|
|
destroy() {
|
|
if (this._unregisterGraphChange) {
|
|
this._unregisterGraphChange();
|
|
this._unregisterGraphChange = null;
|
|
}
|
|
|
|
if (this.visualizer && this.canvas) {
|
|
const gl = this.canvas.getContext('webgl2') || this.canvas.getContext('webgl');
|
|
if (gl) {
|
|
const ext = gl.getExtension('WEBGL_lose_context');
|
|
if (ext) {
|
|
ext.loseContext();
|
|
}
|
|
}
|
|
this.visualizer = null;
|
|
}
|
|
|
|
this.isInitialized = false;
|
|
this.canvas = null;
|
|
this.audioContext = null;
|
|
console.log('[Butterchurn] Destroyed');
|
|
}
|
|
}
|