Most "8-bit music" on the web is an MP3 of a chiptune recording. Chiptune.js is the other thing: a tiny synthesis engine that generates the audio at runtime, the way the NES did.
The idea
Music is written as a string of note names and durations. The engine schedules square waves ahead of time on a WebAudio timeline. No files, no decoding, no loading state — and the entire soundtrack for a page can be a few hundred bytes of text.
import { play, pulse, duty } from "chiptune-js"
play({
// pulse 1 and pulse 2 are the two square channels
pulse: [
["E4", "G4", "A4", "G4"], // channel 1
["C4", 0, "D4", 0], // channel 2
],
tempo: 150,
duty: duty(0.25), // 12.5% / 25% / 50% — the pulse width
loop: true,
})Why the envelopes are so ugly
On purpose. A NES pulse channel is a square wave through a fixed volume envelope — four steps, no sustain, no filter. If you add a smooth attack and a release ramp it stops sounding like a console and starts sounding like a synth, which defeats the entire point.
// Hard attack, instant decay — that is the whole trick.
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(peak, start + 0.006)
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration)Six milliseconds of attack. Everything else is the hardware's fault, faithfully emulated.
Browsers hate you
The single hardest problem is that a WebAudio context starts suspended until a user
gesture. Autoplay policies mean your carefully scheduled intro will silently not play.
async function unlock() {
const ctx = getContext()
if (ctx.state === "suspended") await ctx.resume()
// then schedule notes against ctx.currentTime, not Date.now()
}Scheduling against ctx.currentTime is mandatory. Date.now() drifts against the audio
clock within seconds, and your music falls apart.
The other trap: creating a new OscillatorNode per note is fine, but forgetting
osc.stop() leaks nodes until the tab dies. Every note schedules its own stop.
Status
Stable and used in production on a couple of side projects. The thing I'm most pleased with is that the API is small enough to memorise — four options, one call.
The next version adds a triangle channel for bass and a noise channel for percussion, which is all three of the NES's remaining audio channels.