У специфікації 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. Щойно ви почнете думати про звук як про потік чисел із рухомою комою, що тече крізь граф, зв'язки з фізикою стають очевидними: зміщення — це значення семпла, частота — кількість коливань за секунду, затухання — полюс на комплексній частотній площині. Математика ідентична; відрізняється лише вихідне середовище.
Найелегантніші симуляції — ті, де звук не є звуковим ефектом, доданим зверху, а чесною проєкцією тих самих базових рівнянь на перцептивний канал слуху. Зробіть фізику правильною — і звук додасться безкоштовно.