How to Build an Audio Visualizer in React

Have you ever wondered how those mesmerizing audio visualizers work? Bars bouncing to the beat and frequencies dancing across the screen? In this tutorial, we’ll break down how to build your own audio visualizer in React using the Canvas API and the powerful Web Audio API. Whether you're building a music app, podcast tool, or just want to experiment with sound and visuals. This is a guide to explore real-time audio processing in the browser.

Jordan Wu profile picture

Jordan Wu

15 min read·Posted 

A Group of Palm Trees Image.
A Group of Palm Trees Image.
Table of Contents

Audio Visualizer

An audio visualizer is a dynamic graphical representation of sound. It takes data in real time of an audio input then translates properties of the audio. Such as frequency, amplitude, and rhythm into animated visual patterns like bars, waves, or shapes that move and change with the sound. Audio visualizers are often used in music players, live DJ sets, streaming platforms, or interactive web applications to enhance the listening experience by making sound visually engaging. In web development, they’re commonly built using technologies like the Web Audio API, Canvas, or WebGL combined with JavaScript frameworks like React.

Creating the Audio Visualizer in React

We will be using Canvas API for displaying the visualizer and Web Audio API for getting the sound audio data in real time. This will be a bar audio visualizer where vertical bars move in real time based on the frequencies and volume levels of the audio being played. Each bar typically represents a specific frequency range, and the height of the bar corresponds to the amplitude or energy in that frequency.

Labeling the Axes: Frequency (Hz) and Amplitude (dB)

Frequency refers to the number of complete sound wave cycles that occur per second and is measured in hertz (Hz). For example, a frequency of 1 Hz means one cycle per second. The human ear can typically detect frequencies ranging from 20 Hz to 20,000 Hz. Frequency determines the pitch of a sound. Lower frequencies produce deeper tones. Higher frequencies create higher pitched sounds. A song is essentially a combination of many sounds playing simultaneously, each with its own frequency. By analyzing this data, you can determine where each sound sits within the audio spectrum.

In a bar audio visualizer, frequency is usually mapped to the x-axis, with bars representing different ranges from low to high pitch. In our case we will only cover the frequency that a human ear can detect from 20 Hz to 20,000 Hz.

const FREQUENCY_LABELS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000, 20000]

Amplitude measures the strength or intensity of a sound wave and directly affects how loud or soft a sound appears to our ears. It is commonly expressed in decibels (dB), a logarithmic unit that quantifies sound intensity relative to a reference level. Because the decibel scale is logarithmic, an increase of 10 dB represents a tenfold increase in sound intensity, meaning even small changes in dB can reflect large differences in perceived loudness.

In a bar audio visualizer, amplitude is typically represented on the y-axis, where taller bars correspond to higher amplitudes and therefore louder sounds. In our case, we will label the maximum amplitude at 0 dB and the minimum at -60 dB.

const DB_VALUES = [0, -10, -20, -30, -40, -50, -60]

Our ears perceive different frequencies with varying sensitivity, meaning sounds at certain pitches may seem louder or softer even if their physical amplitude is the same. This phenomenon, known as equal loudness perception. The human ear is most sensitive to mid-range frequencies around 2,000 to 5,000 Hz and less sensitive to very low or very high frequencies. Because of this, audio measurements often use weighting filters, such as A-weighting. To adjust levels and better reflect how we actually hear sound across the frequency spectrum.

The frequency label will logarithmically. Human perception of pitch is roughly logarithmic, meaning each octave (doubling of frequency) is perceived as an equal step in pitch.

Analyser Node

The AnalyserNode is part of the Web Audio API and provides real-time insight into the audio playing in a web application. It sits in the audio processing graph and lets you examine the sound’s frequency and waveform data as it plays. This makes it especially useful for creating visualizations like bar or wave visualizers that respond dynamically to audio input.

fftSize

