Туторіал · Web Audio API · JavaScript · Звук
📅 Липень 2026 ⏱ ≈ 20 хв 🎯 Середній рівень

Синтезатор за 100 рядків JavaScript

Вам не потрібна DAW чи нативний плагін, щоб побудувати справжній, граючий синтезатор — Web Audio API браузера безкоштовно надає осцилятори з точністю до відліку, обвідні та фільтри. Цей туторіал будує поліфонічний синтезатор, на якому можна грати з клавіатури комп'ютера, приблизно за 100 рядків чистого JavaScript без жодних бібліотек.

1. Аудіоконтекст

Кожен граф Web Audio API починається з одного AudioContext. Браузери блокують запуск звуку без жесту користувача, тому створюйте його лінькувато — при першому кліку чи натисканні клавіші:

let ctx = null;
function getCtx() {
  if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
  return ctx;
}

Усе інше — осцилятори, підсилення, фільтри — це вузол, підключений у граф цього єдиного контексту, який зрештою досягає ctx.destination (ваших колонок).

2. Один голос: осцилятор + підсилення

Мінімальний інструмент — один OscillatorNode (генератор висоти тону/форми хвилі), підключений до одного GainNode (керування гучністю), який веде до колонок:

function midiToFreq(midi) {
  return 440 * Math.pow(2, (midi - 69) / 12); // A4 = MIDI 69 = 440 Гц
}

function createVoice(freq, waveform = 'sawtooth') {
  const ac = getCtx();
  const osc = ac.createOscillator();
  osc.type = waveform; // 'sine' | 'square' | 'sawtooth' | 'triangle'
  osc.frequency.setValueAtTime(freq, ac.currentTime);

  const gain = ac.createGain();
  gain.gain.setValueAtTime(0, ac.currentTime); // починаємо тихо

  osc.connect(gain);
  return { osc, gain };
}

3. Обвідна ADSR

Голий осцилятор клацає миттєво — і це звучить різко. Справжні інструменти мають форму у часі: Attack (наростання до піку), Decay (спад до рівня утримання), Sustain (утримуваний рівень, поки клавіша натиснута) і Release (згасання до тиші після відпускання клавіші) — класична обвідна ADSR.

Гучність ADSR у часі: 0 ──атака──► пік ──спад──► утримання ──(тримається)──► ──згасання──► 0

Методи рамп AudioParam у Web Audio API роблять це тривіальним — цикл анімації вручну не потрібен, аудіопотік обробляє рампи з точністю до відліку:

function applyADSR(gainParam, { attack = 0.01, decay = 0.15, sustain = 0.5, peak = 0.6 } = {}) {
  const t = getCtx().currentTime;
  gainParam.cancelScheduledValues(t);
  gainParam.setValueAtTime(0, t);
  gainParam.linearRampToValueAtTime(peak, t + attack);           // Атака
  gainParam.linearRampToValueAtTime(sustain, t + attack + decay); // Спад → Утримання
}

function releaseADSR(gainParam, release = 0.25) {
  const t = getCtx().currentTime;
  gainParam.cancelScheduledValues(t);
  gainParam.setValueAtTime(gainParam.value, t);
  gainParam.linearRampToValueAtTime(0.0001, t + release); // Згасання
}
Чому 0.0001, а не 0: exponentialRampToValueAtTime кидає помилку при цільовому значенні рівно 0. Використання лінійної рампи до крихітного додатного значення уникає клацання, яке може викликати різкий setValueAtTime(0, …), і зберігає плавність згасання.

4. Note On / Note Off

noteOn створює новий голос, запускає осцилятор і виконує рампу атаки/спаду. noteOff виконує рампу згасання та планує зупинку осцилятора трохи пізніше — зупинити його зарано означало б обірвати згасання:

function noteOn(midi) {
  const { osc, gain } = createVoice(midiToFreq(midi), currentWaveform);
  applyADSR(gain.gain);
  gain.connect(filterNode);
  osc.start();
  return { osc, gain };
}

function noteOff(voice) {
  releaseADSR(voice.gain.gain);
  const stopAt = getCtx().currentTime + 0.3; // після рампи згасання
  voice.osc.stop(stopAt);
}

5. Фільтр низьких частот для тембру

Сира пилкоподібна хвиля яскрава й дзижчить. Вставте BiquadFilterNode між голосами та колонками, щоб згладити її — класичний прийом субтрактивного синтезу і джерело кожного «фільтр-світу», який ви колись чули:

const filterNode = getCtx().createBiquadFilter();
filterNode.type = 'lowpass';
filterNode.frequency.setValueAtTime(1200, getCtx().currentTime); // частота зрізу, Гц
filterNode.Q.setValueAtTime(4, getCtx().currentTime);          // резонанс
filterNode.connect(getCtx().destination);
Спробуйте: Проведіть розгортку filterNode.frequency від 200 Гц до 8000 Гц за кілька секунд за допомогою linearRampToValueAtTime, поки нота тримається — цей один рядок відтворює характерну «фільтр-розгортку» аналогового синтезатора, знайому з незліченних танцювальних і кінематографічних патчів.

6. Поліфонія через мапу голосів

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

const activeVoices = new Map(); // midi -> voice

function keyDown(midi) {
  if (activeVoices.has(midi)) return; // вже звучить
  activeVoices.set(midi, noteOn(midi));
}

function keyUp(midi) {
  const voice = activeVoices.get(midi);
  if (!voice) return;
  noteOff(voice);
  activeVoices.delete(midi);
}

7. Клавіатура-піаніно QWERTY

