Przejdź do treści

Wszystkie eksperymenty

Audio-reactive

AnalyserNode · pasma częstotliwości

Dźwięk zamienia się w liczby, liczby w kształt sceny. Włącznik działa wyłącznie na klik.

Wykres widma dźwięku: czterdzieści pasm układa się w grzbiety, nowsze przesłaniają starsze, a miedziany znacznik stoi nad najgłośniejszym pasmem.

Ta przeglądarka nie daje Web Audio. Zostaje opis i kod pod spodem.

Przeglądarka nie wypuściła kontekstu dźwięku. Robi tak przy oszczędzaniu energii i przy zablokowanym autoodtwarzaniu.

Bez zgody na mikrofon gra synteza. Nic się nie zepsuło.

chunk demo: 3,8 KB

Mikrofon jest opcją. Nic nie jest nagrywane ani wysyłane: strumień dochodzi do analizatora i tam się kończy, a w kodzie nie ma nagrywania ani jednego zapytania do sieci.

Dźwięk gaśnie, kiedy zmienisz kartę albo wyjdziesz ze strony. Powrót wymaga kolejnego kliknięcia.

Co tu jest trudne

AnalyserNode nie jest efektem. To okno na bufor: co klatkę oddaje 1024 liczby od 0 do 255, po jednej na koszyk częstotliwości. Koszyki leżą równomiernie w hercach, a ucho pracuje w oktawach, więc czterdzieści pasm tego wykresu bierze krawędzie geometrycznie. Najniższe pasmo mieści jeden koszyk, najwyższe kilkadziesiąt.

Żadna przeglądarka nie zagra bez gestu. AudioContext utworzony przy wczytaniu strony wstaje w stanie suspended i zostaje w nim, dopóki nie dotknie go zdarzenie od użytkownika. Polityka autoodtwarzania jest faktem platformy, więc zrobiłem z niej konstrukcję: kontekst powstaje wewnątrz obsługi kliknięcia, a przed nią nie istnieje ani jeden węzeł dźwiękowy.

Dźwięku nie ma w repozytorium. Ośmiosekundowa pętla renderuje się offline z dwóch rozstrojonych sinusów, szumu o stałym ziarnie i kilku uderzeń przepuszczonych przez filtry. Ten sam render przy każdym wejściu znaczy te same liczby w analizie, więc dzisiejszy wykres da się porównać z wczorajszym.

Obraz nie jest słupkami. Każdy wiersz to jedno przejście widma, nowsze wiersze zasłaniają starsze wypełnieniem w barwie kadru, a energia całości podnosi jasność i gęstość ziarna tym samym tokenem, którym ziarnuje się cała strona. Miedź dostaje wyłącznie szczyt widma.

Ile to waży

Sąsiad z góry, czyli pole cząstek, ciągnie 278 KB leniwego kodu. Dithering obok waży 3,3 KB. Tutaj chunk demo to 3,8 KB po gzipie i nie ma przy nim ani jednego bajtu dźwięku. Minuta przyzwoitego mp3 zajmuje około dwóch megabajtów; ta pętla to kilkadziesiąt linii, z których przeglądarka robi osiem sekund materiału. Kosztem jest robota, bo sekwencję trzeba skomponować samemu.

Jak to jest zrobione

/** 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();
}
Wyceń projekt