Sound·11.4
Waveform Scope
What is actually reaching the speakers.
Outputno signal
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add @yugo/soundOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/waveform-scope.jsonUsage
"use client";
import { WaveformScope, useWaveformScope } from "@/components/yugo/waveform-scope";
export function SoundPanel() {
return <WaveformScope label="Output" height={158} />;
}
export function BareScope() {
const { canvasRef, live, lastCue } = useWaveformScope({ idleAfter: 1200 });
return (
<figure data-live={live}>
<canvas ref={canvasRef} className="h-24 w-full text-slate-700" />
<figcaption>{lastCue ?? "no signal"}</figcaption>
</figure>
);
}Source
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { getAnalyser, subscribe, type SoundName } from "@yugo/sound";
const IDLE_AFTER = 900;
const SILENCE = 0.004;
const TRACE_GAIN = 2.5;
const GRID_COLUMNS = 12;
const LINE_WIDTH = 1.5;
function prefersReduced(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function deviation(samples: Uint8Array): number {
let peak = 0;
for (let i = 0; i < samples.length; i += 1) {
const value = Math.abs(samples[i] / 128 - 1);
if (value > peak) peak = value;
}
return peak;
}
export type UseWaveformScopeOptions = {
idleAfter?: number;
gain?: number;
};
export function useWaveformScope({
idleAfter = IDLE_AFTER,
gain = TRACE_GAIN,
}: UseWaveformScopeOptions = {}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const analyser = useRef<AnalyserNode | null>(null);
const samples = useRef<Uint8Array<ArrayBuffer> | null>(null);
const frame = useRef(0);
const lastSound = useRef(0);
const [live, setLive] = useState(false);
const [lastCue, setLastCue] = useState<SoundName | null>(null);
const paint = useCallback(
(data: Uint8Array<ArrayBuffer> | null) => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
const { width, height } = canvas;
const dpr = width / Math.max(1, canvas.clientWidth);
const stroke = getComputedStyle(canvas).color;
const middle = height / 2;
context.clearRect(0, 0, width, height);
context.globalAlpha = 0.09;
context.strokeStyle = stroke;
context.lineWidth = 1;
for (let i = 1; i < GRID_COLUMNS; i += 1) {
const x = Math.round((width / GRID_COLUMNS) * i) + 0.5;
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
}
context.globalAlpha = 1;
context.strokeStyle = stroke;
context.lineWidth = LINE_WIDTH * dpr;
context.lineJoin = "round";
context.beginPath();
if (!data) {
context.globalAlpha = 0.32;
context.moveTo(0, middle);
context.lineTo(width, middle);
context.stroke();
return;
}
const step = width / (data.length - 1);
for (let i = 0; i < data.length; i += 1) {
const amplitude = (data[i] / 128 - 1) * gain;
const y = middle - amplitude * middle;
if (i === 0) context.moveTo(0, y);
else context.lineTo(i * step, y);
}
context.stroke();
},
[gain],
);
const measure = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}, []);
const loop = useCallback(() => {
const node = analyser.current;
const buffer = samples.current;
if (!node || !buffer) return;
node.getByteTimeDomainData(buffer);
const level = deviation(buffer);
if (level > SILENCE) lastSound.current = performance.now();
if (performance.now() - lastSound.current > idleAfter) {
frame.current = 0;
setLive(false);
paint(null);
return;
}
paint(buffer);
frame.current = requestAnimationFrame(loop);
}, [idleAfter, paint]);
const start = useCallback(() => {
if (prefersReduced()) return;
if (!analyser.current) {
const node = getAnalyser();
if (!node) return;
analyser.current = node;
samples.current = new Uint8Array(node.fftSize);
}
lastSound.current = performance.now();
if (frame.current) return;
setLive(true);
frame.current = requestAnimationFrame(loop);
}, [loop]);
useEffect(() => {
measure();
paint(null);
const canvas = canvasRef.current;
if (!canvas) return;
const observer = new ResizeObserver(() => {
measure();
if (!frame.current) paint(null);
});
observer.observe(canvas);
return () => observer.disconnect();
}, [measure, paint]);
useEffect(
() =>
subscribe((sound) => {
setLastCue(sound);
start();
}),
[start],
);
useEffect(
() => () => {
if (frame.current) cancelAnimationFrame(frame.current);
frame.current = 0;
},
[],
);
return { canvasRef, live, lastCue, start };
}
export type WaveformScopeProps = {
label?: string;
height?: number;
idleAfter?: number;
gain?: number;
className?: string;
};
export function WaveformScope({
label = "Output",
height = 132,
idleAfter = IDLE_AFTER,
gain = TRACE_GAIN,
className = "",
}: WaveformScopeProps) {
const { canvasRef, live, lastCue } = useWaveformScope({ idleAfter, gain });
return (
<div className={`w-full ${className}`}>
<div className="mb-2 flex items-baseline justify-between gap-3">
<span className="text-[12.5px] text-stone-500 dark:text-stone-400">
{label}
</span>
<span className="grid justify-items-end">
<span
aria-hidden
className="invisible col-start-1 row-start-1 font-mono text-[11px]"
>
no signal
</span>
<span
className="col-start-1 row-start-1 font-mono text-[11px]"
style={{ color: live ? "#4568FF" : "rgb(120 113 108)" }}
>
{lastCue ?? "no signal"}
</span>
</span>
</div>
<div
className="w-full overflow-hidden rounded-[10px] bg-stone-100 dark:bg-white/[0.06]"
style={{ height }}
>
<canvas
ref={canvasRef}
role="img"
aria-label={
lastCue ? `Waveform of the ${lastCue} cue` : "Waveform, no signal"
}
className="block h-full w-full text-stone-700 dark:text-stone-200"
/>
</div>
</div>
);
}Props
label"Output"stringHeading above the trace. The canvas carries its own aria-label naming the cue being drawn.
height132numberCSS pixels. The backing store is sized separately, from the element's real box at device pixel ratio.
idleAfter900numberMilliseconds below the noise floor before the frame loop cancels itself and the trace flattens.
gain2.5numberVertical scale on the trace only, so a quiet cue is still legible. It never touches what is actually played.
className""stringAppended last to the outer frame.