Skip to content
Wróć do bloga
Poradnik3 min czytania

Beethoven w przeglądarce dzięki Web Audio API

Jak niewielka biblioteka JavaScript odtwarza Sonatę księżycową w przeglądarce, używając samych oscylatorów — z żywym demo, które można kliknąć i usłyszeć.

JavaScriptWeb Audio APITypeScriptOpen Source
Beethoven gra Sonatę księżycową na fortepianie obok współczesnego laptopa

W tym wpisie nie ma ani jednego pliku MP3. Żadnej biblioteki sampli, żadnego tagu <audio>, żadnego nagrania fortepianu. Przycisk poniżej odtwarza początek Sonaty księżycowej Beethovena za pomocą fal sinusoidalnych generowanych na bieżąco przez Web Audio API, napędzanych niewielką biblioteką, którą napisałem — browser-dj-js. Kliknij:

Live demo — real oscillators, no audio file

Click to hear it. Runs entirely in your browser.

Jeśli grasz na jakimkolwiek instrumencie — gitarze, pianinie, obojętnie — masz już wszystko, czego trzeba, by w ten sposób zabrzmiała dowolna melodia: nuty i ich długości. To cały wymagany wsad. Oto jak to działa, a przy okazji wideo z omówieniem, jeśli chcesz zbudować to samodzielnie.

Od nazwy nuty do fali dźwiękowej

Nuta muzyczna to po prostu częstotliwość. A4, czyli a1, z konwencji wynosi 440Hz, a każda inna nuta jest oddalona od niej o ustaloną liczbę półtonów w skali logarytmicznej. browser-dj-js przechowuje mapę nazw nut na numery klawiszy fortepianu i wyznacza częstotliwość z jednego wzoru:

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

16.35 to częstotliwość C0, najniższego C na standardowym 88-klawiszowym fortepianie. Każdy półton w górę mnoży częstotliwość przez pierwiastek dwunastego stopnia z dwóch, stąd dzielenie wykładnika przez 12. Gdy każda nuta ma już swoją częstotliwość, odtworzenie jej to garść wywołań Web Audio API: stworzyć oscylator, podłączyć go do węzła gain, ten do głośników, i powiedzieć, kiedy zacząć i kiedy zakończyć.

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 to dokładnie to samo, tylko odczekane po kolei dla tablicy par { note, duration }. Jeśli nazwa nuty nie zostanie znaleziona, funkcja po prostu czeka przez zwykły setTimeout — literówka zamienia się w pauzę, a nie w awarię.

Podpięcie do prawdziwego projektu

Konfiguracja to standardowa aplikacja Vite + React + TypeScript, nic egzotycznego:

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

Odtworzenie trzech nut wymaga AudioContext (punktu wejścia do grafu audio przeglądarki) i tablicy nut:

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 },
]);

Trzy linijki, i przeglądarka odtwarza otwierający tryjol sonaty. Podepnij to pod onClick przycisku, i cała „aplikacja“ staje się jedną funkcją obsługi.

Sztuczka, która sprawia, że brzmi to jak muzyka, a nie jak dzwonek do drzwi

Trzy nuty pod rząd są rozpoznawalne, ale ubogie. To, co naprawdę sprawia, że Sonata księżycowa brzmi jak Sonata księżycowa, to linia basu biegnąca pod trylami, trzymana przez znacznie dłuższe wartości, podczas gdy melodia wciąż się porusza. To oznacza dwa niezależne głosy grające jednocześnie, czyli dwie niezależne instancje AudioContext, każda uruchamiająca własne, równoległe wywołanie 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]);
};

Żadne z tych wywołań nie jest tu oczekiwane, więc obie pętle playMelody działają równolegle, każda tykając po swoich nutach na własnym zegarze. To cała „aranżacja“: jeden kontekst trzyma bas, drugi prowadzi trole na wierzchu, oba startują w tej samej chwili. Demo na górze tego wpisu działa dokładnie na tej logice planowania, z pauzą i zatrzymaniem dopiętymi wokół niej przez AudioContext.suspend(), resume() i close().

Patrząc na tablice melodii, widać wyraźny wzorzec: trzy niemal identyczne frazy (melody, melody, melody2, melody3) zbudowane z tej samej komórki rytmicznej powtarzanej z różną wysokością dźwięku. To nie przypadek transkrypcji — tak faktycznie zbudowany jest utwór, i taka struktura ułatwiłaby generowanie fraz z mniejszego wzorca zamiast ręcznego wypisywania każdej nuty. Zostawiam to jako ćwiczenie dla tych, którzy chcą pójść dalej.

Spróbuj sam

Jeśli grasz na jakimś instrumencie, to naprawdę przystępny projekt na weekend. Już umiesz czytać melodię — browser-dj-js potrzebuje tylko nazw nut i długości. Weź bibliotekę z npm albo zajrzyj do kodu źródłowego, żeby zobaczyć, jak niewielka jest cała implementacja — cała biblioteka mieści się w jednym pliku. Pełny przykład na Vite + React z tego wpisu, zbudowany dokładnie tak jak w filmie, też jest na GitHubie: SmolinPavel/moonlight-sonata.

Jeśli budujesz coś związanego z dźwiękiem w przeglądarce i chcesz spojrzenia z zewnątrz, napisz do mnie.