Skip to content
Назад в блог
Руководство3 мин чтения

Бетховен в браузере: играем ноты через Web Audio API

Как маленькая JavaScript-библиотека проигрывает Лунную сонату прямо в браузере с помощью одних осцилляторов — с живой демкой, которую можно нажать и услышать.

JavaScriptWeb Audio APITypeScriptOpen Source
Бетховен играет Лунную сонату на рояле рядом с современным ноутбуком

В этом посте нет ни одного MP3. Ни библиотеки сэмплов, ни тега <audio>, ни записи настоящего пианино. Кнопка ниже проигрывает начало Лунной сонаты Бетховена синусоидами, сгенерированными на лету через Web Audio API, с помощью маленькой библиотеки, которую я написал — browser-dj-js. Жмите:

Live demo — real oscillators, no audio file

Click to hear it. Runs entirely in your browser.

Если вы играете на каком-нибудь инструменте — гитаре, пианино, неважно — у вас уже есть всё, что нужно, чтобы так же заставить звучать любую мелодию: ноты и их длительности. Это весь необходимый ввод. Вот как это устроено, а заодно и видео с разбором, если захотите собрать это сами.

От названия ноты до звуковой волны

Музыкальная нота — это просто частота. A4, ля первой октавы, по соглашению равна 440Гц, а любая другая нота отстоит от неё на фиксированное число полутонов по логарифмической шкале. browser-dj-js хранит соответствие названий нот номерам клавиш фортепиано и вычисляет частоту по одной формуле:

function calculateFrequency(keyNumber: number): number {
  return 16.35 * Math.pow(2, (keyNumber - 1) / 12);
}

16.35 — это частота C0, самой низкой ноты до на стандартном 88-клавишном фортепиано. Каждый полутон вверх умножает частоту на корень двенадцатой степени из двух, отсюда и деление показателя степени на 12. Когда частота ноты известна, проиграть её — это горстка вызовов Web Audio API: создать осциллятор, подключить его к gain-узлу, тот — к динамикам, и указать, когда начать и когда остановить.

export async function playNote(audioContext: AudioContext, frequency: number, duration: number) {
  const oscillator = audioContext.createOscillator();
  const gainNode = audioContext.createGain();

  oscillator.connect(gainNode);
  gainNode.connect(audioContext.destination);

  oscillator.type = 'sine';
  oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime);
  gainNode.gain.setValueAtTime(0.5, audioContext.currentTime);

  oscillator.start(audioContext.currentTime);
  oscillator.stop(audioContext.currentTime + duration);

  return new Promise<void>((resolve) => {
    oscillator.onended = () => resolve();
  });
}

playMelody — это ровно то же самое, только последовательно дождавшись каждой ноты из массива пар { note, duration }. Если название ноты не найдено, функция просто ждёт через setTimeout — опечатка превращается в паузу, а не в падение.

Встраиваем в реальный проект

Обвязка — обычное Vite + React + TypeScript приложение, ничего экзотического:

pnpm create vite moonlight-sonata
cd moonlight-sonata
pnpm add browser-dj-js

Чтобы проиграть три ноты, нужен AudioContext (точка входа в аудиограф браузера) и массив нот:

import { playMelody } from 'browser-dj-js';

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContext();

playMelody(audioContext, [
  { note: 'G#3', duration: 0.5 },
  { note: 'C#4', duration: 0.5 },
  { note: 'E4', duration: 0.5 },
]);

Три строчки — и браузер играет вступительную триоль сонаты. Повесьте это на onClick кнопки, и всё «приложение» становится одним обработчиком.

Трюк, который делает это музыкой, а не дверным звонком

Три ноты подряд узнаваемы, но звучат бедно. То, что реально делает Лунную сонату Лунной сонатой — это басовая партия, идущая под триолями, с гораздо более долгими длительностями, пока мелодия продолжает двигаться. А значит, нужны два независимых голоса, играющих одновременно, то есть два независимых экземпляра AudioContext, каждый со своим параллельным вызовом playMelody:

const audioContext = new AudioContext();
const bassAudioContext = new AudioContext();

const play = () => {
  playMelody(bassAudioContext, [
    { note: 'C#3', duration: 6 },
    { note: 'B2', duration: 4.5 },
    { note: 'B2', duration: 1.5 },
    { note: 'A2', duration: 3 },
    { note: 'F#3', duration: 3 },
    { note: 'G#2', duration: 3 },
    { note: 'G#2', duration: 3 },
  ]);
  playMelody(audioContext, [...melody, ...melody, ...melody2, ...melody3]);
};

Ни один из вызовов здесь не дожидается своего завершения, поэтому оба цикла playMelody идут параллельно, каждый — по своим нотам и по своим часам. Это и есть вся «аранжировка»: один контекст держит бас, другой сверху ведёт триоли, и оба стартуют в один и тот же момент. Демка в начале поста работает ровно на этой логике планирования, с добавленными паузой и остановкой через AudioContext.suspend(), resume() и close().

Если приглядеться к массивам мелодии, видна очевидная закономерность: три почти одинаковые фразы (melody, melody, melody2, melody3), построенные из одной и той же ритмической ячейки с разной высотой звука. Это не случайность транскрипции — так устроено само произведение, и такая структура вполне позволила бы генерировать фразы из более компактного паттерна, а не выписывать каждую ноту вручную. Оставляю как упражнение для тех, кто захочет пойти дальше.

Попробуйте сами

Если вы играете на каком-то инструменте, это по-настоящему доступный проект на выходные. Вы уже умеете читать мелодию — browser-dj-js нужны только названия нот и длительности. Возьмите библиотеку из npm или загляните в исходники, чтобы увидеть, насколько компактна вся реализация — вся библиотека умещается в один файл. Полный пример на Vite + React из этого поста, собранный точно так же, как в видео, тоже лежит на GitHub: SmolinPavel/moonlight-sonata.

Если вы делаете что-то связанное со звуком в браузере и хотите взгляд со стороны, напишите мне.