Build a Minimal Spectrum Analyzer
Click the 'Play' button below to test
What you will learn
- How to use a file input for local audio files
- How to create an AudioContext, MediaElementSource, and AnalyserNode
- How to map analyser frequency bins to visual bars
- How to add Play/Pause and fallback behavior for a default file
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
- Add a peak-hold indicator per bar by storing previous heights and applying a decay.
- Use logarithmic bin mapping to give more visual space to bass frequencies.
- Expose a small settings panel for bar count or fftSize if you want more control.