diff --git a/.gitignore b/.gitignore index 21f677a..49b7fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* +.claude/ # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/src/app.tsx b/src/app.tsx index 64cda3c..9ced6a6 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -8,6 +8,8 @@ import { ColorResult } from "./protobuf/ColorResult"; import { ErrorData, ErrorHandlerContext, ErrorRecovery } from "./error"; import DebugVisualizer from "./components/renderer/DebugVisualizer"; import SpectrumVisualizer from "./components/renderer/SpectrumVisualizer"; +import CombinedVisualizer from "./components/renderer/CombinedVisualizer"; +import TimelineVisualizer from "./components/renderer/TimelineVisualizer"; import { MainMenuButton } from "./menu"; import { createVisualizerWindow } from "./window"; @@ -24,6 +26,16 @@ export type RendererDefinition = { }; const RENDERERS: RendererDefinition[] = [ + { + id: "combined", + name: "🌟 All-in-One", + renderer: CombinedVisualizer + }, + { + id: "timeline", + name: "Timeline", + renderer: TimelineVisualizer + }, { id: "ncs", name: "NCS", @@ -31,7 +43,7 @@ const RENDERERS: RendererDefinition[] = [ }, { id: "spectrum", - name: "Spectrum (very WIP)", + name: "Spectrum", renderer: SpectrumVisualizer }, { @@ -43,12 +55,12 @@ const RENDERERS: RendererDefinition[] = [ type VisualizerState = | { - state: "loading" | "running"; - } + state: "loading" | "running"; + } | { - state: "error"; - errorData: ErrorData; - }; + state: "error"; + errorData: ErrorData; + }; export default function App(props: { isSecondaryWindow?: boolean; initialRenderer?: string }) { const [rendererId, setRendererId] = useState(props.initialRenderer || "ncs"); @@ -188,12 +200,15 @@ export default function App(props: { isSecondaryWindow?: boolean; initialRendere { if (!createVisualizerWindow(rendererId)) { Spicetify.showNotification("Failed to open a new window", true); } }} - onSelectRenderer={id => setRendererId(id)} + onSelectRenderer={id => { + setRendererId(id); + }} /> )} diff --git a/src/components/renderer/CombinedVisualizer.tsx b/src/components/renderer/CombinedVisualizer.tsx new file mode 100644 index 0000000..cd25721 --- /dev/null +++ b/src/components/renderer/CombinedVisualizer.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import NCSVisualizer from './NCSVisualizer'; +import SpectrumVisualizer from './SpectrumVisualizer'; +import DebugVisualizer from './DebugVisualizer'; +import TimelineVisualizer from './TimelineVisualizer'; + +interface CombinedVisualizerProps { + themeColor: { + rgb: { r: number; g: number; b: number }; + }; + audioAnalysis: Spicetify.AudioAnalysis | null; + isEnabled: boolean; +} + +export default function CombinedVisualizer(props: CombinedVisualizerProps) { + return ( +
+ {/* Top Left: NCS Visualizer */} +
+ +
+ NCS +
+
+ + {/* Top Right: Spectrum Visualizer */} +
+ +
+ Spectrum +
+
+ + {/* Middle Left: Debug Visualizer */} +
+ +
+ Debug +
+
+ + {/* Bottom Row: Timeline Visualizer */} +
+ +
+ Timeline +
+
+ + {/* Audio Info Display */} +
+
+ Audio Info +
+
+ {props.audioAnalysis ? ( + <> +
Segments: {props.audioAnalysis.segments?.length || 0}
+
Bars: {props.audioAnalysis.bars?.length || 0}
+
Beats: {props.audioAnalysis.beats?.length || 0}
+
Tatums: {props.audioAnalysis.tatums?.length || 0}
+
+ Track Analysis Active +
+ + ) : ( +
No audio analysis available
+ )} +
+
+
+ ); +} diff --git a/src/components/renderer/NCSVisualizer.tsx b/src/components/renderer/NCSVisualizer.tsx index 022b35f..917d117 100644 --- a/src/components/renderer/NCSVisualizer.tsx +++ b/src/components/renderer/NCSVisualizer.tsx @@ -28,56 +28,56 @@ type CanvasData = { type RendererState = | { - isError: true; - } + isError: true; + } | { - isError: false; - particleShader: WebGLProgram; - dotShader: WebGLProgram; - blurShader: WebGLProgram; - finalizeShader: WebGLProgram; - viewportSize: number; - particleTextureSize: number; - - inPositionLoc: number; - inPositionLocDot: number; - inPositionLocBlur: number; - inPositionLocFinalize: number; - - uNoiseOffsetLoc: WebGLUniformLocation; - uAmplitudeLoc: WebGLUniformLocation; - uSeedLoc: WebGLUniformLocation; - uDotSpacingLoc: WebGLUniformLocation; - uDotOffsetLoc: WebGLUniformLocation; - uSphereRadiusLoc: WebGLUniformLocation; - uFeatherLoc: WebGLUniformLocation; - uNoiseFrequencyLoc: WebGLUniformLocation; - uNoiseAmplitudeLoc: WebGLUniformLocation; - - uDotCountLoc: WebGLUniformLocation; - uDotRadiusLoc: WebGLUniformLocation; - uDotRadiusPXLoc: WebGLUniformLocation; - uParticleTextureLoc: WebGLUniformLocation; - - uBlurRadiusLoc: WebGLUniformLocation; - uBlurDirectionLoc: WebGLUniformLocation; - uBlurInputTextureLoc: WebGLUniformLocation; - - uOutputColorLoc: WebGLUniformLocation; - uBlurredTextureLoc: WebGLUniformLocation; - uOriginalTextureLoc: WebGLUniformLocation; - - quadBuffer: WebGLBuffer; - - particleFramebuffer: WebGLFramebuffer; - particleTexture: WebGLTexture; - dotFramebuffer: WebGLFramebuffer; - dotTexture: WebGLTexture; - blurXFramebuffer: WebGLFramebuffer; - blurXTexture: WebGLTexture; - blurYFramebuffer: WebGLFramebuffer; - blurYTexture: WebGLTexture; - }; + isError: false; + particleShader: WebGLProgram; + dotShader: WebGLProgram; + blurShader: WebGLProgram; + finalizeShader: WebGLProgram; + viewportSize: number; + particleTextureSize: number; + + inPositionLoc: number; + inPositionLocDot: number; + inPositionLocBlur: number; + inPositionLocFinalize: number; + + uNoiseOffsetLoc: WebGLUniformLocation; + uAmplitudeLoc: WebGLUniformLocation; + uSeedLoc: WebGLUniformLocation; + uDotSpacingLoc: WebGLUniformLocation; + uDotOffsetLoc: WebGLUniformLocation; + uSphereRadiusLoc: WebGLUniformLocation; + uFeatherLoc: WebGLUniformLocation; + uNoiseFrequencyLoc: WebGLUniformLocation; + uNoiseAmplitudeLoc: WebGLUniformLocation; + + uDotCountLoc: WebGLUniformLocation; + uDotRadiusLoc: WebGLUniformLocation; + uDotRadiusPXLoc: WebGLUniformLocation; + uParticleTextureLoc: WebGLUniformLocation; + + uBlurRadiusLoc: WebGLUniformLocation; + uBlurDirectionLoc: WebGLUniformLocation; + uBlurInputTextureLoc: WebGLUniformLocation; + + uOutputColorLoc: WebGLUniformLocation; + uBlurredTextureLoc: WebGLUniformLocation; + uOriginalTextureLoc: WebGLUniformLocation; + + quadBuffer: WebGLBuffer; + + particleFramebuffer: WebGLFramebuffer; + particleTexture: WebGLTexture; + dotFramebuffer: WebGLFramebuffer; + dotTexture: WebGLTexture; + blurXFramebuffer: WebGLFramebuffer; + blurXTexture: WebGLTexture; + blurYFramebuffer: WebGLFramebuffer; + blurYTexture: WebGLTexture; + }; export default function NCSVisualizer(props: RendererProps) { const onError = useContext(ErrorHandlerContext); @@ -90,9 +90,9 @@ export default function NCSVisualizer(props: RendererProps) { const amplitudeCurve: CurveEntry[] = segments.flatMap(segment => segment.loudness_max_time ? [ - { x: segment.start, y: decibelsToAmplitude(segment.loudness_start) }, - { x: segment.start + segment.loudness_max_time, y: decibelsToAmplitude(segment.loudness_max) } - ] + { x: segment.start, y: decibelsToAmplitude(segment.loudness_start) }, + { x: segment.start + segment.loudness_max_time, y: decibelsToAmplitude(segment.loudness_max) } + ] : [{ x: segment.start, y: decibelsToAmplitude(segment.loudness_start) }] ); @@ -242,11 +242,11 @@ export default function NCSVisualizer(props: RendererProps) { gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); // prettier-ignore gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ - -1, -1, - -1, 1, - 1, 1, - 1, -1 - ]), gl.STATIC_DRAW); + -1, -1, + -1, 1, + 1, 1, + 1, -1 + ]), gl.STATIC_DRAW); gl.enable(gl.BLEND); gl.blendEquation(gl.MAX); diff --git a/src/components/renderer/SpectrumVisualizer.tsx b/src/components/renderer/SpectrumVisualizer.tsx index bc7c23c..1b8258c 100644 --- a/src/components/renderer/SpectrumVisualizer.tsx +++ b/src/components/renderer/SpectrumVisualizer.tsx @@ -166,18 +166,27 @@ export default function SpectrumVisualizer(props: RendererProps) { const spaceWidth = (ctx.canvas.width - barWidth * barCount) / (barCount + 1); for (let i = 0; i < barCount; i++) { - const value = sampleSegmentedFunction( + // Sample the value for this bar + let value = sampleSegmentedFunction( data.spectrumData[i], x => x.x, x => x.y, x => x, progress ); + + // Amplify the value to make bars taller (1.8x taller) + value = Math.min(value * 1.8, 1.0); + + // Calculate bar position and draw it + const barHeight = value * ctx.canvas.height; + const barY = ctx.canvas.height - barHeight; + ctx.fillRect( spaceWidth * (i + 1) + barWidth * i, - ctx.canvas.height - value * ctx.canvas.height, + barY, barWidth, - value * ctx.canvas.height + barHeight ); } }, []); diff --git a/src/components/renderer/TimelineVisualizer.tsx b/src/components/renderer/TimelineVisualizer.tsx new file mode 100644 index 0000000..1d3e701 --- /dev/null +++ b/src/components/renderer/TimelineVisualizer.tsx @@ -0,0 +1,432 @@ +import React, { useCallback, useContext, useMemo } from "react"; +import AnimatedCanvas from "../AnimatedCanvas"; +import { + sampleAmplitudeMovingAverage, + decibelsToAmplitude, + mapLinear +} from "../../utils"; +import { ErrorHandlerContext } from "../../error"; +import { RendererProps } from "../../app"; + +type CanvasData = { + themeColor: Spicetify.Color; + seed: number; + amplitudeCurve: CurveEntry[]; +}; + +type RendererState = + | { + isError: true; + } + | { + isError: false; + time: number; + barVisuals: BarVisual[]; + beatVisuals: BeatVisual[]; + sectionVisuals: SectionVisual[]; + pitchHistory: number[][]; + activeSection: number; + activeSectionStartTime: number; + activeSectionProgress: number; + }; + +interface BarVisual { + start: number; + duration: number; + confidence: number; + active: boolean; + color: string; +} + +interface BeatVisual { + start: number; + duration: number; + confidence: number; + active: boolean; + pulse: number; +} + +interface SectionVisual { + start: number; + duration: number; + tempo: number; + loudness: number; + key: number; + mode: number; + color: string; + active: boolean; +} + +export default function TimelineVisualizer(props: RendererProps) { + const onError = useContext(ErrorHandlerContext); + + const amplitudeCurve = useMemo(() => { + if (!props.audioAnalysis) return [{ x: 0, y: 0 }]; + + const segments = props.audioAnalysis.segments; + const amplitudeCurve: CurveEntry[] = segments.flatMap(segment => + segment.loudness_max_time + ? [ + { x: segment.start, y: decibelsToAmplitude(segment.loudness_start) }, + { x: segment.start + segment.loudness_max_time, y: decibelsToAmplitude(segment.loudness_max) } + ] + : [{ x: segment.start, y: decibelsToAmplitude(segment.loudness_start) }] + ); + + return amplitudeCurve; + }, [props.audioAnalysis]); + + // Create a palette of colors from the theme color + const createColorPalette = useCallback((baseColor: Spicetify.Color) => { + const [h, s, l] = rgbToHsl(baseColor.rgb.r, baseColor.rgb.g, baseColor.rgb.b); + + // Create variations for different elements + return { + primary: `hsla(${h * 360}, ${Math.min(100, s * 100 + 20)}%, ${Math.min(100, l * 100 + 10)}%, 0.8)`, + secondary: `hsla(${((h + 0.05) % 1) * 360}, ${Math.min(100, s * 100 + 10)}%, ${Math.min(100, l * 100 + 5)}%, 0.6)`, + accent: `hsla(${((h + 0.5) % 1) * 360}, ${Math.min(100, s * 100 + 20)}%, ${Math.min(100, l * 100 + 10)}%, 0.7)`, + background: `hsla(${h * 360}, ${Math.min(100, s * 100 - 10)}%, ${Math.min(100, l * 100 - 10)}%, 0.9)`, + timeline: `hsla(${h * 360}, ${Math.min(100, s * 100 + 10)}%, ${Math.min(100, l * 100 + 20)}%, 0.6)`, + barColors: Array(12).fill(0).map((_, i) => + `hsla(${((h + i / 12) % 1) * 360}, ${Math.min(100, s * 100 + 10)}%, ${Math.min(100, l * 100 + 10)}%, 0.8)` + ) + }; + }, []); + + // Utility function to generate colors for sections based on their musical key + const getSectionColor = useCallback((section: Spicetify.AudioAnalysis.Section, palette: any) => { + // Use the musical key to select a color (in the circle of fifths) + const keyIndex = section.key >= 0 ? section.key : 0; + const colorIndex = keyIndex % palette.barColors.length; + // Adjust opacity based on confidence + const opacity = 0.3 + section.key_confidence * 0.7; + const baseColor = palette.barColors[colorIndex]; + return baseColor.replace(/[\d.]+\)$/, `${opacity})`); + }, []); + + const seed = props.audioAnalysis?.meta.timestamp ?? 0; + + const onInit = useCallback((ctx: CanvasRenderingContext2D | null): RendererState => { + if (!ctx) return { isError: true }; + + const barVisuals: BarVisual[] = []; + const beatVisuals: BeatVisual[] = []; + const sectionVisuals: SectionVisual[] = []; + const pitchHistory: number[][] = []; + + if (props.audioAnalysis) { + const palette = createColorPalette(props.themeColor); + + // Initialize bars + props.audioAnalysis.bars.forEach(bar => { + barVisuals.push({ + start: bar.start, + duration: bar.duration, + confidence: bar.confidence, + active: false, + color: palette.secondary + }); + }); + + // Initialize beats + props.audioAnalysis.beats.forEach(beat => { + beatVisuals.push({ + start: beat.start, + duration: beat.duration, + confidence: beat.confidence, + active: false, + pulse: 0 + }); + }); + + // Initialize sections + props.audioAnalysis.sections.forEach(section => { + sectionVisuals.push({ + start: section.start, + duration: section.duration, + tempo: section.tempo, + loudness: section.loudness, + key: section.key, + mode: section.mode, + color: getSectionColor(section, palette), + active: false + }); + }); + + // Initialize pitch history with empty arrays + for (let i = 0; i < 50; i++) { + pitchHistory.push(Array(12).fill(0)); + } + } + + return { + isError: false, + time: 0, + barVisuals, + beatVisuals, + sectionVisuals, + pitchHistory, + activeSection: 0, + activeSectionStartTime: 0, + activeSectionProgress: 0 + }; + }, [props.audioAnalysis, props.themeColor, createColorPalette, getSectionColor]); + + const onResize = useCallback((ctx: CanvasRenderingContext2D | null, state: RendererState) => { + // Resize logic if needed + }, []); + + const onRender = useCallback((ctx: CanvasRenderingContext2D | null, data: CanvasData, state: RendererState) => { + if (state.isError || !ctx) return; + + const progress = Spicetify.Player.getProgress() / 1000; + const songPosition = progress; + const newTime = state.time + 0.016; + const colors = createColorPalette(data.themeColor); + + // Clear canvas with transparent background + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + if (!props.audioAnalysis) return; + + const canvasWidth = ctx.canvas.width; + const canvasHeight = ctx.canvas.height; + + const totalDuration = props.audioAnalysis.track.duration; + + // Find current segment, section, bar and beat + const currentSegmentIndex = props.audioAnalysis.segments.findIndex( + (segment, i, segments) => { + const nextSegment = segments[i + 1]; + return segment.start <= songPosition && + (!nextSegment || nextSegment.start > songPosition); + } + ); + + const currentSectionIndex = props.audioAnalysis.sections.findIndex( + (section, i, sections) => { + const nextSection = sections[i + 1]; + return section.start <= songPosition && + (!nextSection || nextSection.start > songPosition); + } + ); + + const currentSection = props.audioAnalysis.sections[currentSectionIndex]; + + // Check if we've changed sections + if (currentSectionIndex !== state.activeSection) { + state.activeSection = currentSectionIndex; + state.activeSectionStartTime = currentSection.start; + } + + // Calculate section progress + state.activeSectionProgress = (songPosition - state.activeSectionStartTime) / currentSection.duration; + + // Update active states for bars, beats, and sections + state.barVisuals.forEach((barVisual, i) => { + const nextBar = state.barVisuals[i + 1]; + barVisual.active = barVisual.start <= songPosition && + (!nextBar || nextBar.start > songPosition); + }); + + state.beatVisuals.forEach((beatVisual, i) => { + const nextBeat = state.beatVisuals[i + 1]; + const wasActive = beatVisual.active; + beatVisual.active = beatVisual.start <= songPosition && + (!nextBeat || nextBeat.start > songPosition); + + // Reset pulse when a new beat becomes active + if (!wasActive && beatVisual.active) { + beatVisual.pulse = 1.0; + } else if (beatVisual.active) { + // Decrease pulse over time + beatVisual.pulse = Math.max(0, beatVisual.pulse - 0.05); + } else { + beatVisual.pulse = 0; + } + }); + + state.sectionVisuals.forEach((sectionVisual, i) => { + const nextSection = state.sectionVisuals[i + 1]; + sectionVisual.active = sectionVisual.start <= songPosition && + (!nextSection || nextSection.start > songPosition); + }); + + // Update pitch history if we have a current segment + if (currentSegmentIndex >= 0) { + const currentSegment = props.audioAnalysis.segments[currentSegmentIndex]; + // Shift pitch history + state.pitchHistory.shift(); + // Add new pitch data + state.pitchHistory.push([...currentSegment.pitches]); + } + + // Draw timeline background + const timelineHeight = 60; + const timelineY = canvasHeight - timelineHeight - 20; + + ctx.fillStyle = "rgba(20, 20, 30, 0.7)"; + ctx.fillRect(10, timelineY, canvasWidth - 20, timelineHeight); + + // Draw sections on timeline + state.sectionVisuals.forEach(section => { + const x = 10 + (section.start / totalDuration) * (canvasWidth - 20); + const width = (section.duration / totalDuration) * (canvasWidth - 20); + + // Draw section background + ctx.fillStyle = section.color; + ctx.fillRect(x, timelineY, width, timelineHeight); + + // Draw section border if active + if (section.active) { + ctx.strokeStyle = colors.primary; + ctx.lineWidth = 2; + ctx.strokeRect(x, timelineY, width, timelineHeight); + } + }); + + // Draw current position on timeline + const positionX = 10 + (songPosition / totalDuration) * (canvasWidth - 20); + ctx.fillStyle = "rgba(255, 255, 255, 0.8)"; + ctx.fillRect(positionX - 2, timelineY - 5, 4, timelineHeight + 10); + + // Draw pitch visualization based on history + const pitchHeight = timelineY - 40; + const pitchWidth = canvasWidth - 40; + const cellWidth = pitchWidth / state.pitchHistory.length; + const pitchCellHeight = pitchHeight / 12; + + // Draw pitch cells + state.pitchHistory.forEach((pitchArray, timeIndex) => { + pitchArray.forEach((pitch, pitchIndex) => { + const x = 20 + timeIndex * cellWidth; + const y = 20 + (11 - pitchIndex) * pitchCellHeight; + + // Invert pitchIndex so C is at the bottom + const colorIndex = (11 - pitchIndex) % colors.barColors.length; + ctx.fillStyle = colors.barColors[colorIndex].replace(/[\d.]+\)$/, `${pitch * 0.8})`); + + ctx.fillRect(x, y, cellWidth, pitchCellHeight); + }); + }); + + // Draw beat pulses + const activeBeat = state.beatVisuals.find(beat => beat.active); + if (activeBeat && activeBeat.pulse > 0) { + const centerX = canvasWidth / 2; + const centerY = (pitchHeight + 20 + timelineY) / 2; + const maxRadius = Math.min(centerX, centerY) * 0.6; + const radius = activeBeat.pulse * maxRadius; + + // Draw pulse circle + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); + ctx.fillStyle = colors.accent.replace(/[\d.]+\)$/, `${(1 - activeBeat.pulse) * 0.3})`); + ctx.fill(); + + // Draw outer ring + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); + ctx.strokeStyle = colors.accent.replace(/[\d.]+\)$/, `${(1 - activeBeat.pulse) * 0.8})`); + ctx.lineWidth = 2; + ctx.stroke(); + } + + // Draw current bar indicator + const activeBar = state.barVisuals.find(bar => bar.active); + if (activeBar) { + const barProgress = (songPosition - activeBar.start) / activeBar.duration; + const barHeight = 20; + const barY = timelineY - barHeight - 10; + + // Draw bar background + ctx.fillStyle = "rgba(40, 40, 60, 0.6)"; + ctx.fillRect(10, barY, canvasWidth - 20, barHeight); + + // Draw bar progress + ctx.fillStyle = colors.primary; + ctx.fillRect(10, barY, (canvasWidth - 20) * barProgress, barHeight); + + // Draw confidence indicator + const confidenceWidth = 5; + ctx.fillStyle = `rgba(255, 255, 255, ${activeBar.confidence})`; + ctx.fillRect( + 10 + (canvasWidth - 20) * barProgress - confidenceWidth / 2, + barY - 5, + confidenceWidth, + barHeight + 10 + ); + } + + // Draw current section info + if (currentSection) { + const textY = timelineY + timelineHeight + 15; + ctx.font = "14px Arial"; + ctx.fillStyle = "rgba(255, 255, 255, 0.9)"; + + // Get key name + const keyNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + const keyName = currentSection.key >= 0 ? keyNames[currentSection.key] : "Unknown"; + const modeName = currentSection.mode === 1 ? "Major" : "Minor"; + + // Format tempo + const tempo = Math.round(currentSection.tempo); + + ctx.fillText( + `Section ${currentSectionIndex + 1}/${state.sectionVisuals.length} | Key: ${keyName} ${modeName} | Tempo: ${tempo} BPM`, + 20, + textY + ); + } + + // Update state + state.time = newTime; + }, [props.audioAnalysis, createColorPalette]); + + return ( + + ); +} + +// Utility function to convert RGB to HSL +function rgbToHsl(r: number, g: number, b: number): [number, number, number] { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const diff = max - min; + + let h = 0; + let s = 0; + let l = (max + min) / 2; + + if (diff !== 0) { + s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min); + + if (max === r) h = ((g - b) / diff + (g < b ? 6 : 0)) / 6; + else if (max === g) h = ((b - r) / diff + 2) / 6; + else h = ((r - g) / diff + 4) / 6; + } + + return [h, s, l]; +} + +// Interface for curve entries +interface CurveEntry { + x: number; + y: number; + accumulatedIntegral?: number; +} \ No newline at end of file diff --git a/src/components/renderer/WaveformVisualizer.tsx b/src/components/renderer/WaveformVisualizer.tsx new file mode 100644 index 0000000..7219c98 --- /dev/null +++ b/src/components/renderer/WaveformVisualizer.tsx @@ -0,0 +1,334 @@ +import React, { useCallback, useContext, useMemo } from "react"; +import AnimatedCanvas from "../AnimatedCanvas"; +import { + sampleAmplitudeMovingAverage, + decibelsToAmplitude, + mapLinear, + integrateLinearSegment, + sampleAccumulatedIntegral +} from "../../utils"; +import { ErrorHandlerContext, ErrorRecovery } from "../../error"; +import { RendererProps } from "../../app"; + +type CanvasData = { + themeColor: Spicetify.Color; + seed: number; + amplitudeCurve: CurveEntry[]; +}; + +type RendererState = + | { + isError: true; + } + | { + isError: false; + wavePoints: WavePoint[]; + bars: Bar[]; + time: number; + energy: number; + rotationSpeed: number; + }; + +interface WavePoint { + angle: number; + radius: number; + baseRadius: number; + frequency: number; + amplitude: number; + phase: number; +} + +interface Bar { + angle: number; + value: number; + targetValue: number; + color: string; + width: number; +} + +export default function WaveformVisualizer(props: RendererProps) { + const onError = useContext(ErrorHandlerContext); + + const amplitudeCurve = useMemo(() => { + if (!props.audioAnalysis) return [{ x: 0, y: 0 }]; + + const segments = props.audioAnalysis.segments; + const amplitudeCurve: CurveEntry[] = segments.flatMap(segment => + segment.loudness_max_time + ? [ + { x: segment.start, y: decibelsToAmplitude(segment.loudness_start) }, + { x: segment.start + segment.loudness_max_time, y: decibelsToAmplitude(segment.loudness_max) } + ] + : [{ x: segment.start, y: decibelsToAmplitude(segment.loudness_start) }] + ); + + // Add the accumulated integral for smooth animations + if (segments.length) { + amplitudeCurve[0].accumulatedIntegral = 0; + for (let i = 1; i < amplitudeCurve.length; i++) { + const prev = amplitudeCurve[i - 1]; + const curr = amplitudeCurve[i]; + curr.accumulatedIntegral = (prev.accumulatedIntegral ?? 0) + integrateLinearSegment(prev, curr); + } + + const lastSegment = segments[segments.length - 1]; + amplitudeCurve.push({ + x: lastSegment.start + lastSegment.duration, + y: decibelsToAmplitude(lastSegment.loudness_end) + }); + } + + return amplitudeCurve; + }, [props.audioAnalysis]); + + // Create a palette of colors from the theme color + const createColorPalette = useCallback((baseColor: Spicetify.Color) => { + const [h, s, v] = rgbToHsv(baseColor.rgb.r, baseColor.rgb.g, baseColor.rgb.b); + + // Create a few variations for different elements + return { + primary: `hsla(${h * 360}, ${Math.min(100, s * 100 + 20)}%, ${Math.min(100, v * 100 + 10)}%, 0.8)`, + secondary: `hsla(${((h + 0.05) % 1) * 360}, ${Math.min(100, s * 100 + 10)}%, ${Math.min(100, v * 100 + 5)}%, 0.6)`, + accent: `hsla(${((h + 0.5) % 1) * 360}, ${Math.min(100, s * 100 + 20)}%, ${Math.min(100, v * 100 + 10)}%, 0.7)`, + glow: `hsla(${h * 360}, ${Math.min(100, s * 100 + 20)}%, ${Math.min(100, v * 100 + 20)}%, 0.5)`, + barColors: [ + `hsla(${((h + 0.05) % 1) * 360}, ${Math.min(100, s * 100 + 20)}%, ${Math.min(100, v * 100 + 20)}%, 0.8)`, + `hsla(${((h + 0.1) % 1) * 360}, ${Math.min(100, s * 100 + 15)}%, ${Math.min(100, v * 100 + 15)}%, 0.8)`, + `hsla(${((h + 0.2) % 1) * 360}, ${Math.min(100, s * 100 + 10)}%, ${Math.min(100, v * 100 + 10)}%, 0.8)`, + `hsla(${((h + 0.3) % 1) * 360}, ${Math.min(100, s * 100 + 5)}%, ${Math.min(100, v * 100 + 5)}%, 0.8)`, + `hsla(${((h + 0.4) % 1) * 360}, ${Math.min(100, s * 100)}%, ${Math.min(100, v * 100)}%, 0.8)` + ] + }; + }, []); + + const seed = props.audioAnalysis?.meta.timestamp ?? 0; + + const onInit = useCallback((ctx: CanvasRenderingContext2D | null): RendererState => { + if (!ctx) return { isError: true }; + + const wavePoints: WavePoint[] = []; + const numWavePoints = 200; + const baseRadius = Math.min(ctx.canvas.width, ctx.canvas.height) * 0.3; + + // Create wave points in a circle + for (let i = 0; i < numWavePoints; i++) { + const angle = (i / numWavePoints) * Math.PI * 2; + wavePoints.push({ + angle, + radius: baseRadius, + baseRadius: baseRadius, + frequency: 0.05 + Math.random() * 0.05, + amplitude: 20 + Math.random() * 10, + phase: Math.random() * Math.PI * 2 + }); + } + + // Create bars for the spectrum visualization + const bars: Bar[] = []; + const numBars = 72; // A nice number divisible by 2, 3, 4, etc. + const colorPalette = createColorPalette(props.themeColor); + + for (let i = 0; i < numBars; i++) { + const angle = (i / numBars) * Math.PI * 2; + bars.push({ + angle, + value: 0, + targetValue: 0, + color: colorPalette.barColors[Math.floor((i / numBars) * colorPalette.barColors.length)], + width: Math.PI * 2 / numBars * 0.8 // 80% of the available space to leave gaps + }); + } + + return { + isError: false, + wavePoints, + bars, + time: 0, + energy: 0, + rotationSpeed: 0.003 + }; + }, [props.themeColor, createColorPalette]); + + const onResize = useCallback((ctx: CanvasRenderingContext2D | null, state: RendererState) => { + if (state.isError || !ctx) return; + + const baseRadius = Math.min(ctx.canvas.width, ctx.canvas.height) * 0.3; + + // Update base radius for all wave points + state.wavePoints.forEach(point => { + point.baseRadius = baseRadius; + }); + }, []); + + const onRender = useCallback((ctx: CanvasRenderingContext2D | null, data: CanvasData, state: RendererState) => { + if (state.isError || !ctx) return; + + const progress = Spicetify.Player.getProgress() / 1000; + const amplitude = sampleAmplitudeMovingAverage(data.amplitudeCurve, progress, 0.15); + const newTime = state.time + 0.016; + + // Boost amplitude sensitivity for better visual response + const boostedAmplitude = Math.min(1, amplitude * 2.5 + 0.15); + + // Update energy based on boosted amplitude (with smoothing) + const newEnergy = boostedAmplitude; // Directly use boosted amplitude for better response + + // Update rotation speed based on energy + const targetRotationSpeed = 0.003 + newEnergy * 0.02; + const newRotationSpeed = state.rotationSpeed + (targetRotationSpeed - state.rotationSpeed) * 0.1; + + const centerX = ctx.canvas.width / 2; + const centerY = ctx.canvas.height / 2; + const colors = createColorPalette(data.themeColor); + + // Clear canvas with gradient background + const bgGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, Math.max(centerX, centerY)); + bgGradient.addColorStop(0, "rgba(0, 5, 20, 1)"); + bgGradient.addColorStop(1, "rgba(0, 0, 10, 1)"); + ctx.fillStyle = bgGradient; + ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + // Draw a center glow based on amplitude + if (newEnergy > 0.2) { + const glowRadius = 100 + newEnergy * 100; + const glowGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, glowRadius); + glowGradient.addColorStop(0, colors.glow); + glowGradient.addColorStop(1, "rgba(0, 0, 0, 0)"); + ctx.fillStyle = glowGradient; + ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); + } + + // Update and draw bars (spectrum analyzer style) with stronger audio response + for (let i = 0; i < state.bars.length; i++) { + const bar = state.bars[i]; + + // Generate values more directly tied to the audio amplitude + const seed = (i / state.bars.length) * 10 + newTime * 2; + const noise = Math.sin(seed) * 0.5 + Math.sin(seed * 0.7) * 0.3 + Math.sin(seed * 1.7) * 0.2; + + // Make bars much more responsive to audio + bar.targetValue = (0.2 + noise * 0.2 + amplitude * 1.2) * Math.min(ctx.canvas.width, ctx.canvas.height) * 0.3; + + // Faster transition for more immediate response + bar.value += (bar.targetValue - bar.value) * 0.2; + + // Calculate rotation based on time + const rotatedAngle = bar.angle + newTime * newRotationSpeed; + + // Draw bar + ctx.save(); + ctx.translate(centerX, centerY); + ctx.rotate(rotatedAngle); + + const barWidth = bar.width; + const innerRadius = state.wavePoints[0].baseRadius * 1.2; // Outside the waveform + const outerRadius = innerRadius + bar.value; + + // Draw bar as a circular sector + ctx.beginPath(); + ctx.arc(0, 0, outerRadius, -barWidth/2, barWidth/2); + ctx.arc(0, 0, innerRadius, barWidth/2, -barWidth/2, true); + ctx.closePath(); + + // Fill with gradient + const barGradient = ctx.createLinearGradient(0, 0, outerRadius, 0); + barGradient.addColorStop(0, colors.accent); + barGradient.addColorStop(1, bar.color); + ctx.fillStyle = barGradient; + ctx.fill(); + + ctx.restore(); + } + + // Update wave points with more pronounced audio response + if (state.wavePoints && state.wavePoints.length > 0) { + state.wavePoints.forEach((wavePoint, index) => { + // Calculate dynamic radius based on time, amplitude and angle + const timeOffset = newTime * wavePoint.frequency; + const waveFactor = Math.sin(timeOffset + wavePoint.phase + wavePoint.angle * 3); + const energyEffect = waveFactor * wavePoint.amplitude * (1 + newEnergy * 6); // Amplify effect + wavePoint.radius = wavePoint.baseRadius + energyEffect; + }); + } + + // Draw simple circular waveform (base circle) + ctx.save(); + ctx.translate(centerX, centerY); + + // Calculate average radius for a smooth circle that responds to audio + let avgRadius = 0; + if (state.wavePoints && state.wavePoints.length > 0) { + avgRadius = state.wavePoints.reduce((sum, point) => sum + point.radius, 0) / state.wavePoints.length; + } + + // Draw the base circle + ctx.beginPath(); + ctx.arc(0, 0, avgRadius, 0, Math.PI * 2); + + // Create gradient fill + const gradient = ctx.createRadialGradient(0, 0, avgRadius * 0.3, 0, 0, avgRadius * 1.2); + gradient.addColorStop(0, colors.secondary); + gradient.addColorStop(1, colors.primary); + + // Apply styles and draw + ctx.fillStyle = gradient; + ctx.globalAlpha = 0.7; + ctx.fill(); + + // Draw glow outline + ctx.shadowBlur = 15 + newEnergy * 20; + ctx.shadowColor = colors.glow; + ctx.strokeStyle = colors.accent; + ctx.lineWidth = 2 + newEnergy * 3; + ctx.stroke(); + + ctx.globalAlpha = 1; + ctx.shadowBlur = 0; + ctx.restore(); + + // Update state + state.time = newTime; + state.energy = newEnergy; + state.rotationSpeed = newRotationSpeed; + }, [createColorPalette]); + + return ( + + ); +} + +// Utility function to convert RGB to HSV +function rgbToHsv(r: number, g: number, b: number): [number, number, number] { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const diff = max - min; + + let h = 0; + if (diff !== 0) { + if (max === r) h = ((g - b) / diff) % 6; + else if (max === g) h = (b - r) / diff + 2; + else h = (r - g) / diff + 4; + } + h = h * 60; + if (h < 0) h += 360; + + const s = max === 0 ? 0 : diff / max; + const v = max; + + return [h / 360, s, v]; +} diff --git a/src/menu.tsx b/src/menu.tsx index 147012e..37eb25b 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -9,13 +9,18 @@ const SpotifyIcon = React.memo((props: { name: Spicetify.Icon; size: number }) = /> )); -type MainMenuProps = { renderers: RendererDefinition[]; onSelectRenderer: (id: string) => void; onOpenWindow: () => void }; +type MainMenuProps = { + renderers: RendererDefinition[]; + currentRendererId?: string; + onSelectRenderer: (id: string) => void; + onOpenWindow: () => void; +}; const MainMenu = React.memo((props: MainMenuProps) => ( {props.renderers.map(v => ( - props.onSelectRenderer(v.id)}> + props.onSelectRenderer(v.id)}> {v.name} ))} @@ -30,13 +35,134 @@ const MainMenu = React.memo((props: MainMenuProps) => ( )); export const MainMenuButton = React.memo((props: MainMenuProps & { className: string }) => { + const [isOpen, setIsOpen] = React.useState(false); + const menuRef = React.useRef(null); + + const handleClick = React.useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen]); + + const handleMenuItemClick = React.useCallback((id: string) => { + props.onSelectRenderer(id); + setIsOpen(false); + }, [props.onSelectRenderer]); + + // Close menu when clicking outside + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + return ( - }> +
} - > - + /> + {isOpen && ( +
+
+ Visualization Mode +
+ {props.renderers.map(renderer => { + const isSelected = renderer.id === props.currentRendererId; + return ( +
handleMenuItemClick(renderer.id)} + style={{ + padding: "8px 16px", + cursor: "pointer", + fontSize: "14px", + color: isSelected ? "var(--spice-accent)" : "var(--spice-text)", + borderBottom: renderer.id !== props.renderers[props.renderers.length - 1].id ? "1px solid var(--spice-border)" : "none", + backgroundColor: isSelected ? "var(--spice-hover)" : "transparent", + fontWeight: isSelected ? "bold" : "normal", + display: "flex", + alignItems: "center", + justifyContent: "space-between" + }} + onMouseEnter={(e) => { + if (!isSelected) { + e.currentTarget.style.backgroundColor = "var(--spice-hover)"; + } + }} + onMouseLeave={(e) => { + if (!isSelected) { + e.currentTarget.style.backgroundColor = "transparent"; + } + }} + > + {renderer.name} + {isSelected && ( + ✓ + )} +
+ ); + })} +
+
{ + props.onOpenWindow(); + setIsOpen(false); + }} + style={{ + padding: "8px 16px", + cursor: "pointer", + fontSize: "14px", + color: "var(--spice-text)", + display: "flex", + alignItems: "center", + justifyContent: "space-between" + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = "var(--spice-hover)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = "transparent"; + }} + > + Open Window + +
+
+
+ )} +
); }); diff --git a/src/shaders/ncs-visualizer/particle.ts b/src/shaders/ncs-visualizer/particle.ts index dcccdb8..5ffd2bf 100644 --- a/src/shaders/ncs-visualizer/particle.ts +++ b/src/shaders/ncs-visualizer/particle.ts @@ -97,12 +97,31 @@ float fractalNoise(vec3 coord) { } void main() { + // Primary noise layer float noise = fractalNoise(vec3(fragUV * uNoiseFrequency, uNoiseOffset)) * uNoiseAmplitude; - vec3 dotCenter = vec3(fragUV * uDotSpacing + uDotOffset + noise, (noise + 0.5 * uNoiseAmplitude) * uAmplitude * 0.4); + + // Add secondary noise layers for more complex movement + float secondaryNoise = fractalNoise(vec3(fragUV * uNoiseFrequency * 2.0, uNoiseOffset * 0.7)) * uNoiseAmplitude * 0.3; + float tertiaryNoise = fractalNoise(vec3(fragUV * uNoiseFrequency * 0.5, uNoiseOffset * 1.3)) * uNoiseAmplitude * 0.6; + + // Combine noise layers with amplitude-based weighting + float combinedNoise = noise + secondaryNoise * uAmplitude + tertiaryNoise * (1.0 - uAmplitude); + + // Add rotational motion based on time and amplitude + float angle = uNoiseOffset * 0.5 + length(fragUV - 0.5) * 3.14159; + vec2 rotation = vec2(cos(angle), sin(angle)) * uAmplitude * 0.1; + + vec3 dotCenter = vec3(fragUV * uDotSpacing + uDotOffset + combinedNoise + rotation, + (combinedNoise + 0.5 * uNoiseAmplitude) * uAmplitude * 0.4); float distanceFromCenter = length(dotCenter); dotCenter /= distanceFromCenter; distanceFromCenter = min(uSphereRadius, distanceFromCenter); + + // Add pulsing effect based on amplitude + float pulseEffect = 1.0 + sin(uNoiseOffset * 4.0) * uAmplitude * 0.15; + distanceFromCenter *= pulseEffect; + dotCenter *= distanceFromCenter; float featherRadius = uSphereRadius - uFeather; diff --git a/src/window.tsx b/src/window.tsx index 2d3eade..9542016 100644 --- a/src/window.tsx +++ b/src/window.tsx @@ -23,6 +23,10 @@ export function createVisualizerWindow(rendererId: string) { win.document.documentElement.className = document.documentElement.className; win.document.body.className = document.body.className; + // Make background transparent + win.document.body.style.background = 'transparent'; + win.document.documentElement.style.background = 'transparent'; + Spicetify.ReactDOM.render(, win.document.body); return true;