fftSize is a property of the AnalyserNode in the Web Audio API that determines the size of the Fast Fourier Transform (FFT) used to analyze the audio signal. It must be a power of 2 (like 32, 64, 128, up to 32768) and controls the frequency resolution of the analysis. A larger fftSize gives you more frequency bins and finer detail in the frequency spectrum, but it also increases the amount of data and can make the visualizer respond a bit slower. Smaller values update more quickly but offer less detail. The default value is 2048.

frequencyBinCount

frequencyBinCount is a property of the AnalyserNode that represents half of the fftSize and defines how many frequency data points are available for analysis. Each of these points, or "bins," corresponds to a specific range of frequencies in the audio signal, with their values indicating the amplitude of those frequencies. The total frequency range spans from 0 Hz up to half the audio sample rate (usually around 22,050 Hz for standard 44.1 kHz audio). This property is essential for creating accurate and detailed visual representations of an audio signal's frequency content.

minDecibels

The minDecibels property of the AnalyserNode defines the minimum power level (in decibels) that will be considered when analyzing audio frequencies. It sets the lower bound of the dynamic range for frequency data, meaning any signal below this level is effectively treated as silence in the visual output. By default, it's set to -100 dB, but you can adjust it to control how quiet sounds are represented in your visualization.

maxDecibels

The maxDecibels property of the AnalyserNode sets the upper limit of the audio signal's power level in decibels for frequency analysis. It defines the loudest value that will be represented in the frequency data. By default, it's set to -30 dB, meaning any signal stronger than this will be capped at that level in visualizations. Adjusting maxDecibels lets you fine-tune how loud sounds appear in your audio visualizer.

smoothingTimeConstant

The smoothingTimeConstant property of the AnalyserNode controls how much the audio data is smoothed over time, helping to create a more fluid and less jittery visual experience. It’s a value between 0 and 1, where 0 means no smoothing (fast, sharp changes) and 1 means heavy smoothing (slow, gradual changes). This is especially useful for making visualizations like bar graphs look more natural and less erratic when audio levels fluctuate rapidly.

getByteFrequencyData

The getByteFrequencyData is a method of the AnalyserNode that copies the current frequency data into a Uint8Array. This data represents the amplitude of different frequency bands as unsigned 8-bit integers ranging from 0 to 255. It’s commonly used to get real-time frequency information for creating audio visualizations like bar graphs, where each value corresponds to the strength of a specific frequency range.

Audio Visualizer React Component

The implementation of the component will be using react-use-audio-player which uses howler.js to get the audio data. The reason for using this library is to handle one shared audio context within the application by using useAudioPlayerContext it returns a shared instance of an AudioPlayer. It's designed to provide access to a shared audio resource across multiple components within your application.

File ImageAudioVisualizer.tsx
import { useRef, useEffect, useState } from 'react'
import { useAudioPlayerContext } from 'react-use-audio-player'
import clsx from 'clsx'

type AudioVisualizerProps = {
  height?: number | string
  width?: number | string
  fftSize?: 4096
  maxDecibels?: number
  minDecibels?: number
  smoothingTimeConstant?: number
}

const FREQUENCY_LABELS = [
  10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 2000, 3000, 4000, 5000, 6000,
  7000, 8000, 9000, 10000, 20000,
]

const MIN_FREQUENCY = 20
const MAX_FREQUENCY = 20000

const DB_VALUES = [0, -10, -20, -30, -40, -50, -60]

const BARS = 36

