Build a Minimal Spectrum Analyzer

Click the 'Play' button below to test

Click Play to try example.mp3, or choose a local file.

This tutorial explains a compact, copy-pasteable HTML page that: - loads a default file named example.mp3 if present next to the HTML, - lets the user pick a different local audio file, - draws a simple realtime frequency bar spectrum using the Web Audio API and Canvas.

What you will learn

Key concepts (brief)

AnalyserNode basics: analyser.fftSize controls resolution. analyser.frequencyBinCount is fftSize/2. getByteFrequencyData fills a Uint8Array with values 0–255 for each bin.
Mapping bins to bars: Average several FFT bins per visual bar to keep the display readable. Normalize 0–255 to 0–1, and apply a power curve like Math.pow(value, 0.6) to make quieter audio more visible.

Complete tutorial HTML (save as analyzer-tutorial.html)

Copy the full example below into a file named analyzer-tutorial.html. Place an optional example.mp3 next to it to test the default playback. The page uses only client-side JavaScript—no build tools required.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Minimal Spectrum Analyzer with Play/Pause</title>
<style>
  :root{--bg:#071020;--panel:#0b1520;--muted:#9aa4b2;--accent:#7ff3d1}
  html,body{height:100%;margin:0;background:var(--bg);color:#d7f7ee;font-family:system-ui,Arial;padding:12px}
  .controls{display:flex;gap:10px;align-items:center;padding:12px 0}
  button,input{background:var(--panel);border:1px solid rgba(255,255,255,0.04);color:inherit;padding:8px 12px;border-radius:8px}
  .meta{font-size:13px;color:var(--muted)}
  canvas{display:block;width:100%;height:calc(100vh - 120px);background:linear-gradient(#08121a,#041018)}
</style>
</head>
<body>

  <div class="controls">
    <button id="playPause">Play</button>
    <input id="file" type="file" accept=".mp3" />
    <div id="meta" class="meta">Click Play to try example.mp3, or choose a local file.</div>
  </div>

  <canvas id="canvas"></canvas>

<script>
/* Minimal spectrum analyzer with:
   - Play/Pause button
   - Attempts to use example.mp3 (if present) on first Play
   - Allows choosing a local file anytime via file input
   - Simple bars visualization using AnalyserNode
*/

const playBtn = document.getElementById('playPause');
const fileInput = document.getElementById('file');
const meta = document.getElementById('meta');
const canvas = document.getElementById('canvas');

const dpr = window.devicePixelRatio || 1;
function resizeCanvas(){
  canvas.width = Math.floor(canvas.clientWidth * dpr);
  canvas.height = Math.floor(canvas.clientHeight * dpr);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();

let audioCtx = null;
let analyser = null;
let dataArray = null;
let sourceNode = null;
let audioEl = null;
let rafId = null;
let isPlaying = false;
let currentFileName = null;
const BAR_COUNT = 64;

function ensureAudioContext(){
  if(!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}

function connectAudioElement(aEl){
  ensureAudioContext();

  if(sourceNode){ try{ sourceNode.disconnect(); }catch(e){} sourceNode = null; }
  if(analyser){ try{ analyser.disconnect(); }catch(e){} analyser = null; }

  sourceNode = audioCtx.createMediaElementSource(aEl);
  analyser = audioCtx.createAnalyser();
  analyser.fftSize = 2048;
  dataArray = new Uint8Array(analyser.frequencyBinCount);

  sourceNode.connect(analyser);
  analyser.connect(audioCtx.destination);
}

function startDraw(){
  if(!analyser) return;
  const ctx = canvas.getContext('2d');

  function draw(){
    analyser.getByteFrequencyData(dataArray); // fills 0..255
    const w = canvas.width, h = canvas.height;
    ctx.clearRect(0,0,w,h);

    const binCount = dataArray.length; // fftSize / 2
    const binsPerBar = Math.max(1, Math.floor(binCount / BAR_COUNT));
    const margin = 6 * dpr;
    const usable = w - margin*2;
    const barW = usable / BAR_COUNT;

    for(let i=0;i<BAR_COUNT;i++){
      let sum = 0;
      const start = i * binsPerBar;
      const end = Math.min(binCount, start + binsPerBar);
      for(let b=start;b<end;b++) sum += dataArray[b];
      const avg = sum / (end - start); // 0..255

      // amplitude scaling: normalize then apply power curve
      const amplitude = Math.pow(avg / 255, 0.6);
      const barH = amplitude * (h - margin*2);

      // simple color gradient cyan -> magenta
      const hue = 190 - (i / BAR_COUNT) * 140;
      ctx.fillStyle = `hsl(${hue} 72% 55%)`;
      const x = margin + i * barW;
      const y = h - margin - barH;
      ctx.fillRect(x, y, Math.max(1, barW - 2 * dpr), barH);
    }

    rafId = requestAnimationFrame(draw);
  }

  if(rafId) cancelAnimationFrame(rafId);
  draw();
}

function stopDraw(){
  if(rafId) cancelAnimationFrame(rafId);
  rafId = null;
  const ctx = canvas.getContext('2d');
  ctx.clearRect(0,0,canvas.width,canvas.height);
}

// Try to load example.mp3 only when the user presses Play for the first time
async function loadDefaultAndPlay(){
  if(currentFileName) return; // user already picked a file
  const a = new Audio();
  a.crossOrigin = 'anonymous';
  a.loop = true;

  try{
    // HEAD-check to avoid long waits if file missing
    const resp = await fetch('example.mp3', { method: 'HEAD' });
    if(!resp.ok) throw new Error('not found');
    a.src = 'example.mp3';
  }catch(e){
    meta.textContent = 'example.mp3 not found; choose a local file.';
    return;
  }

  audioEl = a;
  connectAudioElement(audioEl);

  try{
    await audioEl.play();
    isPlaying = true;
    playBtn.textContent = 'Pause';
    currentFileName = 'example.mp3';
    meta.textContent = `Playing example.mp3 (default) — looping`;
    startDraw();
  }catch(err){
    meta.textContent = 'Autoplay blocked. Click Play again or choose a file.';
  }
}

function loadLocalFile(file){
  if(!file) return;

  if(audioEl){
    try{ audioEl.pause(); }catch(e){}
    try{ audioEl.src = ''; }catch(e){}
    try{ audioEl.remove(); }catch(e){}
    audioEl = null;
  }

  const a = new Audio();
  a.crossOrigin = 'anonymous';
  a.loop = true;
  a.src = URL.createObjectURL(file);

  audioEl = a;
  connectAudioElement(audioEl);

  audioEl.play().then(()=>{
    isPlaying = true;
    playBtn.textContent = 'Pause';
    currentFileName = file.name;
    meta.textContent = `Playing ${file.name} — looping`;
    startDraw();
  }).catch(err=>{
    meta.textContent = 'Playback blocked: user gesture required';
  });
}

playBtn.addEventListener('click', async ()=>{
  ensureAudioContext();

  if(!isPlaying){
    if(!audioEl){
      await loadDefaultAndPlay();
      if(!audioEl){
        meta.textContent = 'No audio loaded. Choose a local file.';
        return;
      }
    } else {
      if(audioCtx.state === 'suspended') await audioCtx.resume();
      try{
        await audioEl.play();
        isPlaying = true;
        playBtn.textContent = 'Pause';
        startDraw();
        meta.textContent = currentFileName ? `Playing ${currentFileName}` : 'Playing';
      }catch(err){
        meta.textContent = 'Playback failed. Choose a local file.';
      }
    }
  } else {
    if(audioEl) audioEl.pause();
    isPlaying = false;
    playBtn.textContent = 'Play';
    meta.textContent = currentFileName ? `Paused ${currentFileName}` : 'Paused';
    stopDraw();
  }
});

fileInput.addEventListener('change',(ev)=>{
  const f = ev.target.files && ev.target.files[0];
  if(!f) return;
  currentFileName = f.name;
  loadLocalFile(f);
});

meta.textContent = 'Click Play to try loading example.mp3, or choose a local file.';
</script>

</body>
</html>

Tips and small improvements

More JavaScript tutorials:

Oldschool fire effect (20 lines)

Fireworks (60 lines)

Animated fractal (32 lines)

Physics engine for beginners

Yin Yang with a twist (4 circles and 20 lines)