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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-22 18:30:33 +01:00
parent e01b740dba
commit 0786e6bf49
5 changed files with 284 additions and 3 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { playerStore } from '$lib/stores/player.svelte';
import FrequencyBars from '$lib/visualizer/FrequencyBars.svelte';
let progress = $derived(
playerStore.duration > 0 ? (playerStore.currentTime / playerStore.duration) * 100 : 0
@ -62,6 +63,11 @@
</svg>
</div>
<!-- Frequency visualizer -->
<div class="w-full max-w-md">
<FrequencyBars barCount={48} height={80} mirror={true} barGap={2} barRadius={2} />
</div>
<!-- Song info -->
<div class="text-center w-full max-w-md">
<div class="text-xl font-bold text-foreground truncate">

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { playerStore } from '$lib/stores/player.svelte';
import FrequencyBars from '$lib/visualizer/FrequencyBars.svelte';
let progress = $derived(
playerStore.duration > 0 ? (playerStore.currentTime / playerStore.duration) * 100 : 0
@ -44,9 +45,14 @@
</div>
{/if}
<!-- Progress bar at top -->
<div class="h-1 w-full bg-border">
<div class="h-full bg-primary transition-all duration-200" style="width: {progress}%"></div>
<!-- Frequency visualizer + progress bar -->
<div class="relative">
<div class="opacity-40">
<FrequencyBars barCount={64} height={20} barGap={1} barRadius={1} />
</div>
<div class="absolute bottom-0 left-0 right-0 h-1 bg-border">
<div class="h-full bg-primary transition-all duration-200" style="width: {progress}%"></div>
</div>
</div>
<div class="flex items-center h-16 px-4 gap-3">

View file

@ -371,6 +371,10 @@ function createPlayerStore() {
state.error = null;
},
getAudioElement(): HTMLAudioElement | null {
return audio;
},
removeFromQueue(index: number) {
if (index === state.currentIndex) return;

View file

@ -0,0 +1,169 @@
<script lang="ts">
import { playerStore } from '$lib/stores/player.svelte';
import { connectAnalyzer, getFrequencyData, resumeAudioContext } from './analyzer';
interface Props {
/** Number of bars to display */
barCount?: number;
/** Gap between bars in pixels */
barGap?: number;
/** Border radius of each bar */
barRadius?: number;
/** CSS color or 'gradient' for a frequency-based gradient */
color?: string;
/** Height of the component in pixels */
height?: number;
/** Whether to mirror bars (symmetric) */
mirror?: boolean;
}
let {
barCount = 32,
barGap = 2,
barRadius = 2,
color = 'gradient',
height = 64,
mirror = false,
}: Props = $props();
let canvas: HTMLCanvasElement | undefined = $state();
let animationId: number | null = null;
let isConnected = $state(false);
// Connect analyzer when audio element becomes available and is playing
$effect(() => {
const audio = playerStore.getAudioElement();
if (audio && playerStore.isPlaying && !isConnected) {
try {
connectAnalyzer(audio);
resumeAudioContext();
isConnected = true;
} catch (e) {
console.warn('[Visualizer] Failed to connect analyzer:', e);
}
}
});
// Start/stop animation loop based on play state
$effect(() => {
if (playerStore.isPlaying && canvas && isConnected) {
startAnimation();
} else {
stopAnimation();
// Draw empty/faded bars when paused
if (canvas) drawBars(null);
}
return () => stopAnimation();
});
function startAnimation() {
if (animationId !== null) return;
function loop() {
const data = getFrequencyData();
drawBars(data);
animationId = requestAnimationFrame(loop);
}
animationId = requestAnimationFrame(loop);
}
function stopAnimation() {
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
}
function drawBars(frequencyData: Uint8Array | null) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
// Set canvas resolution for sharp rendering
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
}
ctx.clearRect(0, 0, w, h);
const effectiveBarCount = mirror ? Math.ceil(barCount / 2) : barCount;
const totalGap = barGap * (barCount - 1);
const barWidth = Math.max(1, (w - totalGap) / barCount);
// Sample frequency data into bar count
const bars = new Float32Array(effectiveBarCount);
if (frequencyData) {
const binCount = frequencyData.length;
for (let i = 0; i < effectiveBarCount; i++) {
// Use logarithmic distribution to emphasize lower frequencies
const logStart = Math.pow(i / effectiveBarCount, 1.5) * binCount;
const logEnd = Math.pow((i + 1) / effectiveBarCount, 1.5) * binCount;
const start = Math.floor(logStart);
const end = Math.max(start + 1, Math.floor(logEnd));
let sum = 0;
for (let j = start; j < end && j < binCount; j++) {
sum += frequencyData[j];
}
bars[i] = sum / (end - start) / 255;
}
}
// Draw bars
for (let i = 0; i < barCount; i++) {
let barIndex: number;
if (mirror) {
const center = Math.floor(barCount / 2);
barIndex = Math.abs(i - center);
barIndex = Math.min(barIndex, effectiveBarCount - 1);
} else {
barIndex = Math.min(i, effectiveBarCount - 1);
}
const value = bars[barIndex];
const minHeight = 2;
const barHeight = Math.max(minHeight, value * h);
const x = i * (barWidth + barGap);
const y = h - barHeight;
// Color
if (color === 'gradient') {
const hue = mirror
? 200 + (Math.abs(i - barCount / 2) / (barCount / 2)) * 120
: 200 + (i / barCount) * 120;
const lightness = 50 + value * 20;
ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`;
} else {
ctx.fillStyle = color;
}
// Draw rounded bar
if (barRadius > 0) {
const r = Math.min(barRadius, barWidth / 2, barHeight / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + barWidth - r, y);
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + r);
ctx.lineTo(x + barWidth, y + barHeight);
ctx.lineTo(x, y + barHeight);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.fill();
} else {
ctx.fillRect(x, y, barWidth, barHeight);
}
}
}
</script>
<canvas bind:this={canvas} class="w-full block" style="height: {height}px;" aria-hidden="true"
></canvas>

View file

@ -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<AnalyzerConfig> = {
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<void> {
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;
}