function AudioVisualizer(props: AudioVisualizerProps) {
  const {
    width = '100%',
    height = '100%',
    fftSize = 4096,
    maxDecibels = 0,
    minDecibels = -60,
    smoothingTimeConstant = 0.4,
  } = props
  const [responsiveHeight, setResponsiveHeight] = useState<null | number>(null)
  const [responsiveWidth, setResponsiveWidth] = useState<null | number>(null)
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const { player } = useAudioPlayerContext()
  const audio = player?._sounds[0]?._node

  useEffect(() => {
    const container = document.getElementById('audio-visualizer')
    if (!container) return

    const parentElement = container.parentElement
    if (!parentElement) return

    const { width: initialWidth, height: initialHeight } = parentElement.getBoundingClientRect()
    if (width === '100%') {
      setResponsiveWidth(initialHeight)
    }
    if (height === '100%') {
      setResponsiveHeight(initialWidth)
    }

    const resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const { width: newWidth, height: newHeight } = entry.contentRect
        if (width === '100%') {
          setResponsiveWidth(newWidth)
        }
        if (height === '100%') {
          setResponsiveHeight(newHeight)
        }
      }
    })

    resizeObserver.observe(parentElement)

    return () => {
      resizeObserver.disconnect()
    }
  }, [setResponsiveHeight, setResponsiveWidth])

  useEffect(() => {
    if (!canvasRef.current) return
    const canvas = canvasRef.current
    const canvasCtx = canvas.getContext('2d')

    if (audio) {
      audio.crossOrigin = 'anonymous'

      const AudioContext = window.AudioContext || (window as any).webkitAudioContext
      const ctx = new AudioContext()
      const analyserNode = ctx.createAnalyser()
      analyserNode.fftSize = fftSize
      analyserNode.minDecibels = minDecibels
      analyserNode.maxDecibels = maxDecibels
      analyserNode.smoothingTimeConstant = smoothingTimeConstant

      const source = ctx.createMediaElementSource(audio)
      source.connect(analyserNode)
      source.connect(ctx.destination)

      const dataArray = new Uint8Array(analyserNode.frequencyBinCount)
      const nyquistFrequency = ctx.sampleRate / 2
      const frequencyPerBin = nyquistFrequency / analyserNode.frequencyBinCount
      const startBin = Math.floor(MIN_FREQUENCY / frequencyPerBin)
      const endBin = Math.ceil(MAX_FREQUENCY / frequencyPerBin)

      const previousValues = new Float32Array(endBin - startBin)

      let animationFrameId: number

      function draw() {
        if (!canvasRef.current || !analyserNode) return
        if (!canvasCtx) return

        analyserNode.getByteFrequencyData(dataArray)

        const width = canvas.width
        const height = canvas.height

        canvasCtx.fillStyle = 'rgb(23, 23, 23)'
        canvasCtx.fillRect(0, 0, width, height)

        canvasCtx.fillStyle = 'white'
        canvasCtx.strokeStyle = 'white'
        drawFrequencyLabels(canvasCtx)
        drawDbLabels(canvasCtx)

        for (let bar = 0; bar < BARS; bar++) {
          const { averageValue, frequency } = getFrequencyAndRawValue(dataArray, bar, frequencyPerBin)
          const currentValue = (averageValue + aWeighting(frequency)) / 255

          if (currentValue > previousValues[bar]) {
            previousValues[bar] = currentValue // rise immediately
          } else {
            previousValues[bar] *= 0.994 // decay slowly
          }
          const smoothedValue = previousValues[bar]

          const minHeight = height * 0.001
          const barHeight = Math.max(smoothedValue * height, minHeight)
          const gap = 4
          const totalWidth = canvas.width
          const availableWidth = totalWidth - gap * (BARS - 1)
          const barWidth = availableWidth / BARS
          const x = bar * (barWidth + gap)

          canvasCtx.fillStyle = 'white'
          canvasCtx.fillRect(x, canvas.height - barHeight, barWidth, barHeight)
        }

        animationFrameId = requestAnimationFrame(draw)
      }

      animationFrameId = requestAnimationFrame(draw)

      return () => {
        cancelAnimationFrame(animationFrameId)
        source?.disconnect()
        analyserNode?.disconnect()
        if (ctx.state !== 'closed') {
          ctx.close()
        }
      }
    }
  }, [audio])

  return (
    <canvas
      id="audio-visualizer"
      className={clsx(
        width === '100%' ? 'w-full' : `w-[${responsiveWidth || width}px]`,
        height === '100%' ? 'h-full' : `h-[${responsiveHeight || height}px]`
      )}
      ref={canvasRef}
      width={responsiveWidth || width}
      height={responsiveHeight || height}
      style={{
        aspectRatio: 'unset',
      }}
    />
  )
}

