mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
e01b740dba
commit
0786e6bf49
5 changed files with 284 additions and 3 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -371,6 +371,10 @@ function createPlayerStore() {
|
|||
state.error = null;
|
||||
},
|
||||
|
||||
getAudioElement(): HTMLAudioElement | null {
|
||||
return audio;
|
||||
},
|
||||
|
||||
removeFromQueue(index: number) {
|
||||
if (index === state.currentIndex) return;
|
||||
|
||||
|
|
|
|||
169
apps/mukke/apps/web/src/lib/visualizer/FrequencyBars.svelte
Normal file
169
apps/mukke/apps/web/src/lib/visualizer/FrequencyBars.svelte
Normal 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>
|
||||
96
apps/mukke/apps/web/src/lib/visualizer/analyzer.ts
Normal file
96
apps/mukke/apps/web/src/lib/visualizer/analyzer.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue