Skip to content
2025Live and maintained

Slateberry

A zero-dependency WebAudio library that plays NES-style music from a compact note syntax. Ships about 4kb and no assets.

typescriptaudioweb-audioopen-source

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.

ts
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.

ts
// 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.

ts
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.

HAVE A QUESTION?

Open an issue on the repository, or just email me. Both work.

Email Razi

MORE PROJECTS

Live

This website — a statically exported Next.js app with a hand-built 8-bit design system, MDX content, and a CRT phosphor you can switch.

nextjstypescripttailwinddesign
LiveSource
Live

A browser tool that converts any image into 1-bit, 4-colour and 8-colour pixel art with real dithering algorithms. Fully client-side.

typescriptcanvastoolingwebgl
LiveSource
In progress

A habit tracker built as a text adventure. Quests, XP, stats, and a deliberately punishing stat decay that made me actually do the things.

typescriptreactsqlitedesign
Source

Command palette

Search for a command to run