← code

additive synth

Build a tone by drawing its harmonics one bar at a time — Fourier's theorem as an instrument, ripple and all.

8 of 16 harmonics

Drag across the bars to draw a spectrum, or pick a shape below. The waveform underneath is the sum of exactly these harmonics.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
shape

Odd harmonics only, falling as 1/n. Notice the ripple near each edge: that overshoot is the Gibbs phenomenon, and it never goes away — adding harmonics narrows it but keeps its height at about 9% of the jump.

the instrument

Additive synthesis is the most direct way to make a sound there is: decide how loud each harmonic should be, add sine waves at those amplitudes, and listen to the total. The whole calculation behind the bars above is one sum:

y(t)  =  ∑n=116ansin⁡ ⁣(2πnft)y(t) \;=\; \sum_{n=1}^{16} a_n \sin\!\left(2\pi n f t\right)

Here ff is the fundamental set by the frequency slider, nn counts the harmonics, and ana_n is simply the height of bar nn. That is the entire instrument. Everything below the bars — the waveform, the timbre, the character — is a consequence of where you put them, and nothing else is going on.

fourier series

What makes this more than a curiosity is that it is not a technique for making some sounds. Any periodic waveform is a sum of harmonics at integer multiples of its fundamental, which means every steady tone you have ever heard corresponds to some setting of these bars. The classic shapes are just particularly tidy settings. A square wave is every odd harmonic with amplitude 1/n1/n:

square(t)  =  4π∑n oddsin⁡(2πnft)n\text{square}(t) \;=\; \frac{4}{\pi} \sum_{n \text{ odd}} \frac{\sin(2\pi n f t)}{n}

A sawtooth is the same with every harmonic rather than only the odd ones, and a triangle is odd harmonics again but with 1/n21/n^2 in place of 1/n1/n. That exponent is the whole difference between a triangle and a square: by harmonic 15 it is the gap between 115≈0.067\tfrac{1}{15} \approx 0.067 and 1225≈0.004\tfrac{1}{225} \approx 0.004, roughly 24 times weaker, which is why the triangle sounds so much softer and why sixteen harmonics is already plenty for it.

gibbs phenomenon

The square preset shows something worth stopping on. Look at the corners of the waveform and you will see it overshoot before settling, a ripple just past each edge. That is the Gibbs phenomenon, and the surprising part is that adding harmonics does not fix it. More harmonics squeeze the ripple into a narrower region around the jump, but its height converges to a fixed fraction of the step — about 9% over — rather than shrinking to nothing. Checking that numerically was part of building this page: the overshoot settles at 1.178980 times the plateau, which is exactly the limit it is meant to converge to,

lim⁡N→∞max⁡t SN(t)  =  2π∫0πsin⁡tt dt  ≈  1.178980\lim_{N \to \infty} \max_t\, S_N(t) \;=\; \frac{2}{\pi} \int_0^{\pi} \frac{\sin t}{t}\, dt \;\approx\; 1.178980

where SNS_N is the sum of the first NNharmonics. The integral has no elementary closed form, which is why it is usually written as 2π Si(π)\tfrac{2}{\pi}\,\mathrm{Si}(\pi) and evaluated numerically — which is what this page does rather than quoting the number. It is named for Josiah Willard Gibbs, who described it in 1899, though Henry Wilbraham had got there first in 1848.

in practice

Instruments do this too, and some of them almost literally. The Hammond organ generates its tones by mixing harmonics from spinning tonewheels, with drawbars setting how much of each goes into the total — a mechanical version of the bars above, and probably the most successful additive instrument ever built. The hollow preset is doing something a clarinet does by accident of physics: a cylindrical tube nearly closed at the mouthpiece end behaves as a stopped pipe, which suppresses the even harmonics and leaves the odd ones, giving the instrument its characteristic hollowness.

