From c2fd81348afb7cca090631f4b69b86cd221c49d3 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Mon, 9 Feb 2026 03:26:32 +0000 Subject: [PATCH] ACTUALLY fix butterchurn --- js/audio-context.js | 41 ++++++ js/settings.js | 66 ++++++--- js/visualizer.js | 16 +-- js/visualizers/butterchurn.js | 262 ++++++++++++++++++++++------------ 4 files changed, 263 insertions(+), 122 deletions(-) diff --git a/js/audio-context.js b/js/audio-context.js index 000aa7a..dd23c6c 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -86,10 +86,41 @@ class AudioContextManager { this.currentGains = new Array(16).fill(0); this.audio = null; + // Callbacks for audio graph changes (for visualizers like Butterchurn) + this._graphChangeCallbacks = []; + // Load saved settings this._loadSettings(); } + /** + * Register a callback to be called when audio graph is reconnected + * @param {Function} callback - Function to call when graph changes + * @returns {Function} - Unregister function + */ + onGraphChange(callback) { + this._graphChangeCallbacks.push(callback); + return () => { + const index = this._graphChangeCallbacks.indexOf(callback); + if (index > -1) { + this._graphChangeCallbacks.splice(index, 1); + } + }; + } + + /** + * Notify all registered callbacks that graph has changed + */ + _notifyGraphChange() { + this._graphChangeCallbacks.forEach((callback) => { + try { + callback(this.source); + } catch (e) { + console.warn('[AudioContext] Graph change callback failed:', e); + } + }); + } + /** * Initialize the audio context and connect to the audio element * This should be called when audio starts playing @@ -183,6 +214,9 @@ class AudioContextManager { this.analyser.connect(this.audioContext.destination); console.log('[AudioContext] EQ bypassed'); } + + // Notify visualizers that graph has been reconnected + this._notifyGraphChange(); } catch (e) { console.warn('[AudioContext] Failed to connect graph:', e); // Fallback: direct connection @@ -234,6 +268,13 @@ class AudioContextManager { return this.audioContext; } + /** + * Get the source node for visualizers + */ + getSourceNode() { + return this.source; + } + /** * Check if initialized */ diff --git a/js/settings.js b/js/settings.js index 23ae14a..c6c6d6f 100644 --- a/js/settings.js +++ b/js/settings.js @@ -26,6 +26,7 @@ import { fontSettings, } from './storage.js'; import { audioContextManager, EQ_PRESETS } from './audio-context.js'; +import { getButterchurnPresets } from './visualizers/butterchurn.js'; import { db } from './db.js'; import { authManager } from './accounts/auth.js'; import { syncManager } from './accounts/pocketbase.js'; @@ -884,31 +885,37 @@ export function initializeSettings(scrobbler, player, api, ui) { if (butterchurnDurationSetting) butterchurnDurationSetting.style.display = showSubSettings ? 'flex' : 'none'; if (butterchurnRandomizeSetting) butterchurnRandomizeSetting.style.display = showSubSettings ? 'flex' : 'none'; - // Populate preset list if visible - if (show && ui && ui.visualizer && ui.visualizer.presets['butterchurn']) { - const preset = ui.visualizer.presets['butterchurn']; - const select = butterchurnSpecificPresetSelect; + // Populate preset list using module-level cache (works even before visualizer initializes) + const { keys: presetNames } = getButterchurnPresets(); + const select = butterchurnSpecificPresetSelect; - // Only populate if needed (to avoid resetting selection or heavy DOM ops) - if (select && select.options.length <= 1 && preset.getPresetNames && preset.getPresetNames().length > 0) { - const names = preset.getPresetNames(); + if (select && presetNames.length > 0) { + const currentNames = Array.from(select.options).map((opt) => opt.value); + // Check if dropdown only has "Loading..." or needs full update + const hasOnlyLoadingOption = currentNames.length === 1 && currentNames[0] === ''; + const needsUpdate = + hasOnlyLoadingOption || + currentNames.length !== presetNames.length || + !presetNames.every((name) => currentNames.includes(name)); + + if (needsUpdate) { + // Save current selection + const currentSelection = select.value; + + // Clear and rebuild dropdown select.innerHTML = ''; - names.forEach(name => { + presetNames.forEach((name) => { const option = document.createElement('option'); option.value = name; option.textContent = name; select.appendChild(option); }); - // Select current - if (preset.getCurrentPresetName) { - select.value = preset.getCurrentPresetName(); - } - } else if (select && preset.getCurrentPresetName) { - // Just update selection if list already populated - const current = preset.getCurrentPresetName(); - if (select.value !== current) { - select.value = current; + // Restore selection if it still exists + if (presetNames.includes(currentSelection)) { + select.value = currentSelection; + } else { + select.selectedIndex = 0; } } } @@ -982,6 +989,7 @@ export function initializeSettings(scrobbler, player, api, ui) { if (butterchurnSpecificPresetSelect) { butterchurnSpecificPresetSelect.addEventListener('change', (e) => { + // Try to load via visualizer if active, otherwise just store the selection if (ui && ui.visualizer && ui.visualizer.presets['butterchurn']) { ui.visualizer.presets['butterchurn'].loadPreset(e.target.value); } @@ -990,9 +998,33 @@ export function initializeSettings(scrobbler, player, api, ui) { // Refresh settings when presets are loaded asynchronously window.addEventListener('butterchurn-presets-loaded', () => { + console.log('[Settings] Butterchurn presets loaded event received'); updateButterchurnSettingsVisibility(); }); + // Check if presets already cached and update immediately + const { keys: cachedKeys } = getButterchurnPresets(); + if (cachedKeys.length > 0) { + console.log('[Settings] Presets already cached, updating dropdown immediately'); + updateButterchurnSettingsVisibility(); + } + + // Watch for audio tab becoming active and refresh presets + const audioTabContent = document.getElementById('settings-tab-audio'); + if (audioTabContent) { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + if (audioTabContent.classList.contains('active')) { + console.log('[Settings] Audio tab became active, refreshing presets'); + updateButterchurnSettingsVisibility(); + } + } + }); + }); + observer.observe(audioTabContent, { attributes: true }); + } + // Visualizer Mode Select const visualizerModeSelect = document.getElementById('visualizer-mode-select'); if (visualizerModeSelect) { diff --git a/js/visualizer.js b/js/visualizer.js index d0a70af..4f92fe1 100644 --- a/js/visualizer.js +++ b/js/visualizer.js @@ -79,7 +79,7 @@ export class Visualizer { * Get the source node */ getSourceNode() { - return audioContextManager.source; + return audioContextManager.getSourceNode(); } initContext() { @@ -143,11 +143,8 @@ export class Visualizer { // Initialize Butterchurn if it's the active preset if (this.activePresetKey === 'butterchurn' && this.activePreset.lazyInit) { - this.activePreset.lazyInit( - this.canvas, - this.audioContext, - this.analyser - ); + const sourceNode = audioContextManager.getSourceNode(); + this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode); } this.resize(); @@ -272,11 +269,8 @@ export class Visualizer { // Initialize Butterchurn if switching to it if (key === 'butterchurn' && this.presets[key].lazyInit && this.audioContext) { - this.presets[key].lazyInit( - this.canvas, - this.audioContext, - this.analyser - ); + const sourceNode = audioContextManager.getSourceNode(); + this.presets[key].lazyInit(this.canvas, this.audioContext, sourceNode); } } } diff --git a/js/visualizers/butterchurn.js b/js/visualizers/butterchurn.js index 70514c8..388d6d1 100644 --- a/js/visualizers/butterchurn.js +++ b/js/visualizers/butterchurn.js @@ -1,9 +1,103 @@ /** * Butterchurn (Milkdrop) Visualizer Preset * WebGL-based audio visualization using the Butterchurn library + * Uses same loading logic as bc-demo.html - loads presets as global scripts */ 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 so they're available immediately + */ +function loadPresetsModule() { + if (cachedPresets || isLoading) return; + isLoading = true; + + // Check if already loaded in global + if (window.butterchurnPresets) { + processPresetsModule(); + return; + } + + // Load presets script like bc-demo.html does + const script = document.createElement('script'); + script.src = '/node_modules/butterchurn-presets/lib/butterchurnPresets.min.js'; + script.onload = () => { + console.log('[Butterchurn] Presets script loaded'); + processPresetsModule(); + }; + script.onerror = (e) => { + console.error('[Butterchurn] Failed to load presets script:', e); + isLoading = false; + }; + document.head.appendChild(script); +} + +/** + * Process loaded presets at module level + */ +function processPresetsModule() { + try { + const presetsModule = window.butterchurnPresets; + if (!presetsModule) { + console.error('[Butterchurn] butterchurnPresets not found on window'); + isLoading = false; + return; + } + + const allPresets = + typeof presetsModule.getPresets === 'function' + ? presetsModule.getPresets() + : presetsModule.default || presetsModule; + + cachedPresets = allPresets || {}; + cachedPresetKeys = Object.keys(cachedPresets); + 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 process 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 +loadPresetsModule(); export class ButterchurnPreset { constructor() { @@ -17,60 +111,30 @@ export class ButterchurnPreset { this.lastPresetChange = 0; this.isInitialized = false; - this.presets = {}; - this.presetKeys = []; - this.isLoadingPresets = false; + // Use cached presets if available + this.presets = cachedPresets || {}; + this.presetKeys = cachedPresetKeys || []; + this.isLoadingPresets = isLoading; // Transition settings this.blendProgress = 0; this.blendDuration = 2.7; // seconds for preset transitions - // Load presets asynchronously - this.loadPresets(); - } + // Listen for presets if not loaded yet + if (!cachedPresets) { + onButterchurnPresetsLoaded((presets, keys) => { + this.presets = presets; + this.presetKeys = keys; + this.isLoadingPresets = false; - /** - * Load presets dynamically to avoid blocking main bundle - */ - async loadPresets() { - if (this.isLoadingPresets) return; - this.isLoadingPresets = true; + // Notify system that presets are ready (for settings dropdown) + window.dispatchEvent(new CustomEvent('butterchurn-presets-loaded')); - try { - const module = await import('butterchurn-presets'); - const presets = module.default.getPresets(); - - this.presets = presets; - this.presetKeys = Object.keys(this.presets); - - // Filter to get a good selection of presets - this.presetKeys = this.presetKeys.filter(key => { - const skipPatterns = ['flexi', 'empty', 'test', '_']; - return !skipPatterns.some(pattern => key.toLowerCase().includes(pattern)); + // If visualizer already initialized, load a preset + if (this.isInitialized && this.visualizer) { + this.loadNextPreset(); + } }); - - if (this.presetKeys.length === 0) { - this.presetKeys = Object.keys(this.presets); - } - - // Shuffle presets for variety - this.shufflePresets(); - - console.log('[Butterchurn] Presets loaded:', this.presetKeys.length); - - // Notify system that presets are ready - window.dispatchEvent(new CustomEvent('butterchurn-presets-loaded')); - - // If initialized (visualizer ready), load a preset immediately - if (this.isInitialized && this.visualizer) { - this.loadNextPreset(); - } - } catch (e) { - console.error('[Butterchurn] Failed to load presets:', e); - this.presets = {}; - this.presetKeys = []; - } finally { - this.isLoadingPresets = false; } } @@ -102,7 +166,7 @@ export class ButterchurnPreset { // Connect audio source if (sourceNode) { - this.visualizer.connectAudio(sourceNode); + this.connectAudioWithDelay(sourceNode); } // Load initial preset @@ -111,6 +175,16 @@ export class ButterchurnPreset { 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); @@ -118,12 +192,27 @@ export class ButterchurnPreset { } /** - * Shuffle the preset keys for random variety + * Connect audio source to the visualizer (public API) */ - 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]]; + 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); } } @@ -133,15 +222,6 @@ export class ButterchurnPreset { loadNextPreset() { if (!this.visualizer || this.presetKeys.length === 0) return; - // If cycle enabled is false, don't change preset automatically unless forced (e.g. init or manual next) - // But here we are just loading 'a' preset. - // The cycling logic is in draw(). - - // Wait, loadNextPreset is general. - // Let's check settings inside loadNextPreset? - // No, loadNextPreset is an action. It should just do it. - // The caller decides when. - const randomize = visualizerSettings.isButterchurnRandomizeEnabled(); if (randomize) { @@ -156,7 +236,6 @@ export class ButterchurnPreset { 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 @@ -222,8 +301,6 @@ export class ButterchurnPreset { */ 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; } @@ -249,11 +326,8 @@ export class ButterchurnPreset { 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 + // Handle blended mode if (mode === 'blended') { - // The canvas will be composited by CSS in the parent canvas.style.opacity = '0.85'; canvas.style.mixBlendMode = 'screen'; } else { @@ -262,38 +336,34 @@ export class ButterchurnPreset { } } - /** - * 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, - }); + 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); + 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); } } @@ -301,9 +371,13 @@ export class ButterchurnPreset { * Cleanup resources */ destroy() { + // Unregister graph change listener + if (this._unregisterGraphChange) { + this._unregisterGraphChange(); + this._unregisterGraphChange = null; + } + if (this.visualizer) { - // Butterchurn doesn't have an explicit cleanup method - // but we can null our references this.visualizer = null; } this.isInitialized = false;