DEV Community

Abdul Kabir Khan
Abdul Kabir Khan

Posted on

Building a Real-Time Audio-Reactive Visualizer in Next.js 15 with Web Audio API & Canvas

Building real-time visualizations in React often leads to severe frame drops if rendering state updates directly through the DOM. When handling continuous data streams—like microphone input or audio frequency analysis—triggering React re-renders on every frame will cause massive layout thrashing.

In this tutorial, we will build a 60fps real-time frequency visualizer in Next.js 15 App Router using the Web Audio API and an isolated HTML5 2D Canvas pipeline.


Architecture Overview

To maintain seamless performance:

  1. Direct Canvas Rendering: We bypass React's virtual DOM reconciliation loop by handling canvas drawing directly inside a dedicated requestAnimationFrame loop.
  2. Web Audio API Stream Analysis: We capture audio via navigator.mediaDevices.getUserMedia(), route it through an AudioContext, and extract frequency data using an AnalyserNode.
  3. Strict Lifecycle Cleanup: Audio contexts, audio streams, and animation frames must be torn down on unmount to prevent memory leaks and dangling hardware listeners.

1. Creating the Visualizer Component

Create components/AudioVisualizer.tsx:

"use client";

import React, { useRef, useState, useEffect, useCallback } from "react";

interface AudioVisualizerProps {
  barColor?: string;
  barWidth?: number;
  gap?: number;
}

export const AudioVisualizer: React.FC<AudioVisualizerProps> = ({
  barColor = "#38bdf8",
  barWidth = 4,
  gap = 2,
}) => {
  const canvasRef = useRef<HTMLCanvasElement null |>(null);
  const audioContextRef = useRef<AudioContext null |>(null);
  const analyserRef = useRef<AnalyserNode null |>(null);
  const sourceRef = useRef<MediaStreamAudioSourceNode null |>(null);
  const streamRef = useRef<MediaStream null |>(null);
  const animationFrameId = useRef<number | null>(null);

  const [isListening, setIsListening] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const draw = useCallback(() => {
    const canvas = canvasRef.current;
    const analyser = analyserRef.current;
    if (!canvas || !analyser) return;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const bufferLength = analyser.frequencyBinCount;
    const dataArray = new Uint8Array(bufferLength);

    const renderFrame = () => {
      animationFrameId.current = requestAnimationFrame(renderFrame);

      analyser.getByteFrequencyData(dataArray);

      ctx.clearRect(0, 0, canvas.width, canvas.height);

      const totalBarWidth = barWidth + gap;
      const numBars = Math.floor(canvas.width / totalBarWidth);
      const step = Math.floor(bufferLength / numBars);

      for (let i = 0; i < numBars; i++) {
        const value = dataArray[i * step] || 0;
        const percent = value / 255;
        const height = canvas.height * percent;
        const x = i * totalBarWidth;
        const y = canvas.height - height;

        ctx.fillStyle = barColor;
        ctx.fillRect(x, y, barWidth, height);
      }
    };

    renderFrame();
  }, [barColor, barWidth, gap]);

  const startVisualizer = async () => {
    try {
      setError(null);
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      streamRef.current = stream;

      const AudioCtx =
        window.AudioContext ||
        (window as unknown as { webkitAudioContext: typeof AudioContext })
          .webkitAudioContext;
      const audioCtx = new AudioCtx();
      const analyser = audioCtx.createAnalyser();
      analyser.fftSize = 256;

      const source = audioCtx.createMediaStreamSource(stream);
      source.connect(analyser);

      audioContextRef.current = audioCtx;
      analyserRef.current = analyser;
      sourceRef.current = source;

      setIsListening(true);
      draw();
    } catch (err) {
      setError("Microphone access denied or audio input unavailable.");
    }
  };

  const stopVisualizer = useCallback(() => {
    if (animationFrameId.current) cancelAnimationFrame(animationFrameId.current);
    if (sourceRef.current) sourceRef.current.disconnect();
    if (streamRef.current) {
      streamRef.current.getTracks().forEach((track) => track.stop());
    }
    if (audioContextRef.current && audioContextRef.current.state !== "closed") {
      audioContextRef.current.close();
    }
    setIsListening(false);
  }, []);

  useEffect(() => {
    return () => {
      stopVisualizer();
    };
  }, [stopVisualizer]);

  return (
    <div className="flex flex-col items-center justify-center p-6 bg-slate-900 rounded-xl border border-slate-800 w-full max-w-md mx-auto">
      <canvas
        ref={canvasRef}
        width={400}
        height={150}
        className="w-full bg-slate-950 rounded-lg shadow-inner"
      />
      {error && <p className="text-rose-400 text-xs mt-2">{error}</p>}
      <button
        onClick={isListening ? stopVisualizer : startVisualizer}
        className="mt-4 px-4 py-2 text-sm font-medium rounded-md bg-sky-500 hover:bg-sky-400 text-slate-950 transition-colors cursor-pointer"
      >
        {isListening ? "Stop Microphone" : "Start Visualizer"}
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

2. Key Implementation Details

Bypassing React State in the Render Loop

Storing high-frequency data arrays in React state (useState) would trigger component re-renders on every animation tick (up to 120 times/sec on high-refresh displays). Using persistent useRef handles keeps calculations completely off the main thread's virtual DOM reconciliation loop.

AnalyserNode Configuration

Setting analyser.fftSize = 256 results in frequencyBinCount = 128 distinct frequency bands. This provides a balance between fine-grained frequency resolution and lightweight rendering calculations.

Audio Resource Teardown

Browser audio streams remain active unless explicitly killed. Cleaning up requires:

  1. Stopping all active media tracks via stream.getTracks().forEach(track => track.stop()).
  2. Disconnecting audio routing nodes (source.disconnect()).
  3. Closing the hardware audio context (audioContext.close()).

Conclusion

Combining the Web Audio API with isolated HTML5 Canvas components in Next.js 15 provides smooth, hardware-accelerated rendering without sacrificing React architecture principles.

Top comments (0)