function getFrequencyAndRawValue(dataArray: Uint8Array, bar: number, frequencyPerBin: number) {
  let averageValue
  let frequency
  let startBinIndex
  let endBinIndex
  let bins

  switch (bar) {
    case 0:
    case 1:
    case 2:
    case 3:
    case 4:
    case 5:
    case 6:
    case 7:
    case 8:
      startBinIndex = bar
      endBinIndex = bar
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 9:
      startBinIndex = 9
      endBinIndex = 11
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 10:
      startBinIndex = 12
      endBinIndex = 14
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 11:
      startBinIndex = 15
      endBinIndex = 17
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 12:
      startBinIndex = 18
      endBinIndex = 22
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 13:
      startBinIndex = 23
      endBinIndex = 27
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 14:
      startBinIndex = 28
      endBinIndex = 32
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 15:
      startBinIndex = 33
      endBinIndex = 37
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
    case 16:
      startBinIndex = 38
      endBinIndex = 46
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 17:
      startBinIndex = 47
      endBinIndex = 55
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 18:
      startBinIndex = 56
      endBinIndex = 64
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 19:
      startBinIndex = 65
      endBinIndex = 74
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 20:
      startBinIndex = 75
      endBinIndex = 87
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 21:
      startBinIndex = 88
      endBinIndex = 115
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 22:
      startBinIndex = 116
      endBinIndex = 143
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 23:
      startBinIndex = 144
      endBinIndex = 171
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 24:
      startBinIndex = 172
      endBinIndex = 212
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 25:
      startBinIndex = 213
      endBinIndex = 255
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 26:
      startBinIndex = 256
      endBinIndex = 314
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 27:
      startBinIndex = 315
      endBinIndex = 400
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 28:
      startBinIndex = 401
      endBinIndex = 482
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 29:
      startBinIndex = 483
      endBinIndex = 565
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 30:
      startBinIndex = 566
      endBinIndex = 667
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 31:
      startBinIndex = 668
      endBinIndex = 777
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 32:
      startBinIndex = 778
      endBinIndex = 888
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 33:
      startBinIndex = 889
      endBinIndex = 1162
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 34:
      startBinIndex = 1163
      endBinIndex = 1436
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    case 35:
      startBinIndex = 1437
      endBinIndex = 1707
      bins = dataArray.slice(startBinIndex, endBinIndex + 1)
      averageValue = average(bins)
      break
    default:
      throw new Error('Invalid bar there should be 36 bars')
  }
  frequency =
    (frequencyPerBin * (endBinIndex + 1) - frequencyPerBin * startBinIndex) / 2 + frequencyPerBin * startBinIndex

  return { averageValue, frequency }
}

function drawFrequencyLabels(canvasCtx: CanvasRenderingContext2D) {
  canvasCtx.fillStyle = 'white'
  canvasCtx.globalAlpha = 1

  canvasCtx.setLineDash([])
  const frequencyBands = generateOctaveBands()
  const minLabelRange = frequencyBands[0].ctr
  const maxLabelRange = frequencyBands[frequencyBands.length - 1].ctr

  FREQUENCY_LABELS.map((frequency) => {
    const label = frequency >= 1000 ? frequency / 1000 + 'kHz' : frequency + 'Hz'
    const posX = map(
      Math.log2(frequency),
      Math.log2(minLabelRange),
      Math.log2(maxLabelRange),
      1 / frequencyBands.length / 2,
      1 - 1 / frequencyBands.length / 2
    )
    canvasCtx.beginPath()
    canvasCtx.lineTo(posX * canvasCtx.canvas.width, canvasCtx.canvas.height)
    canvasCtx.lineTo(posX * canvasCtx.canvas.width, 0)
    canvasCtx.stroke()

    canvasCtx.fillText(label, posX * canvasCtx.canvas.width, canvasCtx.canvas.height)
  })
  canvasCtx.setLineDash([])
  canvasCtx.textAlign = 'start'
  canvasCtx.textBaseline = 'alphabetic'
}

