fft spectrum
A live spectrum analyser fed by the same voices the drum machine and Karplus-Strong pages use — synthesis seen from the other side.
the transform
Everything else in the audio section builds sounds. This one takes them apart. A fast Fourier transform converts a short window of samples into the strength of each frequency it contains. The discrete transform it computes is a single sum,
taking samples to complex numbers , one per frequency. The height drawn above is , the magnitude of each — so the top panel is a live answer to "what is in this sound", and the strip below it is the raw waveform those frequencies add up to. Note the shape of that sum: it is exactly the additive synth run backwards. One builds a signal from a weighted sum over frequencies; this recovers the weights from the signal.
the same voices
The one-shot buttons are not imitations. The pluck, kick, snare, hat and clap here run the same functions as the karplus-strong and drum machine pages — those voices were moved into a shared module so this page could play them rather than reimplement them, which means what you are measuring really is what those demos produce. Hit kick and watch the peak slide down the axis: that is the 150 Hz to 40 Hz pitch sweep, visible as motion rather than inferred. Hit hat and the energy is spread across the top of the range instead of concentrated anywhere, because it is filtered noise with no pitch to speak of.
checking the synth
Turn on the harmonic guides and this becomes a test of the additive synth page rather than an illustration. That page claims a square wave is odd harmonics only, a sawtooth is every harmonic, and a triangle is odd harmonics falling much faster. The guides mark every integer multiple of the current tone, drawn green where that shape is predicted to have a harmonic and red where it is not. Play a square and every red guide sits over an empty gap; switch to sawtooth and they all turn green with a peak underneath. The prediction and the measurement come from completely separate code, which is what makes them agreeing worth anything.
size and resolution
The fft size control exposes the trade-off that governs all of this. The window is split into half as many frequency bins as it has samples, each one
so at a 48 kHz sample rate gives bins about 6 Hz wide, while gives bins about 94 Hz wide — fine enough to separate two close harmonics in the first case, hopeless in the second. But those two expressions are reciprocals, which is the whole problem: every bin you gain in frequency costs you time resolution exactly. A larger window covers more time, so anything that changes quickly gets averaged across it. Trigger the kick at 8192 and the pitch sweep smears into a blur; drop to 512 and the sweep is crisp while the frequency reading turns vague. You can have precision in time or in frequency, and buying one spends the other.
artefacts
Two artefacts are worth naming, because both are visible above rather than theoretical. Cutting a finite window out of a continuous signal is itself an operation, and it smears each true frequency into a small spread around it — spectral leakage, which is why even a pure sine shows a narrow hill rather than a single spike. The usual mitigation is to taper the window at both ends before transforming, and the browser's analyser applies a Blackman window for exactly this reason. The second is that the analyser reports magnitude and discards phase: the picture tells you which frequencies are present and how loudly, but not how they line up, so you could not rebuild the waveform from what is drawn.
cooley-tukey
The algorithm has a good origin story. A naive transform costs O(n²) and the Cooley-Tukey algorithm brings that to O(n log n), which is the difference between this page running at sixty frames a second and not running at all. Their 1965 paper made it a standard tool — but Gauss had worked out the same decomposition around 1805, while interpolating the orbits of the asteroids Juno and Pallas, and never published it. It surfaced posthumously, in Neo-Latin, and went unnoticed for a century and a half.
view source
import { useEffect, useRef, useState } from 'react';
import {
DRUM_VOICES,
noiseBuffer,
pluckBuffer,
type DrumVoice,
} from '../../lib/voices';
type OscShape = 'sine' | 'square' | 'sawtooth' | 'triangle';
const SHAPES: OscShape[] = ['sine', 'square', 'sawtooth', 'triangle'];
const ONE_SHOTS: (DrumVoice | 'pluck')[] = [
'pluck',
'kick',
'snare',
'hat',
'clap',
];
const FFT_SIZES = [512, 1024, 2048, 4096, 8192];
const F_MIN = 30;
// Which harmonics each shape actually contains, for the overlay. This is the
// prediction the additive-synth page makes; the spectrum is where you get to
// check it.
const HARMONIC_RULE: Record<OscShape, (n: number) => boolean> = {
sine: (n) => n === 1,
square: (n) => n % 2 === 1,
sawtooth: () => true,
triangle: (n) => n % 2 === 1,
};
export default function FftSpectrum() {
const [shape, setShape] = useState<OscShape | 'noise' | null>('square');
const [freq, setFreq] = useState(220);
const [fftSize, setFftSize] = useState(4096);
const [logScale, setLogScale] = useState(true);
const [showHarmonics, setShowHarmonics] = useState(true);
const [peakHold, setPeakHold] = useState(false);
const [running, setRunning] = useState(false);
const [readout, setReadout] = useState<string>('');
const ctxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const outRef = useRef<GainNode | null>(null);
const srcRef = useRef<AudioScheduledSourceNode | null>(null);
const rafRef = useRef<number | null>(null);
const peaksRef = useRef<Float32Array | null>(null);
const specRef = useRef<HTMLCanvasElement | null>(null);
const waveRef = useRef<HTMLCanvasElement | null>(null);
const ensure = () => {
let ctx = ctxRef.current;
if (!ctx) {
ctx = new AudioContext();
ctxRef.current = ctx;
const analyser = ctx.createAnalyser();
analyser.fftSize = fftSize;
analyser.smoothingTimeConstant = 0.6;
const out = ctx.createGain();
out.gain.value = 0.5;
// Sources feed the analyser, which passes through to the speakers, so
// what is measured is exactly what is heard.
analyser.connect(out).connect(ctx.destination);
analyserRef.current = analyser;
outRef.current = out;
}
if (ctx.state === 'suspended') ctx.resume();
return { ctx, analyser: analyserRef.current!, out: outRef.current! };
};
useEffect(() => {
if (analyserRef.current) {
analyserRef.current.fftSize = fftSize;
peaksRef.current = null;
}
}, [fftSize]);
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
ctxRef.current?.close();
};
}, []);
const stopSource = () => {
try {
srcRef.current?.stop();
} catch {
/* already stopped */
}
srcRef.current = null;
setRunning(false);
};
// The shape is passed in rather than read from state: a click sets state
// and starts the source in the same tick, so reading `shape` here would
// pick up the *previous* value and play the wrong waveform.
const startSustained = (which: OscShape | 'noise' = shape ?? 'square') => {
const { ctx, analyser } = ensure();
stopSource();
let node: AudioScheduledSourceNode;
if (which === 'noise') {
const src = ctx.createBufferSource();
src.buffer = noiseBuffer(ctx, 2);
src.loop = true;
node = src;
} else {
const osc = ctx.createOscillator();
osc.type = which;
osc.frequency.value = freq;
node = osc;
}
node.connect(analyser);
node.start();
srcRef.current = node;
setRunning(true);
};
useEffect(() => {
const src = srcRef.current;
if (running && src && src instanceof OscillatorNode && shape !== 'noise') {
src.type = shape as OscShape;
src.frequency.setTargetAtTime(freq, ctxRef.current!.currentTime, 0.01);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shape, freq, running]);
const selectShape = (s: OscShape | 'noise') => {
setShape(s);
const src = srcRef.current;
if (running && src instanceof OscillatorNode && s !== 'noise') {
src.type = s;
} else {
startSustained(s);
}
};
const trigger = (id: DrumVoice | 'pluck') => {
const { ctx, analyser } = ensure();
if (id === 'pluck') {
const src = ctx.createBufferSource();
src.buffer = pluckBuffer(ctx, freq, 0.996);
src.connect(analyser);
src.start();
} else {
DRUM_VOICES[id](ctx, analyser, ctx.currentTime);
}
};
// ---- drawing ----
useEffect(() => {
const spec = specRef.current;
const wave = waveRef.current;
if (!spec || !wave) return;
const draw = () => {
rafRef.current = requestAnimationFrame(draw);
const analyser = analyserRef.current;
const ctxAudio = ctxRef.current;
if (!analyser || !ctxAudio) return;
const bins = analyser.frequencyBinCount;
const data = new Uint8Array(bins);
analyser.getByteFrequencyData(data);
const nyquist = ctxAudio.sampleRate / 2;
const binHz = nyquist / bins;
if (peakHold) {
if (!peaksRef.current || peaksRef.current.length !== bins) {
peaksRef.current = new Float32Array(bins);
}
const pk = peaksRef.current;
for (let i = 0; i < bins; i++) {
pk[i] = Math.max(data[i], pk[i] - 0.35);
}
}
const dpr = window.devicePixelRatio || 1;
const w = spec.clientWidth;
const h = spec.clientHeight;
if (spec.width !== w * dpr || spec.height !== h * dpr) {
spec.width = w * dpr;
spec.height = h * dpr;
}
const g = spec.getContext('2d');
if (!g) return;
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, w, h);
const fMax = nyquist;
const xOf = (f: number) =>
logScale
? (Math.log(Math.max(f, F_MIN) / F_MIN) / Math.log(fMax / F_MIN)) * w
: (f / fMax) * w;
// frequency gridlines
g.strokeStyle = '#1e2125';
g.fillStyle = '#5f656d';
g.font = '9px monospace';
g.lineWidth = 1;
const ticks = logScale
? [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
: [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000];
for (const f of ticks) {
if (f > fMax) continue;
const x = xOf(f);
g.beginPath();
g.moveTo(x, 0);
g.lineTo(x, h - 12);
g.stroke();
g.fillText(f >= 1000 ? `${f / 1000}k` : String(f), x + 2, h - 3);
}
// predicted harmonics for the current shape
// Every integer multiple gets a guide, but the ones this shape is
// predicted to contain are drawn brightly and the ones it should not
// are dimmed. A square then shows bright guides sitting on peaks and
// dim guides sitting over empty gaps, which is the comparison worth
// seeing -- drawing only the predicted harmonics would hide it.
if (showHarmonics && shape && shape !== 'noise' && running) {
const rule = HARMONIC_RULE[shape];
for (let n = 1; n * freq <= fMax && n <= 64; n++) {
const x = xOf(n * freq);
g.strokeStyle = rule(n)
? 'rgba(124, 242, 156, 0.4)'
: 'rgba(160, 120, 120, 0.35)';
g.beginPath();
g.moveTo(x, 0);
g.lineTo(x, h - 12);
g.stroke();
}
}
// the spectrum itself
const floorY = h - 12;
g.beginPath();
g.moveTo(0, floorY);
for (let i = 1; i < bins; i++) {
const f = i * binHz;
const x = xOf(f);
const y = floorY - (data[i] / 255) * (floorY - 4);
g.lineTo(x, y);
}
g.lineTo(w, floorY);
g.closePath();
g.fillStyle = 'rgba(124, 242, 156, 0.18)';
g.fill();
g.strokeStyle = '#7cf29c';
g.lineWidth = 1.2;
g.beginPath();
for (let i = 1; i < bins; i++) {
const x = xOf(i * binHz);
const y = floorY - (data[i] / 255) * (floorY - 4);
if (i === 1) g.moveTo(x, y);
else g.lineTo(x, y);
}
g.stroke();
if (peakHold && peaksRef.current) {
const pk = peaksRef.current;
g.strokeStyle = 'rgba(230, 230, 230, 0.55)';
g.lineWidth = 1;
g.beginPath();
for (let i = 1; i < bins; i++) {
const x = xOf(i * binHz);
const y = floorY - (pk[i] / 255) * (floorY - 4);
if (i === 1) g.moveTo(x, y);
else g.lineTo(x, y);
}
g.stroke();
}
// strongest bin, reported in Hz
let maxI = 1;
for (let i = 2; i < bins; i++) if (data[i] > data[maxI]) maxI = i;
setReadout(
data[maxI] > 8
? `loudest ${Math.round(maxI * binHz)} Hz · ${bins} bins · ${binHz.toFixed(1)} Hz each`
: `${bins} bins · ${binHz.toFixed(1)} Hz each`,
);
// time domain
const tw = wave.clientWidth;
const th = wave.clientHeight;
if (wave.width !== tw * dpr || wave.height !== th * dpr) {
wave.width = tw * dpr;
wave.height = th * dpr;
}
const gw = wave.getContext('2d');
if (!gw) return;
const td = new Uint8Array(analyser.fftSize);
analyser.getByteTimeDomainData(td);
gw.setTransform(dpr, 0, 0, dpr, 0, 0);
gw.clearRect(0, 0, tw, th);
gw.strokeStyle = '#2a2d31';
gw.beginPath();
gw.moveTo(0, th / 2);
gw.lineTo(tw, th / 2);
gw.stroke();
gw.strokeStyle = '#7cf29c';
gw.lineWidth = 1.2;
gw.beginPath();
const step = Math.max(1, Math.floor(td.length / tw / 2));
for (let x = 0, i = 0; i < td.length / 2; i += step, x++) {
const y = th / 2 - ((td[i] - 128) / 128) * (th / 2 - 3);
if (i === 0) gw.moveTo(0, y);
else gw.lineTo(x, y);
}
gw.stroke();
};
rafRef.current = requestAnimationFrame(draw);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [logScale, showHarmonics, peakHold, shape, freq, running]);
return (
<div className="fft">
<canvas ref={specRef} className="spec" aria-label="frequency spectrum" />
<canvas ref={waveRef} className="wave" aria-label="waveform" />
<p className="readout">{readout}</p>
<div className="controls">
<span className="group-label">tone</span>
{SHAPES.map((s) => (
<button
key={s}
type="button"
className={shape === s && running ? 'active' : ''}
onClick={() => selectShape(s)}
>
{s}
</button>
))}
<button
type="button"
className={shape === 'noise' && running ? 'active' : ''}
onClick={() => selectShape('noise')}
>
noise
</button>
<button
type="button"
onClick={() => (running ? stopSource() : startSustained())}
>
{running ? 'stop' : 'play'}
</button>
</div>
<div className="controls">
<label className="freq">
{freq} Hz
<input
type="range"
min={55}
max={2000}
step={1}
value={freq}
onChange={(e) => setFreq(Number(e.target.value))}
aria-label="tone frequency"
/>
</label>
</div>
<div className="controls">
<span className="group-label">hit</span>
{ONE_SHOTS.map((id) => (
<button key={id} type="button" onClick={() => trigger(id)}>
{id}
</button>
))}
</div>
<div className="controls">
<span className="group-label">fft size</span>
{FFT_SIZES.map((n) => (
<button
key={n}
type="button"
className={n === fftSize ? 'active' : ''}
onClick={() => setFftSize(n)}
>
{n}
</button>
))}
<label className="toggle">
<input
type="checkbox"
checked={logScale}
onChange={(e) => setLogScale(e.target.checked)}
/>
log frequency
</label>
<label className="toggle">
<input
type="checkbox"
checked={showHarmonics}
onChange={(e) => setShowHarmonics(e.target.checked)}
/>
harmonic guides
</label>
<label className="toggle">
<input
type="checkbox"
checked={peakHold}
onChange={(e) => {
setPeakHold(e.target.checked);
peaksRef.current = null;
}}
/>
peak hold
</label>
</div>
<style>{`
.fft {
border: 1px solid #2a2d31;
background: #0e1113;
padding: 1.25rem 1rem;
}
.fft .spec {
display: block;
width: 100%;
height: 13rem;
border: 1px solid #2a2d31;
background: #0b0d0f;
}
.fft .wave {
display: block;
width: 100%;
height: 4rem;
margin-top: 0.4rem;
border: 1px solid #2a2d31;
background: #0b0d0f;
}
.fft .readout {
color: var(--muted);
font-size: 0.72rem;
margin: 0.35rem 0 0;
min-height: 1rem;
}
.fft .controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-top: 0.7rem;
}
.fft .group-label {
color: var(--muted);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
min-width: 3.6rem;
}
.fft button {
font-family: inherit;
font-size: 0.75rem;
color: #8a8f98;
background: rgba(0, 0, 0, 0.4);
border: 1px solid #2a2d31;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.fft button:hover {
color: #e6e6e6;
}
.fft button.active {
color: #0b0d0f;
background: var(--accent);
border-color: var(--accent);
}
.fft .freq {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--muted);
font-size: 0.78rem;
}
.fft .freq input {
flex: 1;
max-width: 22rem;
}
.fft .toggle {
display: flex;
align-items: center;
gap: 0.3rem;
color: var(--muted);
font-size: 0.72rem;
}
`}</style>
</div>
);
}