Playing Beethoven in the Browser with the Web Audio API
How a small JavaScript library plays the Moonlight Sonata in the browser using nothing but oscillators, with a live demo you can click and hear.

There’s no MP3 anywhere in this post. No sample library, no <audio> tag, no recording of a piano. The button below plays the opening of Beethoven’s Moonlight Sonata using sine waves generated on the spot by the Web Audio API, driven by a small library I wrote called browser-dj-js. Click it:
Live demo — real oscillators, no audio file
Click to hear it. Runs entirely in your browser.
If you’re a music player, guitar or piano, you already have everything you need to make any melody sound like this: the notes and their durations. That’s the whole input. Here’s how it works, and the video walkthrough if you want to build it yourself.
From a note name to a sound wave
A musical note is just a frequency. A4, the A above middle C, is 440Hz by convention, and every other note is a fixed number of semitones away from it on a logarithmic scale. browser-dj-js keeps a map of note names to piano key numbers and derives the frequency from a single formula:
function calculateFrequency(keyNumber: number): number {
return 16.35 * Math.pow(2, (keyNumber - 1) / 12);
}
16.35 is the frequency of C0, the lowest C on a standard 88-key piano. Every semitone up multiplies the frequency by the twelfth root of two, which is why the exponent divides by 12. Once every note has a frequency, playing one is a handful of Web Audio API calls: create an oscillator, connect it to a gain node, connect that to the speakers, and tell it when to start and stop.
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 is just this, awaited in sequence over an array of { note, duration } pairs. No note found for a name, it falls back to a plain setTimeout, so a typo turns into a rest instead of a crash.
Wiring it into a real project
The setup is a stock Vite + React + TypeScript app, nothing exotic:
pnpm create vite moonlight-sonata
cd moonlight-sonata
pnpm add browser-dj-js
Playing three notes takes an AudioContext (the browser’s audio graph entry point) and an array of notes:
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 },
]);
Three lines, and the browser plays the opening triplet of the Sonata. Wire that up to a button’s onClick and the whole “app” is a handler function.
The trick that makes it sound like music, not a doorbell
Three notes in a row is recognizable but thin. What actually makes the Moonlight Sonata sound like the Moonlight Sonata is the bass line running underneath the triplets, held for much longer durations while the melody keeps moving. That means two independent voices playing at the same time, which means two independent AudioContext instances, each running its own playMelody call concurrently:
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]);
};
Neither call is awaited here, so both playMelody loops run in parallel, each ticking through its own notes on its own clock. That’s the entire “arrangement”: one context holding down the bass, one context running the triplets on top, started in the same tick. The demo at the top of this post runs on exactly this scheduling logic, with pause and stop wired in around it using AudioContext.suspend(), resume(), and close().
Looking at the melody arrays, there’s an obvious pattern: three near-identical phrases (melody, melody, melody2, melody3) built from the same rhythmic cell repeated with different pitches. That’s not an accident of the transcription, it’s how the piece is actually built, and it’s the kind of structure that would make it easy to generate the phrases from a smaller pattern instead of hand-writing every note. Left as an exercise for anyone who wants to take this further.
Try it yourself
If you play an instrument, this is a genuinely approachable weekend project. You already know how to read a melody; browser-dj-js just needs the note names and durations. Grab it from npm, or look at the source to see how small the whole implementation actually is — the entire library is one file. The full Vite + React example from this post, exactly as built in the video, is on GitHub too: SmolinPavel/moonlight-sonata.
If you’re building something audio-related in the browser and want a second pair of eyes on it, get in touch.