Add visualizer configuration (#1443)

* add visualizer configuration

* add visualizer presets

* add butterchurn visualizer

* wrap visualizers in error boundary
This commit is contained in:
Jeff
2025-12-24 18:12:13 -08:00
committed by GitHub
parent 8e04f98e26
commit d9172efae9
22 changed files with 3197 additions and 80 deletions
@@ -0,0 +1,11 @@
.container {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-md);
width: 100%;
margin: 0 auto;
}
.select-label {
text-align: center;
}
@@ -0,0 +1,5 @@
import { VisualizerSettingsForm } from './visualizer-settings-form';
export const VisualizerSettingsContextModal = () => {
return <VisualizerSettingsForm />;
};
@@ -0,0 +1,28 @@
.container {
position: relative;
z-index: 50;
width: 100%;
height: 100%;
margin: auto;
canvas {
width: 100%;
margin: auto;
}
&:hover {
.settings-icon {
opacity: 1;
}
}
}
.container .settings-icon {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.visualizer {
width: 100%;
height: 100%;
}
@@ -0,0 +1,243 @@
import AudioMotionAnalyzer from 'audiomotion-analyzer';
import { createRef, useCallback, useEffect, useMemo, useState } from 'react';
import styles from './visualizer.module.css';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { openVisualizerSettingsModal } from '/@/renderer/features/player/utils/open-visualizer-settings-modal';
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
import { useSettingsStore } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
const VisualizerInner = () => {
const { webAudio } = useWebAudio();
const canvasRef = createRef<HTMLDivElement>();
const accent = useSettingsStore((store) => store.general.accent);
const visualizer = useSettingsStore((store) => store.visualizer);
const [motion, setMotion] = useState<AudioMotionAnalyzer>();
// Check if a gradient name is a custom gradient
const isCustomGradient = useCallback(
(gradientName: string | undefined): boolean => {
if (!gradientName || visualizer.type !== 'audiomotionanalyzer') {
return false;
}
const customGradients = visualizer.audiomotionanalyzer.customGradients || [];
return customGradients.some((gradient) => gradient.name === gradientName);
},
[visualizer],
);
const [gradientsRegistered, setGradientsRegistered] = useState(false);
const options = useMemo(() => {
if (visualizer.type !== 'audiomotionanalyzer') {
return {};
}
const ama = visualizer.audiomotionanalyzer;
const defaults = {
bgAlpha: 0,
showBgColor: false,
};
const gradients: { gradient?: string; gradientLeft?: string; gradientRight?: string } = {};
// Use default gradient if custom gradient is selected but not yet registered
const getSafeGradient = (gradientName: string | undefined): string => {
if (!gradientName) return 'classic';
if (isCustomGradient(gradientName)) {
// Use default until custom gradients are registered
return gradientsRegistered ? gradientName : 'classic';
}
return gradientName;
};
if (ama.channelLayout === 'single') {
gradients.gradient = getSafeGradient(ama.gradient);
} else {
gradients.gradientLeft = getSafeGradient(ama.gradientLeft);
gradients.gradientRight = getSafeGradient(ama.gradientRight);
}
return {
...defaults,
...gradients,
alphaBars: ama.alphaBars,
ansiBands: ama.ansiBands,
barSpace: ama.barSpace,
channelLayout: ama.channelLayout,
colorMode: ama.colorMode,
connectSpeakers: false,
fadePeaks: ama.fadePeaks,
fftSize: ama.fftSize,
fillAlpha: ama.fillAlpha,
frequencyScale: ama.frequencyScale,
gravity: ama.gravity,
ledBars: ama.ledBars,
linearAmplitude: ama.linearAmplitude,
linearBoost: ama.linearBoost,
lineWidth: ama.lineWidth,
loRes: ama.loRes,
lumiBars: ama.lumiBars,
maxDecibels: ama.maxDecibels,
maxFPS: ama.maxFPS,
maxFreq: ama.maxFreq,
minDecibels: ama.minDecibels,
minFreq: ama.minFreq,
mirror: ama.mirror,
mode: ama.mode,
noteLabels: ama.noteLabels,
outlineBars: ama.outlineBars,
overlay: true,
peakFadeTime: ama.peakFadeTime,
peakHoldTime: ama.peakHoldTime,
peakLine: ama.peakLine,
radial: ama.radial,
radialInvert: ama.radialInvert,
radius: ama.radius,
reflexAlpha: ama.reflexAlpha,
reflexBright: ama.reflexBright,
reflexFit: ama.reflexFit,
reflexRatio: ama.reflexRatio,
roundBars: ama.roundBars,
showFPS: ama.showFPS,
showPeaks: ama.showPeaks,
showScaleX: ama.showScaleX,
showScaleY: ama.showScaleY,
smoothing: ama.smoothing,
spinSpeed: ama.spinSpeed,
splitGradient: ama.splitGradient,
trueLeds: ama.trueLeds,
volume: ama.volume,
weightingFilter: (ama.weightingFilter || '') as any,
};
}, [visualizer, gradientsRegistered, isCustomGradient]);
const registerCustomGradients = useCallback(
(audioMotionInstance: AudioMotionAnalyzer) => {
if (visualizer.type !== 'audiomotionanalyzer') {
return;
}
const customGradients = visualizer.audiomotionanalyzer.customGradients || [];
customGradients.forEach((gradient) => {
try {
const gradientConfig: {
colorStops: (string | { color: string; level?: number; pos?: number })[];
dir?: string;
} = {
colorStops: gradient.colorStops,
};
if (gradient.dir) {
gradientConfig.dir = gradient.dir;
}
// Type assertion needed as TypeScript definitions may be incomplete
audioMotionInstance.registerGradient(gradient.name, gradientConfig as any);
} catch (error) {
console.error(`Failed to register gradient "${gradient.name}":`, error);
}
});
// Mark gradients as registered
setGradientsRegistered(true);
},
[visualizer],
);
useEffect(() => {
const { context, gains } = webAudio || {};
if (gains && context && canvasRef.current && !motion) {
// Reset gradients registered flag on new instance
setGradientsRegistered(false);
// Create options without custom gradients on first init
const initOptions: any = { ...options };
// Replace custom gradients with default 'classic' for initial setup
if (visualizer.type === 'audiomotionanalyzer') {
const ama = visualizer.audiomotionanalyzer;
if (isCustomGradient(ama.gradient)) {
initOptions.gradient = 'classic';
}
if (isCustomGradient(ama.gradientLeft)) {
initOptions.gradientLeft = 'classic';
}
if (isCustomGradient(ama.gradientRight)) {
initOptions.gradientRight = 'classic';
}
}
const audioMotion = new AudioMotionAnalyzer(canvasRef.current, {
...initOptions,
audioCtx: context,
});
// Register custom gradients (this will set gradientsRegistered to true)
registerCustomGradients(audioMotion);
setMotion(audioMotion);
for (const gain of gains) audioMotion.connectInput(gain);
}
return () => {};
}, [
accent,
canvasRef,
motion,
registerCustomGradients,
webAudio,
visualizer,
options,
isCustomGradient,
]);
// Re-register custom gradients when they change
useEffect(() => {
if (motion && visualizer.type === 'audiomotionanalyzer') {
setGradientsRegistered(false);
registerCustomGradients(motion);
}
}, [
motion,
registerCustomGradients,
visualizer.audiomotionanalyzer.customGradients,
visualizer.type,
]);
// Update visualizer settings when they change
useEffect(() => {
if (motion) {
motion.setOptions(options);
}
}, [motion, options]);
return (
<div className={styles.container}>
<ActionIcon
className={styles.settingsIcon}
icon="settings2"
iconProps={{ size: 'lg' }}
onClick={openVisualizerSettingsModal}
pos="absolute"
right={0}
top={0}
variant="transparent"
/>
<div className={styles.visualizer} ref={canvasRef} />
</div>
);
};
export const Visualizer = () => {
return (
<ComponentErrorBoundary>
<VisualizerInner />
</ComponentErrorBoundary>
);
};
@@ -0,0 +1,7 @@
declare module 'butterchurn' {
export default butterchurn;
}
declare module 'butterchurn-presets' {
export default butterchurnPresets;
}
@@ -0,0 +1,43 @@
.container {
position: relative;
z-index: 50;
width: 100%;
height: 100%;
margin: auto;
&:hover {
.settings-icon {
opacity: 1;
}
}
}
.container .settings-icon {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.canvas {
display: block;
width: 100%;
height: 100%;
}
.preset-overlay {
position: absolute;
bottom: 0;
left: 0;
z-index: 10;
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
font-weight: 500;
color: var(--theme-colors-foreground);
pointer-events: none;
background-color: rgb(0 0 0 / 50%);
border-radius: 0 var(--theme-radius-md) 0 0;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.container:hover .preset-overlay {
opacity: 1;
}
@@ -0,0 +1,316 @@
import butterchurn from 'butterchurn';
import butterchurnPresets from 'butterchurn-presets';
import { createRef, useEffect, useRef, useState } from 'react';
import styles from './visualizer.module.css';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { openVisualizerSettingsModal } from '/@/renderer/features/player/utils/open-visualizer-settings-modal';
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
import { usePlayerStatus } from '/@/renderer/store/player.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Text } from '/@/shared/components/text/text';
import { PlayerStatus } from '/@/shared/types/types';
type ButterchurnVisualizer = {
connectAudio: (audioNode: AudioNode) => void;
loadPreset: (preset: any, blendTime: number) => void;
render: () => void;
setRendererSize: (width: number, height: number) => void;
};
const VisualizerInner = () => {
const { webAudio } = useWebAudio();
const canvasRef = createRef<HTMLCanvasElement>();
const containerRef = createRef<HTMLDivElement>();
const [visualizer, setVisualizer] = useState<ButterchurnVisualizer>();
const animationFrameRef = useRef<number | undefined>(undefined);
const resizeObserverRef = useRef<ResizeObserver | undefined>(undefined);
const cycleTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const cycleStartTimeRef = useRef<number | undefined>(undefined);
const butterchurnSettings = useSettingsStore((store) => store.visualizer.butterchurn);
const { setSettings } = useSettingsStoreActions();
const playerStatus = usePlayerStatus();
const isPlaying = playerStatus === PlayerStatus.PLAYING;
useEffect(() => {
const { context, gains } = webAudio || {};
if (
context &&
gains &&
canvasRef.current &&
containerRef.current &&
!visualizer &&
isPlaying
) {
const canvas = canvasRef.current;
const container = containerRef.current;
const getDimensions = () => {
const rect = container.getBoundingClientRect();
return {
height: rect.height || 600,
width: rect.width || 800,
};
};
let dimensions = getDimensions();
// If dimensions are 0, wait for next frame
if (dimensions.width === 0 || dimensions.height === 0) {
requestAnimationFrame(() => {
dimensions = getDimensions();
if (dimensions.width > 0 && dimensions.height > 0) {
initializeVisualizer(dimensions.width, dimensions.height);
}
});
} else {
initializeVisualizer(dimensions.width, dimensions.height);
}
function initializeVisualizer(width: number, height: number) {
if (!gains || gains.length === 0) return;
canvas.width = width;
canvas.height = height;
try {
const butterchurnInstance = butterchurn.createVisualizer(context, canvas, {
height,
width,
}) as ButterchurnVisualizer;
// Connect to audio gains (use the first gain node)
butterchurnInstance.connectAudio(gains[0]);
// Load preset from settings or default
const presets = butterchurnPresets.getPresets();
const presetNames = Object.keys(presets);
if (presetNames.length > 0) {
const presetName =
butterchurnSettings.currentPreset &&
presets[butterchurnSettings.currentPreset]
? butterchurnSettings.currentPreset
: presetNames[0];
const preset = presets[presetName];
butterchurnInstance.loadPreset(
preset,
butterchurnSettings.blendTime || 0.0,
);
// Initialize cycle timer
cycleStartTimeRef.current = Date.now();
}
setVisualizer(butterchurnInstance);
} catch (error) {
console.error('Failed to create butterchurn visualizer:', error);
}
}
}
return () => {};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [webAudio, canvasRef, containerRef, visualizer, isPlaying]);
// Handle resize
useEffect(() => {
const container = containerRef.current;
if (!container || !visualizer) return;
const handleResize = () => {
const rect = container.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
if (canvasRef.current) {
canvasRef.current.width = width;
canvasRef.current.height = height;
}
visualizer.setRendererSize(width, height);
};
resizeObserverRef.current = new ResizeObserver(handleResize);
resizeObserverRef.current.observe(container);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [visualizer, containerRef, canvasRef]);
// Update preset when currentPreset or blendTime changes (but not when cycling)
const isCyclingRef = useRef(false);
useEffect(() => {
if (!visualizer || !butterchurnSettings.currentPreset) return;
// Skip if we're currently cycling (to avoid recreating the visualizer)
if (isCyclingRef.current) {
isCyclingRef.current = false;
return;
}
const presets = butterchurnPresets.getPresets();
const preset = presets[butterchurnSettings.currentPreset];
if (preset) {
visualizer.loadPreset(preset, butterchurnSettings.blendTime || 0.0);
// Reset cycle timer when preset changes manually
cycleStartTimeRef.current = Date.now();
}
}, [visualizer, butterchurnSettings.currentPreset, butterchurnSettings.blendTime]);
// Handle preset cycling
useEffect(() => {
if (!visualizer || !butterchurnSettings.cyclePresets) {
// Clear cycle timer if cycling is disabled
if (cycleTimerRef.current) {
clearInterval(cycleTimerRef.current);
cycleTimerRef.current = undefined;
}
return;
}
const presets = butterchurnPresets.getPresets();
const allPresetNames = Object.keys(presets);
// Get the list of presets to cycle through
const presetList = butterchurnSettings.includeAllPresets
? allPresetNames
: butterchurnSettings.selectedPresets.length > 0
? butterchurnSettings.selectedPresets.filter((name) => presets[name])
: allPresetNames;
if (presetList.length === 0) return;
// Reset cycle timer when settings change
cycleStartTimeRef.current = Date.now();
const cycleToNextPreset = () => {
if (!visualizer) return;
const currentPresetName = butterchurnSettings.currentPreset;
let nextPresetName: string;
if (butterchurnSettings.randomizeNextPreset) {
// Randomly select a preset (excluding current if there are multiple)
const availablePresets =
presetList.length > 1
? presetList.filter((name) => name !== currentPresetName)
: presetList;
const randomIndex = Math.floor(Math.random() * availablePresets.length);
nextPresetName = availablePresets[randomIndex];
} else {
// Cycle to next preset in order
const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;
const nextIndex =
currentIndex >= 0 && currentIndex < presetList.length - 1
? currentIndex + 1
: 0;
nextPresetName = presetList[nextIndex];
}
const nextPreset = presets[nextPresetName];
if (nextPreset) {
// Get current settings to ensure we use the latest blendTime
const currentSettings = useSettingsStore.getState().visualizer.butterchurn;
// Mark that we're cycling to prevent the preset change effect from running
isCyclingRef.current = true;
// Load the preset with blending
visualizer.loadPreset(nextPreset, currentSettings.blendTime || 0.0);
// Update currentPreset in settings
const currentVisualizer = useSettingsStore.getState().visualizer;
setSettings({
visualizer: {
...currentVisualizer,
butterchurn: {
...currentVisualizer.butterchurn,
currentPreset: nextPresetName,
},
},
});
cycleStartTimeRef.current = Date.now();
}
};
// Check every second if it's time to cycle
cycleTimerRef.current = setInterval(() => {
if (cycleStartTimeRef.current === undefined) {
cycleStartTimeRef.current = Date.now();
return;
}
const elapsed = (Date.now() - cycleStartTimeRef.current) / 1000; // Convert to seconds
if (elapsed >= butterchurnSettings.cycleTime) {
cycleToNextPreset();
}
}, 1000);
return () => {
if (cycleTimerRef.current) {
clearInterval(cycleTimerRef.current);
cycleTimerRef.current = undefined;
}
};
}, [visualizer, butterchurnSettings, setSettings]);
useEffect(() => {
if (!visualizer) return;
let lastFrameTime = 0;
const maxFPS = butterchurnSettings.maxFPS;
const minFrameInterval = maxFPS > 0 ? 1000 / maxFPS : 0;
const render = (currentTime: number) => {
if (maxFPS === 0 || currentTime - lastFrameTime >= minFrameInterval) {
visualizer.render();
lastFrameTime = currentTime;
}
animationFrameRef.current = requestAnimationFrame(render);
};
animationFrameRef.current = requestAnimationFrame(render);
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [visualizer, butterchurnSettings.maxFPS]);
return (
<div className={styles.container} ref={containerRef}>
<ActionIcon
className={styles.settingsIcon}
icon="settings2"
iconProps={{ size: 'lg' }}
onClick={openVisualizerSettingsModal}
pos="absolute"
right={0}
top={0}
variant="transparent"
/>
<canvas className={styles.canvas} ref={canvasRef} />
{butterchurnSettings.currentPreset && (
<Text className={styles['preset-overlay']} isNoSelect size="sm">
{butterchurnSettings.currentPreset}
</Text>
)}
</div>
);
};
export const Visualizer = () => {
return (
<ComponentErrorBoundary>
<VisualizerInner />
</ComponentErrorBoundary>
);
};