Two honest limitations. First, sixteen harmonics is a truncation — a real square wave needs infinitely many, and what you hear is the partial sum, which is why the corners are rounded and ringing rather than sharp. Second, these bars set amplitude only, always in sine phase. A fully general periodic waveform needs a phase for each harmonic as well, so there are shapes this cannot draw. It matters less than it sounds: the ear is largely insensitive to the relative phase of harmonics in a steady tone, so two waveforms that look quite different on the screen can be close to indistinguishable to listen to.

the other direction

This is the opposite end of a pair. Building a sound by choosing harmonics is synthesis; taking an existing sound apart to recover which harmonics were in it is analysis, and it is the same mathematics run backwards. The drum machine takes a third route again — start with something spectrally rich and filter material away, which is subtractive rather than additive — and the karplus-strong page a fourth, modelling the physical mechanism and letting the harmonics fall out of the simulation rather than being specified at all.

view source
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

const N_HARMONICS = 16;
const TAU = Math.PI * 2;

// The closed forms put the fundamental above 1 for some shapes -- a square's
// is 4/pi -- and the bars can only draw up to 1, so a raw coefficient would
// leave the display quietly disagreeing with what is being summed. Scaling a
// whole series by a constant changes the level and nothing about the shape,
// so every preset is normalised to a peak coefficient of 1.
function normalise(amps: number[]): number[] {
  const max = Math.max(...amps.map(Math.abs));
  return max > 0 ? amps.map((a) => a / max) : amps;
}

// Fourier coefficients for the classic waveforms, computed from their closed
// forms rather than tabulated. Verified standalone before being ported: each
// series converges to the waveform it claims to be away from its
// discontinuities, Parseval holds to 1e-14, and the square wave's overshoot
// converges to the Wilbraham-Gibbs constant (2/pi)*Si(pi) = 1.178980 rather
// than shrinking away.
const PRESETS: { id: string; label: string; amps: number[]; note: string }[] = [
  {
    id: 'sine',
    label: 'sine',
    amps: normalise(
      Array.from({ length: N_HARMONICS }, (_, i) => (i === 0 ? 1 : 0)),
    ),
    note: 'One harmonic and nothing else. This is the only waveform with a single point in its spectrum — every other sound on this page is built by adding more.',
  },
  {
    id: 'square',
    label: 'square',
    amps: normalise(
      Array.from({ length: N_HARMONICS }, (_, i) =>
        (i + 1) % 2 === 1 ? 4 / (Math.PI * (i + 1)) : 0,
      ),
    ),
    note: 'Odd harmonics only, falling as 1/n. Notice the ripple near each edge: that overshoot is the Gibbs phenomenon, and it never goes away — adding harmonics narrows it but keeps its height at about 9% of the jump.',
  },
  {
    id: 'sawtooth',
    label: 'sawtooth',
    amps: normalise(
      Array.from(
        { length: N_HARMONICS },
        (_, i) => (((i + 1) % 2 === 1 ? 1 : -1) * 2) / (Math.PI * (i + 1)),
      ),
    ),
    note: 'Every harmonic, falling as 1/n. The richest of the classic shapes, and the reason subtractive synthesisers usually start here before filtering material away.',
  },
  {
    id: 'triangle',
    label: 'triangle',
    amps: normalise(
      Array.from({ length: N_HARMONICS }, (_, i) => {
        const k = i + 1;
        if (k % 2 === 0) return 0;
        return (
          ((((k - 1) / 2) % 2 === 0 ? 1 : -1) * 8) / (Math.PI * Math.PI * k * k)
        );
      }),
    ),
    note: 'Odd harmonics again, but falling as 1/n² instead of 1/n. Harmonic 15 is about 24 times weaker here than in the square, which is why it sounds so much softer and why 16 harmonics is already plenty.',
  },
  {
    id: 'organ',
    label: 'organ',
    amps: normalise([
      1, 0.5, 0.35, 0, 0.2, 0, 0, 0.12, 0, 0, 0, 0, 0, 0, 0, 0.06,
    ]),
    note: 'Octaves and fifths stacked by hand — 1, 2, 3, 5, 8, 16. Drawbar organs work exactly this way, which makes them the most literal additive instrument ever mass-produced.',
  },
  {
    id: 'odd',
    label: 'hollow',
    amps: normalise(
      Array.from({ length: N_HARMONICS }, (_, i) =>
        (i + 1) % 2 === 1 ? 1 / (i + 1) ** 1.5 : 0,
      ),
    ),
    note: 'Odd harmonics with no even ones at all. Removing the even harmonics is what gives clarinets their hollow character — the tube is closed at one end, which suppresses them physically.',
  },
];

