waveform visualiser

This commit is contained in:
2026-01-31 15:54:59 +00:00
parent 7971ca229c
commit f249c1dbe5
3 changed files with 140 additions and 2 deletions

View File

@@ -282,7 +282,7 @@ function Index() {
); );
return ( return (
<Layout sidebarContent={sidebarContent}> <Layout sidebarContent={sidebarContent} waveformColor={state.color}>
<ShapeCanvas state={state} onStateChange={setState} /> <ShapeCanvas state={state} onStateChange={setState} />
</Layout> </Layout>
); );

View File

@@ -1,3 +1,4 @@
import AudioWaveform from "@/components/AudioWaveform";
import ThemeToggle from "@/components/theme-toggle"; import ThemeToggle from "@/components/theme-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Home, Settings } from "lucide-react"; import { Home, Settings } from "lucide-react";
@@ -6,9 +7,11 @@ import { Link, useLocation } from "react-router-dom";
export default function Layout({ export default function Layout({
children, children,
sidebarContent, sidebarContent,
waveformColor,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
sidebarContent?: React.ReactNode; sidebarContent?: React.ReactNode;
waveformColor?: string;
}) { }) {
const location = useLocation(); const location = useLocation();
@@ -20,7 +23,10 @@ export default function Layout({
<img src="/icon.png" alt="" aria-hidden="true" className="size-8" /> <img src="/icon.png" alt="" aria-hidden="true" className="size-8" />
<h1 className="text-2xl font-semibold">The Shape of Sound</h1> <h1 className="text-2xl font-semibold">The Shape of Sound</h1>
</div> </div>
<div className="flex-1">{sidebarContent || null}</div> <div className="flex-1 overflow-y-auto">{sidebarContent || null}</div>
{/* Audio Waveform Visualization */}
<AudioWaveform color={waveformColor} />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<ThemeToggle className="rounded-lg" /> <ThemeToggle className="rounded-lg" />

View File

@@ -0,0 +1,132 @@
import { useEffect, useRef } from "react";
import * as Tone from "tone";
export default function AudioWaveform({ color }: { color?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const analyzerRef = useRef<Tone.Waveform | null>(null);
const animationFrameRef = useRef<number | null>(null);
useEffect(() => {
// create analyzer and connect to destination
const analyzer = new Tone.Waveform(512);
Tone.getDestination().connect(analyzer);
analyzerRef.current = analyzer;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Set canvas size
const updateCanvasSize = () => {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
};
updateCanvasSize();
window.addEventListener("resize", updateCanvasSize);
// Animation loop
const draw = () => {
if (!canvas || !ctx || !analyzerRef.current) return;
const values = analyzerRef.current.getValue();
const width = canvas.getBoundingClientRect().width;
const height = canvas.getBoundingClientRect().height;
ctx.clearRect(0, 0, width, height);
let strokeColor = color || "#caa3ff";
if (!color) {
const computedStyle = getComputedStyle(canvas);
const primaryColor = computedStyle.getPropertyValue("--primary") || "210 100% 50%";
// Convert HSL to RGB for canvas
const hslMatch = primaryColor.match(/(\d+)\s+(\d+)%\s+(\d+)%/);
if (hslMatch) {
const h = Number.parseInt(hslMatch[1]);
const s = Number.parseInt(hslMatch[2]) / 100;
const l = Number.parseInt(hslMatch[3]) / 100;
// HSL to RGB conversion
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h >= 0 && h < 60) {
r = c; g = x; b = 0;
} else if (h >= 60 && h < 120) {
r = x; g = c; b = 0;
} else if (h >= 120 && h < 180) {
r = 0; g = c; b = x;
} else if (h >= 180 && h < 240) {
r = 0; g = x; b = c;
} else if (h >= 240 && h < 300) {
r = x; g = 0; b = c;
} else {
r = c; g = 0; b = x;
}
strokeColor = `rgb(${Math.round((r + m) * 255)}, ${Math.round((g + m) * 255)}, ${Math.round((b + m) * 255)})`;
}
}
ctx.beginPath();
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 2;
const sliceWidth = width / values.length;
let x = 0;
for (let i = 0; i < values.length; i++) {
const value = values[i] as number;
// multiplied amplitude for more prominent visualisation
const amplifiedValue = Math.max(-1, Math.min(1, value * 5));
const y = ((amplifiedValue + 1) / 2) * height;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.stroke();
animationFrameRef.current = requestAnimationFrame(draw);
};
draw();
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (analyzerRef.current) {
analyzerRef.current.dispose();
}
window.removeEventListener("resize", updateCanvasSize);
};
}, [color]);
return (
<div className="w-full h-20 rounded-lg border bg-card/50 overflow-hidden">
<canvas
ref={canvasRef}
className="w-full h-full"
style={{ display: "block" }}
/>
</div>
);
}