Skip to content

All experiments

Audio-reactive

AnalyserNode · frequency bands

Sound turns into numbers, numbers into the shape of the scene. The switch only fires on a click.

A spectrum plot: forty bands stack up as ridges, newer ones hide older ones, and a copper marker sits above the loudest band.

This browser has no Web Audio. The description and the code below stay.

The browser refused to hand over an audio context. Power saving does that, and so does blocked autoplay.

Without microphone permission the synthesis plays instead. Nothing broke.

demo chunk: 3.8 KB

The microphone is optional. Nothing is recorded and nothing is sent: the stream reaches the analyser and ends there, and the code holds no recorder and no network call.

The sound dies when you switch tabs or leave the page. Coming back takes another click.

What's hard here

AnalyserNode is not an effect. It is a window onto a buffer: every frame it hands back 1024 numbers between 0 and 255, one per frequency bin. Bins sit evenly in hertz while hearing works in octaves, so the forty bands of this plot take their edges geometrically. The lowest band covers a single bin, the highest a few dozen.

No browser will play a sound without a gesture. An AudioContext created at page load comes up suspended and stays there until a user event touches it. Autoplay policy is a fact of the platform, so I built on it: the context is born inside the click handler, and before that handler runs there is not one audio node in existence.

The sound is not a file in this repository. An eight-second loop renders offline from two detuned sines, noise with a fixed seed and a handful of hits pushed through filters. The same render on every visit means the same numbers in the analysis, so today's plot can be held against yesterday's.

The picture is not a bar meter. Each row is one pass of the spectrum, newer rows hide older ones behind a fill in the frame colour, and total energy lifts brightness and grain density through the same token that grains the whole site. Copper goes to the peak of the spectrum and nowhere else.

What it weighs

The neighbour upstairs, the particle field, pulls 278 KB of lazy code. The dithering pass next to it weighs 3.3 KB. This demo chunk is 3.8 KB gzipped and carries no audio bytes whatsoever. A minute of decent mp3 runs to some two megabytes; this loop is a few dozen lines that the browser turns into eight seconds of material. The cost is labour, because the sequence has to be composed rather than picked.

How it's built

/** Krawędzie pasm w indeksach koszyków FFT. AnalyserNode oddaje koszyki
    równomiernie po częstotliwości (sampleRate / fftSize na koszyk), a my
    chcemy pasm równomiernych po SŁUCHU, więc krawędzie idą geometrycznie od
    45 Hz do 11 kHz i każde pasmo dostaje przynajmniej jeden koszyk. */
function bandEdges(sampleRate: number, bins: number): Int32Array {
  const edges = new Int32Array(BANDS + 1);
  const perBin = sampleRate / (bins * 2);
  const low = 45;
  const high = Math.min(11000, sampleRate / 2 - perBin);
  let previous = 0;
  for (let i = 0; i <= BANDS; i += 1) {
    const hz = low * Math.pow(high / low, i / BANDS);
    const bin = Math.round(hz / perBin);
    previous = i === 0 ? bin : Math.max(bin, previous + 1);
    edges[i] = Math.min(bins, previous);
  }
  return edges;
}

/** Jedna próbka analizy: widmo na pasma, energia całości, szczyt, poziom w
    dBFS. getByteFrequencyData daje 0..255 na koszyk (już po oknie i
    logarytmie amplitudy), getByteTimeDomainData daje surowy przebieg wokół
    128 — z niego liczy się poziom, bo widmo nie mówi nic o głośności
    chwilowej. */
function sample(
  analyser: AnalyserNode,
  edges: Int32Array,
  spectrum: Bytes,
  wave: Bytes,
  out: Float32Array,
): { energy: number; peak: number; level: number } {
  analyser.getByteFrequencyData(spectrum);
  analyser.getByteTimeDomainData(wave);

  let energy = 0;
  let peak = 0;
  for (let band = 0; band < BANDS; band += 1) {
    const from = edges[band];
    const to = Math.max(from + 1, edges[band + 1]);
    let total = 0;
    for (let bin = from; bin < to; bin += 1) total += spectrum[bin];
    // lekka krzywa w górę: ciche pasma mają zostać widoczne, nie płaskie
    const value = Math.pow(total / (to - from) / 255, 0.85);
    out[band] = value;
    energy += value;
    if (value > out[peak]) peak = band;
  }
  energy /= BANDS;

  let square = 0;
  for (let i = 0; i < wave.length; i += 1) {
    const centered = (wave[i] - 128) / 128;
    square += centered * centered;
  }
  const rms = Math.sqrt(square / wave.length);
  const level = rms > 0 ? Math.max(-60, 20 * Math.log10(rms)) : -60;

  return { energy, peak, level };
}

if (source === "mic") {
  try {
    stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: false,
        noiseSuppression: false,
        autoGainControl: false,
      },
    });
  } catch {
    /* Odmowa zgody NIE jest błędem: przełączamy źródło na syntezę i gramy
       dalej. Nota pod paskiem mówi, co się stało. */
    stream = null;
    root.dataset.labAudioMic = "denied";
    setSource("synth");
  }
}

if (stream) {
  /* Mikrofon wchodzi TYLKO do analizatora, a analizator do wzmocnienia
     ZERO. Wzmocnienie zero istnieje z jednego powodu: graf odcięty od
     wyjścia nie musi być przez przeglądarkę przetwarzany, a bez
     przetwarzania analizator nie dostaje próbek. Na wyjście nie idzie
     przy tym ani jedna próbka mikrofonu (stąd brak sprzężenia), w całym
     module nie ma MediaRecorder ani jednego wywołania sieciowego, więc
     „nic nie jest nagrywane ani wysyłane" jest własnością tego grafu, a
     nie obietnicą w copy. */
  const silent = ctx.createGain();
  silent.gain.value = 0;
  ctx.createMediaStreamSource(stream).connect(analyser);
  analyser.connect(silent);
  silent.connect(ctx.destination);
} else {
  if (!sequence || sequenceRate !== ctx.sampleRate) {
    sequence = await renderSequence(ctx.sampleRate);
    sequenceRate = ctx.sampleRate;
  }
  if (disposed || mine !== generation) {
    void ctx.close();
    return;
  }
  const player = ctx.createBufferSource();
  player.buffer = sequence;
  player.loop = true;
  master = ctx.createGain();
  master.gain.setValueAtTime(0.0001, ctx.currentTime);
  master.gain.linearRampToValueAtTime(0.9, ctx.currentTime + 0.15);
  player.connect(master);
  master.connect(analyser);
  // synteza idzie na wyjście PRZEZ analizator: mierzone jest dokładnie
  // to, co słyszy gość
  analyser.connect(ctx.destination);
  player.start();
}
Get a quote