From 0786e6bf49083e08de9c21285a3c7f16f9b9d9bd Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 22 Mar 2026 18:30:33 +0100 Subject: [PATCH] feat(mukke): add real-time frequency bars visualizer Add Web Audio API AnalyserNode integration and a Canvas 2D-based frequency bars component. The visualizer connects to the player's Audio element and renders frequency data in real-time using requestAnimationFrame. Integrated into FullPlayer (mirrored, 48 bars) and MiniPlayer (subtle 64-bar overlay behind progress bar). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/components/FullPlayer.svelte | 6 + .../web/src/lib/components/MiniPlayer.svelte | 12 +- .../apps/web/src/lib/stores/player.svelte.ts | 4 + .../src/lib/visualizer/FrequencyBars.svelte | 169 ++++++++++++++++++ .../apps/web/src/lib/visualizer/analyzer.ts | 96 ++++++++++ 5 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 apps/mukke/apps/web/src/lib/visualizer/FrequencyBars.svelte create mode 100644 apps/mukke/apps/web/src/lib/visualizer/analyzer.ts diff --git a/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte b/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte index d783aef0c..35e543fff 100644 --- a/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte +++ b/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte @@ -1,5 +1,6 @@ + + diff --git a/apps/mukke/apps/web/src/lib/visualizer/analyzer.ts b/apps/mukke/apps/web/src/lib/visualizer/analyzer.ts new file mode 100644 index 000000000..a0360bb5b --- /dev/null +++ b/apps/mukke/apps/web/src/lib/visualizer/analyzer.ts @@ -0,0 +1,96 @@ +/** + * Audio Analyzer - connects Web Audio API AnalyserNode to the player's Audio element. + * + * Singleton: one AudioContext and AnalyserNode shared by all visualizer components. + * The MediaElementSource can only be created once per Audio element, so we cache it. + */ + +let audioContext: AudioContext | null = null; +let analyserNode: AnalyserNode | null = null; +let sourceNode: MediaElementAudioSourceNode | null = null; +let connectedElement: HTMLAudioElement | null = null; + +export interface AnalyzerConfig { + fftSize?: number; + smoothingTimeConstant?: number; + minDecibels?: number; + maxDecibels?: number; +} + +const DEFAULT_CONFIG: Required = { + fftSize: 256, + smoothingTimeConstant: 0.8, + minDecibels: -90, + maxDecibels: -10, +}; + +/** + * Connect the analyzer to an HTMLAudioElement. + * Safe to call multiple times — only creates nodes once per element. + */ +export function connectAnalyzer( + audio: HTMLAudioElement, + config: AnalyzerConfig = {} +): AnalyserNode { + const cfg = { ...DEFAULT_CONFIG, ...config }; + + if (!audioContext) { + audioContext = new AudioContext(); + } + + if (!analyserNode) { + analyserNode = audioContext.createAnalyser(); + } + + analyserNode.fftSize = cfg.fftSize; + analyserNode.smoothingTimeConstant = cfg.smoothingTimeConstant; + analyserNode.minDecibels = cfg.minDecibels; + analyserNode.maxDecibels = cfg.maxDecibels; + + // Only create a source node once per audio element + if (connectedElement !== audio) { + if (sourceNode) { + sourceNode.disconnect(); + } + sourceNode = audioContext.createMediaElementSource(audio); + sourceNode.connect(analyserNode); + analyserNode.connect(audioContext.destination); + connectedElement = audio; + } + + return analyserNode; +} + +/** + * Get the current AnalyserNode (null if not yet connected). + */ +export function getAnalyzer(): AnalyserNode | null { + return analyserNode; +} + +/** + * Resume the AudioContext (required after user gesture on some browsers). + */ +export async function resumeAudioContext(): Promise { + if (audioContext?.state === 'suspended') { + await audioContext.resume(); + } +} + +/** + * Get frequency data as a Uint8Array (0-255 per bin). + * Returns null if analyzer is not connected. + */ +export function getFrequencyData(): Uint8Array | null { + if (!analyserNode) return null; + const data = new Uint8Array(analyserNode.frequencyBinCount); + analyserNode.getByteFrequencyData(data); + return data; +} + +/** + * Get the number of frequency bins (half of fftSize). + */ +export function getFrequencyBinCount(): number { + return analyserNode?.frequencyBinCount ?? 0; +}