npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

chipvoice

v0.2.0

Published

Game audio on a real NES sound chip. Cycle-accurate 2A03 in an AudioWorklet or rendered offline to a file, with sound effects that steal channels from the music - the way the hardware did.

Readme

chipvoice

Game audio on a real sound chip.

A cycle-accurate Ricoh 2A03 - the NES chip - running in an AudioWorklet, with a driver and a tracker on top, and one thing no other browser library does: sound effects take channels away from the music, the way the hardware forced them to.

npm i chipvoice
import { Chip } from "chipvoice";

const chip = await Chip.create();     // from a click, so the browser allows audio
chip.play(THEME);

// The gun. It holds pulse 2 for a tenth of a second, and the chord drops out
// under it - which is most of what makes this sound like a console.
chip.sfx("p2", {
  note: "B6",
  instrument: { duty: 0, volume: [13, 12, 10, 8, 5, 2], slide: -3.4 },
  duration: 0.1,
});

No files to copy, no build step, no setup. The worklet is inlined and handed to the browser as a blob URL, so npm install is the whole installation.

Try it - a grid, a playhead, and a Fire button that takes a channel away from the music while you watch. The song lives in the URL, so a link is the save file.

Why not oscillators

Every other "8-bit" library in a browser is OscillatorNode with type: "square" and a gain envelope. That cannot sound like a console, for three reasons that are structural rather than a matter of tuning:

  • PeriodicWave is band-limited. Web Audio anti-aliases its waveforms. The 2A03 outputs a raw square with every harmonic intact. There is no setting for this.
  • The 2A03 mixes non-linearly. The two pulses go through 95.88 / (8128/(p1+p2) + 100), the triangle and noise through another curve. Two notes together are not the sum of two notes.
  • Three analog filters - high-pass at 90 Hz and 440 Hz, low-pass at 14 kHz - are what give the NES its boxy, bass-light voice.

So this steps 8-entry duty sequences and a 15-bit LFSR at the chip clock, mixes them through the hardware's own DAC curves, and runs the filters. Register writes arrive timestamped and are applied sample-exactly, so a slide lands on the frame it was scheduled for.

Four channels, and the fight over them

| Channel | Usually music | Usually effects | | --- | --- | --- | | p1 | Lead | Game over | | p2 | Arpeggiated chord | Everything else | | tri | Bass | Explosion sub-thump | | noi | Drums | Hits, explosions, whoosh |

There are four voices and no more, so music and effects compete for them. Every library that generates chiptune in a browser gives the music its own tracks and the effects theirs - which is the one thing the hardware could not do, and losing it is most of why those libraries sound wrong.

chip.sfx() claims a channel for the length of the effect. The sequencer asks before every note it schedules, and skips the ones it cannot have.

chip.canPlay("p1");        // is the lead free right now?

Writing music

One token per sixteenth note, four channels, as text.

const PATTERN = {
  bass:  `A1  .   A1  .   A1  .   A1  .   A1  .   A1  .   A1  .   G1  .`,
  lead:  `E4  .   .   .   G4  .   A4  .   .   .   B4  .   C5  .   .   .`,
  chord: `A3  .   .   .   .   .   .   .   .   .   .   .   .   .   .   .`,
  chordShape: [[0, 3, 7]],   // one held note, arpeggiated at 60 Hz
  perc:  `K   .   H   .   S   .   H   .   K   .   H   K   S   .   H   .`,
};