function aWeighting(frequency: number): number {
  const f2 = frequency * frequency
  const f4 = f2 * f2

  const ra_num = 12194 ** 2 * f4
  const ra_den = (f2 + 20.6 ** 2) * Math.sqrt((f2 + 107.7 ** 2) * (f2 + 737.9 ** 2)) * (f2 + 12194 ** 2)

  return 20 * Math.log10(ra_num / ra_den)
}

function drawDbLabels(canvasCtx: CanvasRenderingContext2D) {
  canvasCtx.setLineDash([])
  canvasCtx.textBaseline = 'alphabetic'
  DB_VALUES.map((dbValue) => {
    const label = `${dbValue}dB`
    const posY = map(ascale(10 ** (dbValue / 20)), 0, 1, canvasCtx.canvas.height, 0)
    if (posY >= 0) {
      canvasCtx.beginPath()
      canvasCtx.lineTo(0, posY)
      canvasCtx.lineTo(canvasCtx.canvas.width, posY)
      canvasCtx.stroke()
      canvasCtx.textAlign = 'end'
      canvasCtx.fillText(label, canvasCtx.canvas.width, posY + 10)
    }
  })
  canvasCtx.setLineDash([])
  canvasCtx.textAlign = 'start'
  canvasCtx.textBaseline = 'alphabetic'
}

function map(frequency: number, min: number, max: number, targetMin: number, targetMax: number) {
  return ((frequency - min) / (max - min)) * (targetMax - targetMin) + targetMin
}

function generateOctaveBands(
  bandsPerOctave = 12,
  lowerNote = 4,
  higherNote = 123,
  detune = 0,
  tuningFreq = 440,
  bandwidth = 0.5
) {
  const tuningNote = isFinite(Math.log2(tuningFreq)) ? Math.round((Math.log2(tuningFreq) - 4) * 12) * 2 : 0,
    root24 = 2 ** (1 / 24),
    c0 = tuningFreq * root24 ** -tuningNote, // ~16.35 Hz
    groupNotes = 24 / bandsPerOctave
  let bands = []
  for (let i = Math.round((lowerNote * 2) / groupNotes); i <= Math.round((higherNote * 2) / groupNotes); i++) {
    bands.push({
      lo: c0 * root24 ** ((i - bandwidth) * groupNotes + detune),
      ctr: c0 * root24 ** (i * groupNotes + detune),
      hi: c0 * root24 ** ((i + bandwidth) * groupNotes + detune),
    })
  }
  return bands
}

function ascale(dbValue: number) {
  const minDecibels = -60
  const maxDecibels = 0

  return map(20 * Math.log10(dbValue), minDecibels, maxDecibels, 0, 1)
}

function average(arr: Uint8Array | number[]) {
  if (!arr.length) return 0
  const sum = Array.from(arr).reduce((acc, val) => acc + val, 0)
  return sum / arr.length
}

export default AudioVisualizer
  • There will only be 36 bars in the audio visualizer.
  • This component uses ResizeObserver to automatically resize the canvas HTML element whenever the browser window size changes.
  • The function getFrequencyAndRawValue analyzes frequency data to calculate the average amplitude for a specific frequency band (bar) and identifies the corresponding frequency for that band. This frequency is then used with aWeighting to apply a weighting that reflects how the human ear perceives different frequencies, emphasizing some more than others.
Audio Visualizer
Audio Visualizer

Check out Audio Visualizer Playground

Summary

That’s everything you need to build a basic audio visualizer React component! It’s a fun and powerful way to translate frequency and amplitude into animated visual bars. From here, you can customize and expand the setup to create your own unique way of visualizing music. Personally, I wanted to design a visualizer that gives more control over how specific sounds like drums, vocals, and melodies are displayed. Imagine highlighting each element of a track visually, based on how it sounds. Check out the visualizer I built below!

About the Author

Jordan Wu profile picture
Jordan is a full stack engineer with years of experience working at startups. He enjoys learning about software development and building something people want. What makes him happy is music. He is passionate about finding music and is an aspiring DJ. He wants to create his own music and in the process of finding is own sound.
Email icon image
Stay up to date

Get notified when I publish something new, and unsubscribe at any time.