There's a little music note button in the navigation bar of this site. Click it and you get a three-minute synthwave track with pads, an arpeggio, a bass line, drums, and a lead melody.
There's no audio file behind it. No MP3, no streaming embed. The browser builds every sound from scratch while you listen, using about 300 lines of TypeScript and the Web Audio API.
Until this week, that button played a lo-fi chiptune loop I added in October. It was fine, but the site already had a synthwave color theme, and the music didn't match it. So I rewrote it. Here's how the whole thing works, from turning note names into numbers to keeping the timing tight when the browser is busy.
Notes Are Just Numbers
Music theory gives you note names like A4 or C#5. The browser wants frequencies in hertz. The bridge between them is MIDI numbering, where every semitone gets an integer and A4 is note 69, tuned to 440Hz. Each semitone up multiplies the frequency by the twelfth root of two.
That one function lets me write the song in note names, which I can actually read, and convert them at the last second. It also throws on a typo like "H4", which matters more than you'd think when the whole song is hand-typed strings.
Writing the Song as Data
The song itself is plain data. Chords are a bass note plus a four-note voicing for the pads:
Each bar is split into 16 steps, which are sixteenth notes. The melody is a list of bars, and each note is a tuple of which step it starts on, the pitch, and how many steps it lasts. TypeScript's labeled tuple types make that readable:
The arrangement is a list of sections. Each one says how long it is, which chords to cycle through, and a few knobs that change the energy:
That's intro, verse, chorus, breakdown, chorus, and outro. No groove means no drums or bass. No leadCutoff means no melody. A low padCutoff muffles the pads, which is how the intro and outro sound distant before everything opens up.
Keeping the song as data pays off. I can rearrange it without touching any audio code, and the tests can check it. One test makes sure every pitch in the melody is valid and that no note runs past the end of its bar.
Making Sounds From Oscillators
Web Audio gives you oscillators that produce basic waveforms: sine, square, sawtooth, and triangle. On their own they sound thin and buzzy. The classic synthwave trick is to stack several copies of the same note and detune each one a few cents. They drift in and out of phase with each other, and the result sounds wide and warm.
The pads use three sawtooth voices per note. The lead uses five, plus vibrato. The vibrato is one more oscillator running at 5.5Hz, wired into the detune of every lead voice so the pitch wobbles slightly.
An oscillator that turns on and off instantly clicks, so every note goes through a gain node that shapes its volume over time. There are two shapes in the file. Pads and lead fade in, hold, and fade out:
The arpeggio, bass, and drums start loud and decay right away, more like a plucked string:
The pluck ramps to 0.001 instead of 0 because exponential ramps can't reach zero. The curve is calculated from the ratio between the start and end values, and zero breaks that math, so the browser throws a RangeError if you try.
Drums Without Samples
Drums sound like they'd be the hardest part, but they're some of the shortest functions in the file.
A kick drum is a sine wave that drops in pitch very fast. Starting at 150Hz and falling to 48Hz in a tenth of a second gives you the thump:
Snares and hi-hats are filtered noise. The player fills one second of audio with random values once at startup, then plays short slices of it through different filters. A bandpass filter around 1800Hz gives the snare its crack, and a triangle wave at 190Hz underneath gives it some body. A highpass filter at 8000Hz leaves only the hiss, which is a closed hi-hat.
Each noise hit starts at a random offset into the buffer, so no two hi-hats are exactly the same.
Reverb and Delay
Dry synths sound like a ringtone. The 80s sound comes from huge reverb and echoing delay.
Web Audio's ConvolverNode does reverb by blending your sound with an impulse response, which is normally a recording of a real room. I don't have a recording, but random noise that fades out sounds a lot like a room's echoes dying away:
Cubing the fade makes the tail die off quickly at first and then linger, which is close to how real reverb behaves. Using separate random noise for the left and right channels makes it sound wide.
The delay is a DelayNode feeding into a gain node that feeds back into the delay. Each echo comes back at 38% of the volume of the one before it. The delay time is 0.375 seconds, which gives the arpeggio and lead a trail of repeats behind every note.
Not every instrument should get the same amount of each effect. Drenching the kick in reverb turns it to mush. So each instrument plays into its own bus, which mixes in its own amount of reverb and delay:
This is the same idea as aux sends on a mixing board. The snare gets heavy reverb for that big 80s snare. The arpeggio and lead get delay. The kick, bass, and hi-hats skip the buses and go straight to the master output, which keeps the low end clean.
Timing Is the Hard Part
The obvious approach is to use setInterval and play each note when it's due. That doesn't work. JavaScript timers are only roughly on time. If the browser is busy rendering or running other code, a timer can fire late, and in music even 20 milliseconds late is audible.
Web Audio has its own clock, audioContext.currentTime, which runs on the audio thread and is precise. Every start and stop call accepts a time on that clock. So the fix is to split the job in two. A loose JavaScript timer wakes up every 25 milliseconds and schedules every note due in the next 120 milliseconds on the precise audio clock:
The timer can be late and it doesn't matter, because the notes were already booked with exact timestamps. This pattern comes from Chris Wilson's article "A Tale of Two Clocks," which is still the standard explanation of Web Audio timing.
At 96 BPM, a sixteenth note lasts 60 / 96 / 4, or about 0.156 seconds. So each timer tick usually schedules one step or none. The lookahead window is just a buffer against the timer running late.
What Happens on Each Step
Every sixteenth note goes through one function. It finds the current section and chord, then decides who plays:
BARS is the song flattened into one entry per bar, built once at startup, so finding the current section is a single array lookup. The modulo on BARS.length is what makes the song loop.
The rest is a drum pattern in code. The kick lands on every beat, which is four on the floor. The snare hits beats two and four. The hi-hats play every sixteenth note at levels that alternate between loud and soft so they groove instead of sounding like a machine gun.
During the verse, the bass filter cutoff follows a slow sine wave over 16 bars, so the bass gradually opens up and closes again. It's one line of code, and it's the main reason the verse doesn't feel repetitive.
Wiring It Into React
The React side is small. Browsers block audio until the user interacts with the page, so nothing gets created until the button is clicked:
Stopping closes the whole AudioContext, which cancels every scheduled note and frees the audio hardware. Starting again builds a fresh one. That's simpler than trying to pause and resume the scheduler.
Testing Audio Code
You can't listen to audio in Jest, but you can check what gets scheduled. The player takes an optional factory function for its AudioContext:
In production it uses the real thing. In tests I pass a fake context that records every oscillator and noise source it creates, along with the times they were told to start. With that, the tests can check that notes are scheduled right away, that the scheduler keeps running ahead as time moves forward, that the song loops, and that the lead stays quiet until the first chorus. None of that needs speakers.
Things I'd Still Change
The song loops forever with no variation, so if you leave it on long enough, you'll notice. A second melody for the second chorus would help a lot.
The delay time isn't locked to the tempo. At 96 BPM, a beat is 0.625 seconds, so a dotted eighth note, the classic synthwave echo, would be about 0.469 seconds. At 0.375, the echoes land a little off the grid. Calculating it from BPM would be a one-line fix.
The player also creates new oscillator, filter, and gain nodes for every note. That sounds wasteful, but it's how Web Audio is designed. Source nodes are meant to be played once and thrown away. It would still be worth profiling on an older phone.
If you want to try something like this, start with one oscillator and the lookahead scheduler. Once you can play a steady beat, everything else is adding layers. Click the music note in the navigation to hear where it ended up, and the full source is in src/lib/synthwave.ts if you want to read along.