A note name (A4, F#3), . to hold, = to cut. Drums use K kick, S snare, H hat, O open hat. The bass line's token count sets the pattern length, so a bar in five is possible.

Instruments are per-frame tables, the shape FamiTracker settled on, because that is what a driver on the real machine wrote every NMI:

const LEAD = {
  duty: 1,                                   // 0 = 12.5%, 1 = 25%, 2 = 50%
  volume: [15, 15, 14, 13, 12, 12, 11, 10],  // one entry per frame, at 60 Hz
  sustain: true,                             // hold the last value until note off
  vibrato: { depth: 0.18, rate: 8, delay: 12 },
};

Chords are one held note arpeggiated at frame rate, not three notes. That is what the hardware did when it ran out of channels, and it is the most recognisable chiptune texture there is.

Snapping effects to the beat

Rez's cheapest trick: snap a player's own sounds to the grid and somebody with no rhythm still sounds like a musician.

chip.sfx("noi", { ...boom, delay: chip.beatDelay() });

Capped at 120 ms by default - past that it plays immediately, because being on time matters more than being in time. Never do it to the gun. A shot that arrives an eighth late reads as a mushy trigger, and that is the one thing a shooter cannot afford.

Rendering without a browser

The chip is a pure function of the song and the sample rate. Same input, same bytes, every time - which is what lets a server compute a file on demand and cache it forever instead of storing one.

import { renderSong, toWav } from "chipvoice";

const audio = renderSong(THEME, { seconds: 30 });   // ~1.4s for 30s of sound
writeFileSync("theme.wav", toWav(audio));

The same DSP runs in both places. src/chips/nes/dsp.js has no imports and no host globals precisely so it can: the build inlines it into the worklet, where import does not exist, and exports it as a module for Node. The only difference between real time and a file is where the sample clock comes from - currentFrame in a worklet, a counter offline.

test/parity.mjs measures both and compares. Loudness matches to a thousandth, brightness to six percent; the rest is the browser starting its context wherever it likes.

Checking a song before playing it

import { validateSong } from "chipvoice";

const { ok, issues, measured } = validateSong(song);

There is one property of this format that is hostile to anything writing songs without ears: a mistyped note is silent. A token that is not a note name resolves to 0 Hz, the driver returns without scheduling anything, and the result is a hole in the middle of a piece with no error anywhere.

So every issue carries silent, which is the difference between a mistake and a mistake that leaves no evidence:

{ "level": "error", "track": "lead", "step": 12, "token": "H4",
  "message": "not a note name. A note is a letter A-G, an optional # or b, then an octave: A4, F#3, Bb2. Use . to hold and = to cut",
  "silent": true }

It also measures - loop length, onset density, melodic range - and warns when a loop is under fourteen seconds, which is where a piece starts being heard as a repeat.

Other chips

There is one implementation, and the shape is ready for the second without pretending to have it.

ChipSpec describes what actually differs between machines: the voices, in number and in kind; whether an instrument is per-frame tables, an FM patch or a sample; and whether a voice takes a pitch, a noise period, or a sample. ChipCore is what does not differ - something that takes timestamped register writes and fills a buffer.

What is still 2A03 in disguise, named rather than hidden:

  • Channel is the literal union "p1" | "p2" | "tri" | "noi"
  • Instrument is volume, duty, arpeggio, slide, vibrato - the model for simple waveform chips. An FM patch is four operators with an envelope each, an algorithm and a feedback level, and should not be forced into this shape
  • Pattern names four voices. Eight do not fit four named fields
  • percussion assumes a noise channel; the SNES has none, its drums are samples

Each chip is a project, not a file. The 2A03 is the simplest and best-documented one; the SNES is a small sampler with a 64 KiB budget and BRR compression. Generalising against a single case produces a bad abstraction, so the shape above is deliberately concrete and will be rewritten against the second chip rather than guessed at now.

API

| | | | --- | --- | | Chip.create(options?) | Starts the chip. Resolves to null where AudioWorklet is missing, so a caller degrades instead of crashing | | chip.play(song) | Starts a song. A no-op if that song.id is already playing | | chip.stop() | Stops it and frees every channel | | chip.sfx(channel, opts) | Plays an effect, taking the channel from the music | | chip.canPlay(channel, at?) | Whether a channel is free | | chip.beatDelay(maxWait?) | Seconds until the next eighth, capped | | chip.setGain(0..1) | Ramped, because a step is a click | | chip.output | The node everything runs through, for analysers and recording | | chip.audioContext | For sharing one context with the rest of your audio | | chip.dispose() | Frees the worklet, and closes the context if it made it |

Songs are matched by id, not by identity: a variant built at call time - a spread to change one field - fails an identity check and restarts the piece on every call.

Releasing

Publishing runs on a tag, from GitHub Actions, over trusted publishing: npm trades the workflow's OIDC token for a short-lived credential, so no secret is stored anywhere. There is nothing to leak, rotate, or forget to revoke - and npm is retiring 2FA-bypass tokens for direct publishing in January 2027, so the alternative has an expiry date on it.

npm version patch && git push --follow-tags

The workflow refuses to publish if the tag and package.json disagree, and runs test:fresh first - which installs the tarball into an empty project and drives it in a browser. It is the only check that sees what npm install actually hands over: a wrong files list, a missing export or a worklet left out of the package all look perfect from inside the repo.

Where it comes from

Extracted from redburner.com, a wireframe rail shooter drawn in the four-shade red palette of the 1995 Virtual Boy. Every sound in it comes from this code, which is the only integration test that means anything.

MIT.