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.