Porada: Web Audio API dla symulacji fizycznych — synteza, planowanie i wizualizacja

Większość symulacji fizycznych w sieci jest niema. Nie powinny takie być. Web Audio API daje ci precyzyjny co do próbki, niskoopóźnieniowy silnik audio działający całkowicie w przeglądarce — bez wtyczek, bez serwerów. Oto jak poprawnie podpiąć go do pętli symulacji.

W specyfikacji Web Audio API kryje się zaskakujący fakt: AudioContext utrzymuje własny, monotoniczny zegar — audioCtx.currentTime — który przesuwa się niezależnie od performance.now() i nie jest zaburzany przez pauzy garbage collectora ani przez zacinanie się requestAnimationFrame. To sprawia, że jest o wiele bardziej niezawodny niż pętla zdarzeń JavaScriptu do wszystkiego, co wymaga precyzyjnego taktowania. Automat perkusyjny, dźwięk zderzenia z efektem Dopplera, ton rezonansowy śledzący częstotliwość wahadła — wszystko to staje się trywialnie dokładne, gdy planujesz zdarzenia audio względem currentTime, zamiast wywoływać je z wnętrza pętli renderowania.

API jest dostępne we wszystkich głównych przeglądarkach od około 2014 roku, a mimo to wciąż jest niedostatecznie wykorzystywane w interaktywnych narzędziach naukowych. Ten wpis omawia cztery techniki, które mają największe znaczenie przy dodawaniu dźwięku do symulacji fizycznej: tworzenie i wznawianie AudioContext, planowanie zdarzeń OscillatorNode z precyzją co do klatki, odczytywanie danych częstotliwościowych z AnalyserNode do rysowania spektrogramu na żywo oraz przenoszenie niestandardowego DSP do AudioWorkletProcessor, tak by wątek główny pozostał wolny do renderowania.

1. AudioContext, oscylatory i precyzyjne planowanie

Pierwsza rzecz do zrozumienia: przeglądarki blokują dźwięk do momentu wystąpienia gestu użytkownika. Wywołanie new AudioContext() przed kliknięciem czy naciśnięciem klawisza pozostawi kontekst w stanie "suspended". Rozwiązaniem jest wywołanie audioCtx.resume() z wnętrza handlera interakcji użytkownika i dopiero wtedy rozpoczęcie planowania węzłów.

// Utwórz raz, poza pętlą renderowania
const audioCtx = new AudioContext({ latencyHint: 'interactive' });

document.addEventListener('pointerdown', () => {
  if (audioCtx.state === 'suspended') audioCtx.resume();
}, { once: true });

Gdy kontekst już działa, możesz zaplanować odtworzenie OscillatorNode w precyzyjnym przyszłym momencie, korzystając z zegara audio, a nie z limitu czasu opartego na zegarze ściennym. To kluczowa idea: zamiast wywoływać setTimeout(() => playTone(), delay), mówisz „uruchom ten oscylator w czasie zegara audio T”. Różnica polega na tym, że silnik audio kolejkuje zdarzenie we własnym wątku czasu rzeczywistego, całkowicie omijając opóźnienia planowania JavaScriptu.

function scheduleTone(frequency, startTime, duration) {
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();

  osc.type = 'sine';
  osc.frequency.setValueAtTime(frequency, startTime);

  // Obwiednia amplitudy: szybki atak, wykładniczy zanik
  gain.gain.setValueAtTime(0.001, startTime);
  gain.gain.exponentialRampToValueAtTime(0.6, startTime + 0.01);
  gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);

  osc.connect(gain);
  gain.connect(audioCtx.destination);

  osc.start(startTime);
  osc.stop(startTime + duration + 0.05); // automatyczne sprzątanie
}

// Wewnątrz kroku symulacji — wywołaj to, gdy nastąpi zderzenie:
// "odtwórz ton za 30 ms od teraz na zegarze audio"
scheduleTone(440, audioCtx.currentTime + 0.03, 0.3);

Dla symulacji wahadła możesz zmapować chwilową prędkość kątową ω na wysokość dźwięku, korzystając z zależności f = f₀ · (1 + k·|ω|), gdzie f₀ to naturalna częstotliwość wahadła (f₀ = (1/2π)·√(g/L)), a k to stała skalowania percepcyjnego. Daje to ton, który rośnie, gdy wahadło przechodzi najszybciej przez dół łuku, i cichnie, gdy zwalnia blisko szczytu.

Zasada: Nigdy nie planuj dźwięku, wywołując play() z wnętrza requestAnimationFrame. Callback rAF może być opóźniony o 5–20 ms na zajętym wątku głównym. Zamiast tego patrz w przyszłość o stałą wartość — zazwyczaj 100–200 ms — i planuj wszystkie zdarzenia mieszczące się w tym oknie. To wzorzec lookahead-scheduler, używany przez każdy profesjonalny sekwencer Web Audio.