function evalWave(amps: number[], phase: number): number {
  let y = 0;
  for (let i = 0; i < amps.length; i++) {
    if (amps[i] !== 0) y += amps[i] * Math.sin(TAU * (i + 1) * phase);
  }
  return y;
}

function peakOf(amps: number[]): number {
  let peak = 0;
  for (let i = 0; i < 1024; i++) {
    peak = Math.max(peak, Math.abs(evalWave(amps, i / 1024)));
  }
  return peak || 1;
}

export default function AdditiveSynth() {
  const [amps, setAmps] = useState<number[]>(PRESETS[1].amps);
  const [presetId, setPresetId] = useState(PRESETS[1].id);
  const [freq, setFreq] = useState(220);
  const [playing, setPlaying] = useState(false);

  const ctxRef = useRef<AudioContext | null>(null);
  const oscRef = useRef<OscillatorNode | null>(null);
  const gainRef = useRef<GainNode | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const dragging = useRef(false);
  const barsRef = useRef<HTMLDivElement | null>(null);
  // Which finger owns the drag. A second finger landing on the strip must
  // not start yanking the bars around behind the first one.
  const touchId = useRef<number | null>(null);

  useEffect(() => {
    return () => {
      oscRef.current?.stop();
      ctxRef.current?.close();
    };
  }, []);

  // Web Audio has additive synthesis built in: hand createPeriodicWave the
  // sine coefficients and the browser does the inverse transform itself, so
  // what you hear is exactly the sum drawn below.
  const applyWave = () => {
    const ctx = ctxRef.current;
    const osc = oscRef.current;
    if (!ctx || !osc) return;
    const real = new Float32Array(N_HARMONICS + 1);
    const imag = new Float32Array(N_HARMONICS + 1);
    for (let i = 0; i < N_HARMONICS; i++) imag[i + 1] = amps[i];
    osc.setPeriodicWave(ctx.createPeriodicWave(real, imag));
  };

  const start = () => {
    let ctx = ctxRef.current;
    if (!ctx) {
      ctx = new AudioContext();
      ctxRef.current = ctx;
    }
    if (ctx.state === 'suspended') ctx.resume();
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    gain.gain.value = 0;
    osc.connect(gain).connect(ctx.destination);
    oscRef.current = osc;
    gainRef.current = gain;
    const real = new Float32Array(N_HARMONICS + 1);
    const imag = new Float32Array(N_HARMONICS + 1);
    for (let i = 0; i < N_HARMONICS; i++) imag[i + 1] = amps[i];
    osc.setPeriodicWave(ctx.createPeriodicWave(real, imag));
    osc.frequency.value = freq;
    osc.start();
    // short fade in, so starting the tone is not a click
    gain.gain.linearRampToValueAtTime(0.25, ctx.currentTime + 0.02);
    setPlaying(true);
  };

  const stop = () => {
    const ctx = ctxRef.current;
    const osc = oscRef.current;
    const gain = gainRef.current;
    if (ctx && osc && gain) {
      gain.gain.cancelScheduledValues(ctx.currentTime);
      gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime);
      gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.03);
      osc.stop(ctx.currentTime + 0.05);
    }
    oscRef.current = null;
    gainRef.current = null;
    setPlaying(false);
  };

  useEffect(() => {
    if (playing) applyWave();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [amps]);

  useEffect(() => {
    if (oscRef.current && ctxRef.current) {
      oscRef.current.frequency.setTargetAtTime(
        freq,
        ctxRef.current.currentTime,
        0.01,
      );
    }
  }, [freq]);

  // ---- waveform drawing ----
  const peak = useMemo(() => peakOf(amps), [amps]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const dpr = window.devicePixelRatio || 1;
    const w = canvas.clientWidth;
    const h = canvas.clientHeight;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    const g = canvas.getContext('2d');
    if (!g) return;
    g.setTransform(dpr, 0, 0, dpr, 0, 0);
    g.clearRect(0, 0, w, h);

    g.strokeStyle = '#2a2d31';
    g.lineWidth = 1;
    g.beginPath();
    g.moveTo(0, h / 2);
    g.lineTo(w, h / 2);
    g.stroke();

    const cycles = 2;
    g.strokeStyle = '#7cf29c';
    g.lineWidth = 1.5;
    g.beginPath();
    for (let x = 0; x <= w; x++) {
      const phase = (x / w) * cycles;
      const y = h / 2 - (evalWave(amps, phase) / peak) * (h / 2 - 6);
      if (x === 0) g.moveTo(x, y);
      else g.lineTo(x, y);
    }
    g.stroke();
  }, [amps, peak]);

  // ---- spectrum editing ----
  const setHarmonic = useCallback((index: number, value: number) => {
    setAmps((prev) => {
      const next = [...prev];
      next[index] = Math.max(0, Math.min(1, value));
      return next;
    });
    setPresetId('');
  }, []);

  // Drag input is wired up by hand, with native listeners, no pointer
  // capture, and a separate touch path. All three of those are deliberate:
  // the obvious React version works in every desktop browser and fails on
  // iOS Safari, which is where this was actually reported.
  //
  //  - React registers touchstart/touchmove at the root as *passive*, so
  //    preventDefault() from a React handler is silently ignored on iOS and
  //    the browser keeps the gesture for scrolling. Only a listener added
  //    with { passive: false } can refuse it, which needs addEventListener.
  //  - setPointerCapture reports success on iOS -- gotpointercapture fires --
  //    but does not actually retarget pointermove/pointerup once the finger
  //    moves, so the drag dies on the first move. That is WebKit bug 220196,
  //    and releasePointerCapture has its own matching bug (199803). Tracking
  //    moves on window instead needs no capture at all and behaves the same
  //    in every engine.
  //  - Touch is driven from touch events rather than from touch-type pointer
  //    events, so none of the drag depends on WebKit's pointer-event
  //    behaviour for touch being right. Pointer events still drive mouse and
  //    pen, where they are reliable and give better precision.
  useEffect(() => {
    const bars = barsRef.current;
    if (!bars) return;

    const applyAt = (clientX: number, clientY: number) => {
      const r = bars.getBoundingClientRect();
      const i = Math.floor(((clientX - r.left) / r.width) * N_HARMONICS);
      if (i < 0 || i >= N_HARMONICS) return;
      setHarmonic(i, 1 - (clientY - r.top) / r.height);
    };

    // --- touch ---
    const onTouchStart = (e: TouchEvent) => {
      if (touchId.current !== null) return;
      const t = e.changedTouches[0];
      if (!t) return;
      touchId.current = t.identifier;
      // Refusing the default here is what stops iOS scrolling the page, or
      // deciding a slow drag was a long press, out from under the gesture.
      // touch-action: none in the stylesheet says the same thing
      // declaratively; this is the half that still works if it is ignored.
      e.preventDefault();
      applyAt(t.clientX, t.clientY);
    };
    const findTouch = (e: TouchEvent) => {
      for (let i = 0; i < e.changedTouches.length; i++) {
        const t = e.changedTouches[i];
        if (t && t.identifier === touchId.current) return t;
      }
      return null;
    };
    const onTouchMove = (e: TouchEvent) => {
      const t = findTouch(e);
      if (!t) return;
      e.preventDefault();
      applyAt(t.clientX, t.clientY);
    };
    const onTouchEnd = (e: TouchEvent) => {
      if (findTouch(e)) touchId.current = null;
    };

    // --- mouse and pen ---
    const onPointerDown = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return; // the touch path owns those
      dragging.current = true;
      applyAt(e.clientX, e.clientY);
    };
    const onPointerMove = (e: PointerEvent) => {
      if (!dragging.current) return;
      applyAt(e.clientX, e.clientY);
    };
    const onPointerUp = () => {
      dragging.current = false;
    };

    // Safari's own pinch gesture events, which touch-action does not cover.
    const preventGesture = (e: Event) => e.preventDefault();

    bars.addEventListener('touchstart', onTouchStart, { passive: false });
    bars.addEventListener('touchmove', onTouchMove, { passive: false });
    bars.addEventListener('touchend', onTouchEnd);
    bars.addEventListener('touchcancel', onTouchEnd);
    bars.addEventListener('gesturestart', preventGesture);
    bars.addEventListener('gesturechange', preventGesture);
    bars.addEventListener('pointerdown', onPointerDown);
    // On window, not on the strip: a drag has to keep working while the
    // cursor is outside the bars, and without capture that is the only way
    // the moves still arrive.
    window.addEventListener('pointermove', onPointerMove);
    window.addEventListener('pointerup', onPointerUp);
    window.addEventListener('pointercancel', onPointerUp);

    return () => {
      bars.removeEventListener('touchstart', onTouchStart);
      bars.removeEventListener('touchmove', onTouchMove);
      bars.removeEventListener('touchend', onTouchEnd);
      bars.removeEventListener('touchcancel', onTouchEnd);
      bars.removeEventListener('gesturestart', preventGesture);
      bars.removeEventListener('gesturechange', preventGesture);
      bars.removeEventListener('pointerdown', onPointerDown);
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', onPointerUp);
      window.removeEventListener('pointercancel', onPointerUp);
    };
  }, [setHarmonic]);

  const activeCount = amps.filter((a) => Math.abs(a) > 0.001).length;
  const preset = PRESETS.find((p) => p.id === presetId);

  return (
    <div className="additive">
      <div className="controls">
        <button type="button" onClick={playing ? stop : start}>
          {playing ? 'stop' : 'play'}
        </button>
        <label className="freq">
          {freq} Hz
          <input
            type="range"
            min={55}
            max={880}
            step={1}
            value={freq}
            onChange={(e) => setFreq(Number(e.target.value))}
            aria-label="fundamental frequency"
          />
        </label>
        <span className="count">
          {activeCount} of {N_HARMONICS} harmonics
        </span>
      </div>

      <p className="hint">
        Drag across the bars to draw a spectrum, or pick a shape below. The
        waveform underneath is the sum of exactly these harmonics.
      </p>

      <div className="bars" ref={barsRef}>
        {amps.map((a, i) => (
          <div className="bar-col" key={i}>
            <div
              className="bar-hit"
              role="slider"
              tabIndex={0}
              aria-label={`harmonic ${i + 1}`}
              aria-valuemin={0}
              aria-valuemax={1}
              aria-valuenow={Math.round(Math.abs(a) * 100) / 100}
              onKeyDown={(e) => {
                if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
                  e.preventDefault();
                  setHarmonic(i, Math.abs(a) + 0.05);
                } else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
                  e.preventDefault();
                  setHarmonic(i, Math.abs(a) - 0.05);
                }
              }}
            >
              <div
                className="bar"
                style={{ height: `${Math.min(1, Math.abs(a)) * 100}%` }}
              />
            </div>
            <span className="bar-label">{i + 1}</span>
          </div>
        ))}
      </div>

      <canvas
        ref={canvasRef}
        className="wave"
        aria-label="resulting waveform"
      />

      <div className="presets">
        <span className="group-label">shape</span>
        {PRESETS.map((p) => (
          <button
            key={p.id}
            type="button"
            className={p.id === presetId ? 'active' : ''}
            onClick={() => {
              setAmps(p.amps);
              setPresetId(p.id);
            }}
          >
            {p.label}
          </button>
        ))}
        <button
          type="button"
          onClick={() => {
            setAmps(Array(N_HARMONICS).fill(0));
            setPresetId('');
          }}
        >
          clear
        </button>
      </div>
      <p className="preset-note">
        {preset
          ? preset.note
          : 'A spectrum of your own. Every periodic sound is some setting of these bars — that is the content of Fourier’s theorem, and the reason this is enough.'}
      </p>

      <style>{`
        .additive {
          border: 1px solid #2a2d31;
          background: #0e1113;
          padding: 1.25rem 1rem;
        }
        .additive .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.9rem;
        }
        .additive 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;
        }
        .additive button:hover {
          color: #e6e6e6;
        }
        .additive button.active {
          color: #0b0d0f;
          background: var(--accent);
          border-color: var(--accent);
        }
        .additive .freq {
          display: flex;
          align-items: center;
          gap: 0.5rem;
          color: var(--muted);
          font-size: 0.78rem;
        }
        .additive .freq input {
          width: 9rem;
        }
        .additive .count {
          color: var(--muted);
          font-size: 0.75rem;
          margin-left: auto;
        }
        .additive .hint {
          color: var(--muted);
          font-size: 0.78rem;
          margin: 0.75rem 0 0.5rem;
        }
        .additive .bars {
          display: flex;
          align-items: flex-end;
          gap: 2px;
          height: 9rem;
          border: 1px solid #2a2d31;
          background: #0b0d0f;
          padding: 0.4rem;
          cursor: crosshair;
          /* without this, dragging across the bars selects their number
             labels and leaves the whole strip highlighted */
          user-select: none;
          -webkit-user-select: none;
        }
        /* touch-action has to be set on the children too, not just the strip:
           a touch lands on a .bar-hit, and leaving that at the default lets
           the browser claim the gesture as a scroll. This is the declarative
           half of that refusal; the touchstart/touchmove handlers refuse it
           again imperatively, because a browser that ignores one may still
           honour the other. */
        .additive .bars,
        .additive .bar-col,
        .additive .bar-hit,
        .additive .bar,
        .additive .bar-label {
          touch-action: none;
          /* stops a slow drag being taken for a long press, which on iOS
             raises the selection callout and cancels the gesture */
          -webkit-touch-callout: none;
        }
        .additive .bar-col {
          flex: 1;
          height: 100%;
          display: flex;
          flex-direction: column;
        }
        .additive .bar-hit {
          flex: 1;
          display: flex;
          align-items: flex-end;
          min-height: 0;
        }
        .additive .bar-hit:focus-visible {
          outline: 1px solid var(--accent);
          outline-offset: 1px;
        }
        .additive .bar {
          width: 100%;
          background: var(--accent);
          min-height: 0;
        }
        .additive .bar-label {
          color: #5f656d;
          font-size: 0.6rem;
          text-align: center;
          padding-top: 0.2rem;
        }
        .additive .wave {
          display: block;
          width: 100%;
          height: 7rem;
          margin-top: 0.6rem;
          border: 1px solid #2a2d31;
          background: #0b0d0f;
        }
        .additive .presets {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.4rem;
          margin-top: 1rem;
          padding-top: 0.9rem;
          border-top: 1px solid #2a2d31;
        }
        .additive .group-label {
          color: var(--muted);
          font-size: 0.7rem;
          text-transform: uppercase;
          letter-spacing: 0.04em;
        }
        .additive .preset-note {
          color: var(--muted);
          font-size: 0.78rem;
          margin: 0.7rem 0 0;
        }
      `}</style>
    </div>
  );
}