Порада: Web Audio API для фізичних симуляцій — синтез, планування та візуалізація

Більшість фізичних симуляцій у вебі — німі. А не мали б бути. Web Audio API дає вам точний до семпла аудіодвигун із низькою затримкою, що працює повністю в браузері — без плагінів, без серверів. Ось як правильно вбудувати його у ваш цикл симуляції.

У специфікації Web Audio API прихований дивовижний факт: AudioContext має власний монотонний годинник — audioCtx.currentTime — який рухається незалежно від performance.now() і на нього не впливають ні паузи збирача сміття, ні тремтіння (jank) requestAnimationFrame. Це робить його значно надійнішим за цикл подій JavaScript для будь-чого, що потребує точного таймінгу. Барабанна машина, звук зіткнення зі зсувом Доплера, резонансний тон, що відстежує частоту маятника, — усе це стає тривіально точним, щойно ви плануєте аудіоподії відносно currentTime, а не викликаєте їх усередині циклу рендерингу.

Цей API доступний у всіх основних браузерах приблизно з 2014 року, проте він досі недостатньо використовується в інтерактивних наукових інструментах. У цій статті розглянуто чотири техніки, які мають найбільше значення при додаванні звуку до фізичної симуляції: створення й відновлення AudioContext, планування подій OscillatorNode із точністю до кадру, читання частотних даних з AnalyserNode для малювання живої спектрограми та винесення власного DSP в AudioWorkletProcessor, щоб основний потік залишався вільним для рендерингу.

1. AudioContext, осцилятори та точне планування

Найперше, що варто зрозуміти: браузери блокують звук, доки не відбудеться жест користувача. Виклик new AudioContext() до кліку чи натискання клавіші залишить контекст у стані "suspended". Виправлення — викликати audioCtx.resume() всередині обробника взаємодії користувача і лише після цього починати планування вузлів.

// Створити один раз, поза циклом рендерингу
const audioCtx = new AudioContext({ latencyHint: 'interactive' });

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

Коли контекст запущено, можна запланувати відтворення OscillatorNode у точний момент у майбутньому, використовуючи аудіо-годинник, а не таймаут за настінним часом. Це ключова ідея: замість виклику setTimeout(() => playTone(), delay) ви кажете «запусти цей осцилятор у момент T аудіо-годинника». Різниця в тому, що аудіодвигун ставить подію в чергу у власному потоці реального часу, повністю оминаючи затримку планування JavaScript.

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

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

  // Обвідна амплітуди: швидка атака, експоненційне згасання
  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); // автоматичне очищення
}

// Усередині кроку симуляції — викликайте це, коли стається зіткнення:
// «відтворити тон через 30 мс за аудіо-годинником»
scheduleTone(440, audioCtx.currentTime + 0.03, 0.3);

Для симуляції маятника можна відобразити миттєву кутову швидкість ω на висоту тону за допомогою співвідношення f = f₀ · (1 + k·|ω|), де f₀ — власна частота маятника (f₀ = (1/2π)·√(g/L)), а k — константа перцептивного масштабування. Це дає тон, який підвищується, коли маятник рухається найшвидше в нижній точці дуги, і згасає, коли він сповільнюється біля вершини.

Правило: Ніколи не плануйте звук викликом play() усередині requestAnimationFrame. Колбек rAF може затримуватися на 5–20 мс при завантаженому основному потоці. Натомість заглядайте наперед на фіксований інтервал — зазвичай 100–200 мс — і плануйте всі події, що потрапляють у це вікно. Це патерн планувальника з випередженням (lookahead), який використовує кожен професійний секвенсор Web Audio.

Патерн планувальника з випередженням (lookahead)

const LOOKAHEAD = 0.15;   // планувати на 150 мс наперед
const SCHEDULE_INTERVAL = 25; // перевіряти кожні 25 мс

let nextNoteTime = audioCtx.currentTime;

function scheduler() {
  while (nextNoteTime < audioCtx.currentTime + LOOKAHEAD) {
    scheduleNote(nextNoteTime);
    nextNoteTime += noteInterval; // просуватися на інтервал, що визначається фізикою
  }
  setTimeout(scheduler, SCHEDULE_INTERVAL);
}

scheduler();

Зверніть увагу, що планувальник свідомо використовує setTimeout, а не requestAnimationFrame. Аудіопланувальник не повинен бути прив'язаний до оновлення екрана — йому потрібно лише прокидатися достатньо часто, щоб буфер випередження залишався заповненим. Використання rAF тут спричиняло б розриви звуку щоразу, коли вкладка прихована.

2. AnalyserNode для візуалізації спектрограми в реальному часі

AnalyserNode розташовується в аудіографі та надає дані в частотній області, обчислені за допомогою короткочасного перетворення Фур'є (STFT). Ви обираєте розмір FFT — степінь двійки від 32 до 32768 — який визначає компроміс між частотним і часовим розділенням. Більший FFT дає тонші частотні бін, але усереднює за довшим часовим вікном.

Частотне розділення на один бін становить Δf = sampleRate / fftSize. За стандартної частоти дискретизації 44100 Гц і розміру FFT 2048 кожен бін має ширину приблизно 44100 / 2048 ≈ 21.5 Гц. Властивість frequencyBinCount дорівнює fftSize / 2, що дає 1024 придатних для використання бінів від 0 Гц до частоти Найквіста.

const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.8; // 0 = без згладжування, 1 = максимум

// З'єднання: джерело → аналізатор → призначення
sourceNode.connect(analyser);
analyser.connect(audioCtx.destination);