Wzorzec lookahead scheduler

const LOOKAHEAD = 0.15;   // planuj 150 ms naprzód
const SCHEDULE_INTERVAL = 25; // sprawdzaj co 25 ms

let nextNoteTime = audioCtx.currentTime;

function scheduler() {
  while (nextNoteTime < audioCtx.currentTime + LOOKAHEAD) {
    scheduleNote(nextNoteTime);
    nextNoteTime += noteInterval; // odstęp wyprowadzony z fizyki
  }
  setTimeout(scheduler, SCHEDULE_INTERVAL);
}

scheduler();

Zwróć uwagę, że planista celowo używa setTimeout, a nie requestAnimationFrame. Planista audio nie musi być powiązany z odświeżaniem wyświetlacza — musi tylko budzić się wystarczająco często, by utrzymać pełny bufor lookahead. Użycie rAF spowodowałoby luki w dźwięku, gdy karta jest ukryta.

2. AnalyserNode do wizualizacji spektrogramu w czasie rzeczywistym

AnalyserNode siedzi w grafie audio i udostępnia dane w domenie częstotliwości obliczane za pomocą krótkoczasowej transformaty Fouriera (STFT). Wybierasz rozmiar FFT — potęgę dwóch od 32 do 32768 — który determinuje kompromis między rozdzielczością częstotliwościową a czasową. Większe FFT daje drobniejsze kubełki częstotliwości, ale uśrednia dłuższe okno czasowe.

Rozdzielczość częstotliwościowa na kubełek to Δf = sampleRate / fftSize. Przy domyślnej częstotliwości próbkowania 44100 Hz i rozmiarze FFT 2048, każdy kubełek ma szerokość około 44100 / 2048 ≈ 21,5 Hz. Właściwość frequencyBinCount jest równa fftSize / 2, dając 1024 użytecznych kubełków od 0 Hz do częstotliwości Nyquista.

const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.8; // 0 = brak wygładzania, 1 = maksymalne

// Podłączenie: źródło → analyser → destination
sourceNode.connect(analyser);
analyser.connect(audioCtx.destination);

// W twojej pętli rAF:
const dataArray = new Uint8Array(analyser.frequencyBinCount);

function drawSpectrogram(canvas) {
  const ctx = canvas.getContext('2d');
  analyser.getByteFrequencyData(dataArray); // wypełnia dataArray w miejscu

  const barWidth = canvas.width / dataArray.length;
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  dataArray.forEach((value, i) => {
    const barHeight = (value / 255) * canvas.height;
    const hue = (i / dataArray.length) * 280; // przejście fiolet → czerwień
    ctx.fillStyle = `hsl(${hue}, 80%, 50%)`;
    ctx.fillRect(i * barWidth, canvas.height - barHeight, barWidth, barHeight);
  });
}

function renderLoop() {
  drawSpectrogram(spectrogramCanvas);
  requestAnimationFrame(renderLoop);
}
renderLoop();

Dla przewijającego się spektrogramu wodospadowego — klasycznego widoku używanego w sejsmologii i monitoringu radiowym — rysuj każdą nową klatkę jako kolumnę o szerokości jednego piksela na osobnym płótnie poza ekranem, a następnie przenoś całe to płótno przesunięte o jeden piksel w lewo. To akumuluje historię czasową bez przechowywania pełnej tablicy.

3. AudioWorkletProcessor do niestandardowego DSP

API ScriptProcessorNode — które kiedyś było jedynym sposobem pisania niestandardowego DSP w przeglądarce — jest przestarzałe, ponieważ działało w wątku głównym i powodowało wypadanie dźwięku, gdy strona wykonywała pracę renderującą. Jego następca, AudioWorkletProcessor, działa w dedykowanym wątku renderowania audio czasu rzeczywistego i komunikuje się z wątkiem głównym za pomocą MessagePort i SharedArrayBuffer.

// physics-processor.js  (ładowany jako moduł Workleta)
class PhysicsProcessor extends AudioWorkletProcessor {
  constructor(options) {
    super();
    this._mass = options.processorOptions.mass ?? 1.0;
    this._stiffness = options.processorOptions.stiffness ?? 100.0;
    this._damping = options.processorOptions.damping ?? 0.5;
    this._x = 0;    // przemieszczenie
    this._v = 0;    // prędkość
  }

  process(inputs, outputs) {
    const output = outputs[0][0];
    const dt = 1 / sampleRate; // krok czasowy na próbkę

    for (let i = 0; i < output.length; i++) {
      // Tłumiony oscylator harmoniczny: mẍ + cẋ + kx = 0
      const accel = (-this._stiffness * this._x - this._damping * this._v) / this._mass;
      this._v += accel * dt;
      this._x += this._v * dt;
      output[i] = this._x; // przemieszczenie → próbka audio
    }
    return true; // procesor pozostaje aktywny
  }
}