Прив'яжіть ряд клавіш комп'ютера до півтонових зсувів від базової ноти та з'єднайте справжні події клавіатури з keyDown / keyUp — жоден екранний інтерфейс не потрібен для граючого інструменту:

const KEY_MAP = { // ряд = білі + чорні клавіші, як на роликовому піаніно
  'a': 0, 'w': 1, 's': 2, 'e': 3, 'd': 4,
  'f': 5, 't': 6, 'g': 7, 'y': 8, 'h': 9,
  'u': 10, 'j': 11, 'k': 12,
};
const BASE_MIDI = 60; // до першої октави

window.addEventListener('keydown', e => {
  if (e.repeat || !(e.key in KEY_MAP)) return;
  keyDown(BASE_MIDI + KEY_MAP[e.key]);
});
window.addEventListener('keyup', e => {
  if (!(e.key in KEY_MAP)) return;
  keyUp(BASE_MIDI + KEY_MAP[e.key]);
});

8. Повний синтезатор на ~100 рядків

Об'єднавши всі частини в один самодостатній скрипт — це повний, готовий до запуску синтезатор:

// ── Поліфонічний синтезатор на ~100 рядків: OscillatorNode + GainNode + BiquadFilterNode ──
let ctx = null;
let filterNode = null;
let currentWaveform = 'sawtooth';
const activeVoices = new Map();

function getCtx() {
  if (!ctx) {
    ctx = new (window.AudioContext || window.webkitAudioContext)();
    filterNode = ctx.createBiquadFilter();
    filterNode.type = 'lowpass';
    filterNode.frequency.setValueAtTime(1200, ctx.currentTime);
    filterNode.Q.setValueAtTime(4, ctx.currentTime);
    filterNode.connect(ctx.destination);
  }
  return ctx;
}

function midiToFreq(midi) {
  return 440 * Math.pow(2, (midi - 69) / 12);
}

function createVoice(freq) {
  const ac = getCtx();
  const osc = ac.createOscillator();
  osc.type = currentWaveform;
  osc.frequency.setValueAtTime(freq, ac.currentTime);
  const gain = ac.createGain();
  gain.gain.setValueAtTime(0, ac.currentTime);
  osc.connect(gain);
  gain.connect(filterNode);
  return { osc, gain };
}

function applyADSR(gainParam) {
  const t = getCtx().currentTime;
  const attack = 0.01, decay = 0.15, sustain = 0.5, peak = 0.6;
  gainParam.cancelScheduledValues(t);
  gainParam.setValueAtTime(0, t);
  gainParam.linearRampToValueAtTime(peak, t + attack);
  gainParam.linearRampToValueAtTime(sustain, t + attack + decay);
}

function releaseADSR(gainParam) {
  const t = getCtx().currentTime;
  gainParam.cancelScheduledValues(t);
  gainParam.setValueAtTime(gainParam.value, t);
  gainParam.linearRampToValueAtTime(0.0001, t + 0.25);
}

function noteOn(midi) {
  const { osc, gain } = createVoice(midiToFreq(midi));
  applyADSR(gain.gain);
  osc.start();
  return { osc, gain };
}

function noteOff(voice) {
  releaseADSR(voice.gain.gain);
  voice.osc.stop(getCtx().currentTime + 0.3);
}

function keyDown(midi) {
  if (activeVoices.has(midi)) return;
  activeVoices.set(midi, noteOn(midi));
}

function keyUp(midi) {
  const voice = activeVoices.get(midi);
  if (!voice) return;
  noteOff(voice);
  activeVoices.delete(midi);
}

const KEY_MAP = {
  'a': 0, 'w': 1, 's': 2, 'e': 3, 'd': 4,
  'f': 5, 't': 6, 'g': 7, 'y': 8, 'h': 9,
  'u': 10, 'j': 11, 'k': 12,
};
const BASE_MIDI = 60;

window.addEventListener('keydown', e => {
  if (e.repeat || !(e.key in KEY_MAP)) return;
  keyDown(BASE_MIDI + KEY_MAP[e.key]);
});
window.addEventListener('keyup', e => {
  if (!(e.key in KEY_MAP)) return;
  keyUp(BASE_MIDI + KEY_MAP[e.key]);
});
Наступні кроки: Перемикайте currentWaveform між 'sine', 'square', 'sawtooth' та 'triangle', щоб почути гармонічний вміст кожної форми хвилі безпосередньо. Додайте другий розстроєний осцилятор на голос для товстішого унісонного звучання, або замініть осцилятор повністю на рівняння FM зі статті про FM-синтез, використовуючи osc.frequency.setValueAtTime, кероване виходом другого, швидшого осцилятора через модуляцію AudioParam.

Часті запитання

Чого я навчуся в цьому уроці?

Створіть повноцінний поліфонічний синтезатор приблизно за 100 рядків JavaScript, використовуючи лише Web Audio API: OscillatorNode, GainNode, обвідну ADSR, фільтр і клавіатуру-піаніно.

Які теми розглядаються в цьому уроці?

Цей урок охоплює такі теми: Аудіоконтекст, Один голос: осцилятор + підсилення, Обвідна ADSR, Note On / Note Off, Фільтр низьких частот для тембру, Поліфонія через мапу голосів, Клавіатура-піаніно QWERTY, Повний синтезатор на ~100 рядків.

Скільки часу займає цей урок?

Цей урок займає приблизно 20 хв.

Які попередні знання потрібні?

Це урок рівня «Середній рівень» — окрема попередня підготовка, крім базового JavaScript, не потрібна.