// У вашому циклі rAF:
const dataArray = new Uint8Array(analyser.frequencyBinCount);

function drawSpectrogram(canvas) {
  const ctx = canvas.getContext('2d');
  analyser.getByteFrequencyData(dataArray); // заповнює dataArray на місці

  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; // перехід від фіолетового до червоного
    ctx.fillStyle = `hsl(${hue}, 80%, 50%)`;
    ctx.fillRect(i * barWidth, canvas.height - barHeight, barWidth, barHeight);
  });
}

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

Для спектрограми водоспаду, що прокручується, — класичного відображення, яке використовується в сейсмології та радіомоніторингу — малюйте кожен новий кадр як стовпчик завширшки в один піксель на позаекранному canvas, а потім переносьте весь позаекранний canvas, зсунутий на один піксель ліворуч. Це накопичує історію в часі без збереження повного масиву.

3. AudioWorkletProcessor для власного DSP

API ScriptProcessorNode — який раніше був єдиним способом писати власний DSP у браузері — вважається застарілим, оскільки він виконувався в основному потоці й спричиняв випадання звуку щоразу, коли сторінка виконувала роботу з рендерингу. Його заміна, AudioWorkletProcessor, працює у виділеному потоці рендерингу аудіо реального часу та обмінюється даними з основним потоком через MessagePort і SharedArrayBuffer.

// physics-processor.js  (завантажується як модуль Worklet)
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;    // зміщення
    this._v = 0;    // швидкість
  }

  process(inputs, outputs) {
    const output = outputs[0][0];
    const dt = 1 / sampleRate; // крок часу на семпл

    for (let i = 0; i < output.length; i++) {
      // Затухаючий гармонічний осцилятор: 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; // зміщення → аудіосемпл
    }
    return true; // зберегти процесор активним
  }
}

registerProcessor('physics-processor', PhysicsProcessor);
// Основний потік — реєстрація та створення екземпляра
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);

Затухаючий гармонічний осцилятор вище генерує згасаючу синусоїду на власній частоті ω₀ = √(k/m) радіан за секунду, що відповідає f₀ = ω₀ / 2π Гц. За k = 440² ≈ 193 600 та m = 1 тон є точно концертним ля. Налаштовуйте затухання через виклик AudioWorkletNode.port.postMessage, щоб симулювати різні матеріали — тугу пружину, розгойданий маятник, стрибучий гумовий м'яч.

Підступний момент: audioCtx.audioWorklet.addModule() вимагає, щоб файл віддавався через HTTPS або localhost. Він також не може імпортувати ES-модулі через «голі» специфікатори — використовуйте повні відносні URL. Якщо ви бачите DOMException: The worklet cannot be added, перевірте свою Content Security Policy: потрібно, щоб script-src дозволяв 'self'.

4. Синхронізація анімації з аудіо-годинником

Найважча частина поєднання звуку та візуальної частини — це утримання їхньої синхронізації. Аудіо-годинник і годинник екрана розходяться в часі, оскільки працюють в окремих апаратних доменах. Канонічне рішення — трактувати аудіо-годинник як єдине джерело істини і обчислювати візуальний стан як функцію від audioCtx.currentTime, а не як накопичену суму дельт rAF.

// Замість накопичення dt:
//   t += dt;  // з часом накопичує похибку

// Обчислюємо час симуляції безпосередньо з аудіо-годинника:
function renderLoop() {
  const audioTime = audioCtx.currentTime;

  // Кут маятника як функція часу аудіо-годинника
  const omega0 = Math.sqrt(9.81 / pendulumLength);
  const angle = initialAngle * Math.cos(omega0 * audioTime) * Math.exp(-damping * audioTime);

  drawPendulum(angle);
  requestAnimationFrame(renderLoop);
}

Для симуляцій, де звук синтезується з фізичного стану (а не фізика керується аудіо-часом), можна замість цього записувати пару (audioCtx.currentTime, simulationState) на кожному кроці фізики та використовувати лінійну інтерполяцію в циклі рендерингу, щоб знайти візуальний стан, що відповідає поточному значенню аудіо-годинника. Це дозволяє толерувати фізичний цикл зі змінною частотою без накопичення похибки.

Одна практична деталь: якщо ви використовуєте GainNode для плавного наростання та згасання звуку, завжди використовуйте методи родини setValueAtTime / linearRampToValueAtTime, а не встановлюйте gain.gain.value напряму. Пряме присвоєння значення спричиняє розрив (клацання), оскільки обходить часову шкалу параметра. Запланована рампа рендериться посемпльно й дає плавний перехід без клацань.

Спробуйте самі

Ці симуляції на mysimulator.uk — гарна відправна точка для експериментів зі звуковим зв'язком. Кожна з них демонструє коливальну або хвильову поведінку, яка природно відображається на техніки, описані вище:

Насамкінець

Web Audio API — це, по суті, орієнтований ациклічний граф вузлів обробки сигналу, що не так уже й відрізняється від графів сцени, які ви вже використовуєте в Three.js чи Babylon.js. Щойно ви почнете думати про звук як про потік чисел із рухомою комою, що тече крізь граф, зв'язки з фізикою стають очевидними: зміщення — це значення семпла, частота — кількість коливань за секунду, затухання — полюс на комплексній частотній площині. Математика ідентична; відрізняється лише вихідне середовище.

Найелегантніші симуляції — ті, де звук не є звуковим ефектом, доданим зверху, а чесною проєкцією тих самих базових рівнянь на перцептивний канал слуху. Зробіть фізику правильною — і звук додасться безкоштовно.