registerProcessor('physics-processor', PhysicsProcessor);
// Wątek główny — rejestracja i tworzenie instancji
await audioCtx.audioWorklet.addModule('/audio/physics-processor.js');

const physicsNode = new AudioWorkletNode(audioCtx, 'physics-processor', {
  processorOptions: { mass: 1.0, stiffness: 440 ** 2, damping: 2.0 }
});

physicsNode.connect(audioCtx.destination);

Powyższy tłumiony oscylator harmoniczny generuje zanikającą sinusoidę o częstotliwości naturalnej ω₀ = √(k/m) radianów na sekundę, co odpowiada f₀ = ω₀ / 2π Hz. Przy k = 440² ≈ 193 600 i m = 1, ton to dokładnie koncertowe A. Dostosuj tłumienie za pomocą wywołania AudioWorkletNode.port.postMessage, by symulować różne materiały — napięta sprężyna, luźne wahadło, odbijająca się gumowa piłka.

Pułapka: audioCtx.audioWorklet.addModule() wymaga, by plik był serwowany przez HTTPS albo localhost. Nie może też importować modułów ES przez gołe specyfikatory — używaj pełnych względnych URL-i. Jeśli zobaczysz DOMException: The worklet cannot be added, sprawdź swoją Content Security Policy: script-src musi dopuszczać 'self'.

4. Synchronizacja animacji z zegarem audio

Najtrudniejszą częścią łączenia dźwięku i grafiki jest utrzymanie ich w synchronizacji. Zegar audio i zegar wyświetlacza rozjeżdżają się, ponieważ działają w oddzielnych domenach sprzętowych. Kanoniczne rozwiązanie polega na traktowaniu zegara audio jako źródła prawdy i obliczaniu stanu wizualnego jako funkcji audioCtx.currentTime — a nie jako sumy skumulowanej z delt rAF.

// Zamiast akumulować dt:
//   t += dt;  // dryfuje z czasem

// Wyprowadź czas symulacji bezpośrednio z zegara audio:
function renderLoop() {
  const audioTime = audioCtx.currentTime;

  // Kąt wahadła jako funkcja czasu zegara audio
  const omega0 = Math.sqrt(9.81 / pendulumLength);
  const angle = initialAngle * Math.cos(omega0 * audioTime) * Math.exp(-damping * audioTime);

  drawPendulum(angle);
  requestAnimationFrame(renderLoop);
}

Dla symulacji, w których dźwięk jest syntezowany na podstawie stanu fizycznego (a nie fizyka napędzana czasem audio), możesz zamiast tego logować parę (audioCtx.currentTime, simulationState) przy każdym kroku fizyki i użyć interpolacji liniowej w pętli renderowania, by znaleźć stan wizualny odpowiadający bieżącej wartości zegara audio. To toleruje pętlę fizyki o zmiennej częstotliwości bez akumulowania błędu.

Jedna praktyczna uwaga: jeśli używasz GainNode do wyciszania i wzmacniania dźwięku, zawsze używaj rodziny metod setValueAtTime / linearRampToValueAtTime, zamiast bezpośrednio ustawiać gain.gain.value. Bezpośrednie przypisanie wartości powoduje nieciągłość (kliknięcie), ponieważ omija oś czasu parametru. Zaplanowana rampa jest renderowana próbka po próbce i daje płynne, pozbawione kliknięć przejście.

Wypróbuj sam

Te symulacje w mysimulator.uk są dobrym punktem wyjścia do eksperymentowania ze sprzężeniem audio. Każda z nich pokazuje zachowanie oscylacyjne lub falowe, które w naturalny sposób mapuje się na powyższe techniki:

Myśl na zakończenie

Web Audio API jest w swej istocie skierowanym grafem acyklicznym węzłów przetwarzania sygnału — nie tak różnym od grafów scen, których już używasz w Three.js czy Babylon.js. Gdy zaczniesz myśleć o dźwięku jako strumieniu próbek zmiennoprzecinkowych płynących przez graf, związki z fizyką stają się oczywiste: przemieszczenie to wartość próbki, częstotliwość to liczba oscylacji na sekundę, tłumienie to biegun na zespolonej płaszczyźnie częstotliwości. Matematyka jest identyczna; różni się tylko medium wyjściowe.

Najbardziej eleganckie symulacje to te, w których dźwięk nie jest efektem dźwiękowym dodanym na wierzchu, lecz uczciwą projekcją tych samych leżących u podstaw równań na percepcyjny kanał słuchu. Zrób fizykę dobrze, a dźwięk pojawi się za darmo.