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

Published

Five classic sound chips in an AudioWorklet or offline: NES, Game Boy, Mega Drive, SNES and C64. Musical scores and sound effects that borrow hardware voices.

Readme

chipvoice

Create, code and publish complete songs: music workspace, community, SDK / HTTP docs. Versioned projects preserve complete performances, with cancellable playback, original pixel avatars and immutable published WAVs. No account is needed to create or listen; publication uses your account. Project contracts and operating limits.

Automatic mixing for complete adaptations uses measured instrument responses, with optional PerformancePart.mix importance/trim and explicit unknown-sound fallbacks. Native command playback keeps its original mix. Short game phrases can use the same policy. API and qualification.

Five classic sound chips in a browser or offline: NES (2A03), Game Boy, Mega Drive, SNES and Commodore 64. A score is four musical roles arranged for the chosen hardware. Sound effects borrow physical voices from the music and return them when they finish; Stop cancels future musical writes.

The digital cores are checked against reference emulators and hardware test ROMs. The conformance sheets state verified behavior and remaining differences; this is not a claim that every analog characteristic has been measured on physical hardware.

For complete polyphonic arrangements, Performance complements the compact four-role Score. Import a MIDI with exact ticks and expression, compile an explicit hardware allocation, inspect its losses, and render in a worker:

import {importMidi, planPerformance, renderPerformance, toWav, snesChip} from 'chipvoice';
const source = importMidi(midiBytes); // Uint8Array; SMF 0/1, PPQ timing
const plan = planPerformance(source, snesChip, {allowLoss: true});
console.log(source.notices, plan.losses); // review roles, timbres and omitted notes
const wav = toWav(renderPerformance(plan, snesChip));

Without allowLoss, voice-budget omissions throw. Tempo maps, polyphony, velocities, sustain, pitch bend and volume/expression survive import; unsupported controllers are retained and reported. GM-family patches are approximations, not original-game instrument certification. The arrangement method documents input limits, source checks, native references and worker playback.

Native playback uses importVgm(vgmBytes) and the same renderPerformance(plan, chip) engine, for Mega Drive, NES and Game Boy VGM files alike: importVgm reads the header's clock fields to tell them apart and returns the same PerformancePlan either way, so the rest of the pipeline does not need to know a track came from a file rather than from a score. For the Mega Drive it preserves FM patches, PSG commands and original DAC sample bytes; for the NES it preserves DPCM sample data (VGM data-block type 0xC2); isolateNativePerformance(plan, ['fm6']) (or ['ch1'] for a DMG voice) solos the hardware voice while retaining shared bus timing. The bounded VGM importer rejects unsupported commands, a PAL or otherwise non-NTSC NES/DMG clock, the Famicom Disk System bit, a second ("dual-chip") NES or Game Boy chip, another chip's clock in the same header, and VGM versions outside 1.50-1.71, by name; it does not claim every VGM chip/stream format. See the native source method.

Native SNES playback uses importSpc(spcBytes): it plays an .spc file (a frozen SPC700 + S-DSP snapshot, the Super Nintendo's own music format) through this package's own SPC700 core, restoring the CPU, the 64 KB of ARAM and every DSP register exactly, and returns the same PerformancePlan shape importVgm does. Length comes from options.seconds or the file's ID666 tag ("seconds to play" plus fade); a file with neither throws, since a made-up length would either cut the song short or silently make every import pay for a guessed-long simulation. The ID666 tag's title, game, artist and length are returned on the plan's id666 field when present. A truncated or misidentified file throws explicitly. The CPU's boot ROM (IPL_ROM, 64 bytes at $FFC0-$FFFF, published byte for byte in Anomie's SPC700 doc and fullsnes) is embedded in ssmp.ts, since every known .spc snapshot assumes it is there; see decision 45.

Game Boy playback uses importGbs(gbsBytes) and the same renderPerformance(plan, gbChip) engine. It runs the GBS file's own INIT and PLAY routines on an own SM83 (LR35102) CPU - written from Pan Docs, gbdev's opcode tables and the GBS format spec, never ported from a GPL/LGPL emulator

  • so the register writes reaching the chip are the source program's own, not a reimplementation of its playback. parseGbsHeader(gbsBytes) reads a file's load/init/play addresses, song count and other header fields without running it. Unsupported inputs are rejected by name (an unrecognized header version, the undocumented CGB double-speed timer bit, a load address the importer's memory model does not cover) rather than played back wrong. See scores/gbs-corpus for how it is measured against an independent GBS player; this is a package feature only, with no chipvoice.dev counterpart yet.

A Commodore 64 SID music file plays with importPsid(psidBytes) and renderPsid(performance). A from-scratch 6510 runs the file's INIT and PLAY routines against a minimal, disclosed C64 environment (CIA1 timer A, the VIC raster IRQ, SID register mirroring), built entirely from HVSC's own PSID/RSID file format document, with no ported GPL code, and checked against an independent libsidplayfp oracle (scores/psid-corpus). PAL/NTSC clock and 6581/8580 model header flags are honored. Multi-SID files, RSID files that also need BASIC, and the MUS player format are rejected by name (PsidFormatError), as are the 7 unstable and 12 JAM 6502/6510 opcodes (IllegalOpcodeError) no document gives a value for. See docs/chips/c64.md#psidrsid-playback.

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.

The same song on a Game Boy is one option away. Each chip maps the song's four lines onto its own voices and writes its registers in its own idiom: the bass moves to the wave channel, a volume change becomes a retrigger, a drum's decay becomes the hardware envelope.

const gb = await Chip.create({ chip: "dmg" });
gb.play(THEME);                       // the same four lines
gb.sfx("ch2", { note: "B6", instrument: LASER, duration: 0.1 });
gb.spec.voices;                       // ch1, ch2, ch3, ch4 - what to hand sfx()

A song can carry its instruments spelled out, as THEME does, or it can be a score: the four lines and a word per role for what it should sound like, which each chip's arranger turns into its own instruments. The words are the same on every chip; INTENTS lists them with what they mean.

import { arrange, INTENTS } from "chipvoice";

const song = arrange(
  { bpm: 152, order: [0], patterns: [...], intent: { lead: "bright", bass: "hollow" } },
  "dmg",
);
gb.play(song);                        // a 12.5 % pulse lead, a square wave in wave RAM
INTENTS.bass.hollow;                  // "a square wave on the Game Boy's wave channel; a NES has only the triangle"

Try it - three cartridges, five machines, an editor and four arcade pads 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 stamped with a CPU cycle and land on that cycle, wherever it falls inside a sample, 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. (The chip has a fifth, the DMC, which plays samples from memory: the drums of most cartridges. The chip here has it too; no instrument reaches it yet.)

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 },
};

SNES chords use simultaneous voices: three for a triad, up to five for an extended chord. The chord volume budget is divided across its notes, with moderate stereo placement. Shapes exceeding five notes produce a validation warning and use a single-voice arpeggio that retains every interval. The other console arrangers use one held note arpeggiated at frame rate.

For a seamless live replacement, prepare a second chip on the same AudioContext, read oldChip.phaseAt(at) within its scheduling lookahead, and call newChip.play(nextSong, position, at). The optional position includes fractional progress within its grid step. Crossfade the two output nodes after note-on, then dispose the old chip. The demo's LivePlayback implements this bounded handoff; ordinary play(song) retains its preparation lookahead, while offline rendering starts at musical time zero.

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

Rendering is deterministic for a fixed engine version, score and render options. The engine version is the package version, which project renders report as engineVersion. Cache keys must include the engine and encoder versions. The hosted API revalidates stable song URLs, limits public renders to 30 seconds and checks deletion on every request.

import { renderSong, toWav } from "chipvoice";

const audio = renderSong(THEME, { seconds: 30 });
writeFileSync("theme.wav", toWav(audio));

The same DSP runs in both places. src/chips/nes/dsp.ts is a TypeScript module like any other: Node imports it, and the build bundles it with the worklet shell into one self-contained script - a blob URL has nothing to resolve an import against - and hands that string to addModule. 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.

Rendering one effect

renderSfx is Chip.sfx()'s offline half: a chip, a voice, a note and an instrument in, a RenderResult out, with no Song and no sequencer.

import { renderSfx, trimRender, levelRender, toWav } from "chipvoice";

const boom = renderSfx("2a03", {
  channel: "noi",
  note: 8,
  duration: 0.3,
  instrument: { volume: [15, 14, 12, 10, 8, 6, 4, 2, 1, 0], noiseMode: true },
});
const file = toWav(levelRender(trimRender(boom), { peak: 0.89 }));

The render is longer than duration by default (delay + duration + 0.5 seconds), because a decay table cut off before it reaches the floor is a click, not a shorter sound; trimRender cuts the silence the tail leaves. Pass seconds to render a different amount.

A SfxRecipe is the same call as one JSON value - { chip, channel, note, instrument, duration, ... } - which is what a catalogue stores as a sound's recipe: renderSfx(recipe.chip, recipe) renders it again, and changing one field (a note, a duty, a pitch table) is how a catalogue builds variants from one recipe rather than recording five unrelated takes.

How accurate is it

Accuracy is a measurement here, not an adjective. The chip's conformance sheet, docs/chips/2a03.md, says what has been verified, against which oracle, and what is known to differ - and docs/CONFORMANCE.md is the method every chip is held to. Until the sheet says otherwise, the honest summary is: the clocks match the formulas, the mixer and filters are the documented curves, and the parts the driver does not exercise are implemented from the wiki and unverified.

Four tests run on every push:

| | | | --- | --- | | test/validate.mjs | The validator says the right thing about the wrong song | | test/clock.mjs | Every voice's rate against the datasheet formula, the frame counter's phase, the registers against nesdev | | test/driver.mjs | What the driver writes is what a NES needed: the sweep byte, the phase restarts, silence through the channel's own registers | | test/golden.mjs | A fixed song renders to the same bytes. If the hash moves, the sound moved |

The golden hash is the one to watch. A fix that brings the chip closer to the hardware changes it, and the commit that does so says what moved and why. A hash that changes without that sentence is a regression.

And on every push, conform - the harness in packages/conform - does two more things. It runs a corpus of register logs through this chip and through Nes_Snd_Emu, blargg's reference, and compares the two cycle for cycle: the pulses are identical to it on every song, and what differs, and why it is the oracle's convention rather than a bug here, is on the sheet. And it runs every one of blargg's APU test ROMs on a 6502 it carries for the purpose - length counters, frame timing to the cycle, the IRQ, the reset button, the DMC - and all twenty-nine pass. Blargg's mixer tests, which cancel each channel against the DMC's DAC and which he recorded on a real NES, cancel here as well as they did on his console: the DAC curves are measured, not assumed.

The Game Boy's chip, at src/chips/gb/dsp.ts and exported as gbChip, is held to the same method: test/gb.mjs checks its clocks against the formulas, the harness runs blargg's twelve dmg_sound ROMs on an SM83 of its own, all of which pass, and compares it with Gb_Snd_Emu, where 97.5 % of the voices' edge runs line up under that oracle's own timing convention. Its sheet is docs/chips/dmg.md. test/gb-driver.mjs checks what the driver writes to it, and test/golden-dmg.mjs locks its render the way golden.mjs locks the 2A03's.

The third chip is the Mega Drive's, mdChip: a YM2612 at src/chips/md/ym2612.ts that is Nuked-OPN2 ported line for line - the harness compares the two and they are identical cycle for cycle on every voice - and an SN76489 from the documents. Chip.create({ chip: "md" }) puts the lead and the bass on FM patches, the chord on the PSG and the kit on its noise; Instrument.fm is a patch of four operators for a host that wants its own. Its sheet is docs/chips/md.md. Licence: that one file is a derivative of Nuked-OPN2 and is LGPL 2.1; the package's licence field says (MIT AND LGPL-2.1-or-later) and everything else is MIT.

The fourth is the SNES's, snesChip: an S-DSP at src/chips/snes/sdsp.ts that is snes_spc's ported line for line, the second LGPL file, and identical to it sample for sample on the DSP's output stream. Everything on it is a sample: Chip.create({ chip: "snes" }) plays an original BRR bank authored and encoded at build time, with the machine's echo on. Lead intents choose flute, brass or mallet; chords choose harp or strings; bass intents choose picked, reed or synth bass. Each has its own attack, sustain loop and hardware envelope. Legacy waveforms and drums remain available; encodeBrr is also exported. The kit's kick and snare are BRR drums; its closed and open hats default to the DSP's own hardware noise (NON, and FLG's noise clock set once at power-on) the way the other chips' kits use their own noise, through Instrument.noiseMode

  • set it false on a hat's instrument for the BRR burst instead. Instrument.sample names a sample in the driver's bank. Its sheet is docs/chips/snes.md.

The fifth is the Commodore 64's, c64Chip: a 6581 SID at src/chips/c64/sid.ts, written from the documents and identical to reSID-fp, the harness's oracle, on both digital values of every voice. Three voices for the score's four lines: Chip.create({ chip: "c64" }) puts the lead and the bass on a voice each and the chord and the kit on the third, where a drum cuts the chord and the chord comes back after it. Instrument.waveform picks a voice's waveform, one for the note or one per frame. The analog stage is a profile, SID_6581_PROFILE: the chip's non-linear DAC ladders, its filter on a measured curve, the output stage. Its sheet is docs/chips/c64.md.

The song, as bytes: VGM

A NES saw a song as register writes, and so does this library, so the most honest file it can produce is the list of them. That list is a VGM file - the chiptune world's exchange format - and every chiptune player opens it, and a VGM player on real hardware plays it on the chip itself.

import { recordSong, toVgm } from "chipvoice";

const { events, cycles } = recordSong(THEME, { seconds: 30 });
writeFileSync("theme.vgm", toVgm(events, cycles, { title: "Theme", author: "me" }));

recordSong runs the same driver and sequencer as renderSong and keeps what they write instead of playing it. toVgm rounds each write to a sample at 44100 Hz, which is the format's clock, and writes a GD3 tag so a player shows a name. Pass loopAtCycle and the file loops there.

For a NES song there is also exportNsf, which produces a standard NSF v1 file carrying its own tiny hand-assembled 6502 player instead of a raw write list, so it plays back on real hardware or in any NSF player, not just this library's own:

import { recordSong, exportNsf } from "chipvoice";

const { events, cycles } = recordSong(THEME, { seconds: 30 });
writeFileSync("theme.nsf", exportNsf(events, cycles, { title: "Theme", author: "me" }));

Its player replays one frame's writes per 60 Hz PLAY call, so timing is quantized to the frame (about 16.7 ms). DMC/DPCM sample playback is autonomous hardware DMA needing no mid-frame code, only the sample bytes present in a fixed upper bank; pass them as options.memory (the same shape as PerformancePlan.memory) and DMC plays back normally. A capture that enables DMC without that memory throws a named NsfExportError('dmc_sample_missing', ...), and the one case this player truly cannot carry - raw $4011 PCM streamed through the DAC many times within a frame, not DMA sample playback - throws NsfExportError('dmc_unsupported', ...) rather than drop or approximate it. See docs/chips/2a03.md for the full design and its conformance numbers against a real NSF player.

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.

The song, as bytes: SPC

A SNES song is samples driven by S-DSP register writes read off ARAM, and an .spc file is exactly that machine's own save state - the sample-playing console's native exchange format, and any SPC player, on real hardware too, plays it with no chipvoice runtime involved.

import { recordSong, exportSpc } from "chipvoice";

const { events, cycles, memory } = recordSong(THEME, { chip: "snes", seconds: 30 });
writeFileSync(
  "theme.spc",
  exportSpc(events, cycles, memory, { title: "Theme", artist: "me", loopAtCycle }),
);

The exported ARAM carries its own tiny SPC700 player (this package's own source, MIT, no opaque blob), the compacted sample directory and BRR data the song actually uses, and the S-DSP write stream as tick-delta/register/value. Pass loopAtCycle and playback repeats forever from there, the way a game's own track does. A song that cannot fit in the SNES's 64 KB of sound RAM throws SpcExportSizeError, carrying measured and limit, rather than writing a truncated file - shorten the song or use fewer distinct instrument samples.

Other chips

Five machines ship: NES, Game Boy, Mega Drive, SNES and C64. ChipSpec names voices and role allocation; ChipCore consumes timestamped register writes and fills buffers. Instruments support frame tables, FM patches and samples.

The portable score keeps four musical roles. Arrangers map them onto each machine: FM lead/bass and PSG chord/drums on Mega Drive (or FM drums on channel 6, perc: "punchy", with the chip's own LFO available to any patch that asks for it), four sample voices on SNES, and shared chord/percussion on the C64's third voice. The C64's filter is reachable too: lead: "sweep" opens its cutoff across a note, bass: "resonant" routes a pulse through it at a high resonance. VGM export and import both support NES, Game Boy and Mega Drive; SNES has its own native .spc exporter and importer instead (above; see also importSpc); C64 register logs do not yet have a shipped file exporter or importer.

validateSong reports machine-specific base-pitch and arpeggio range warnings. It preserves the score; it does not guarantee every modulation stays representable. See the portable score and each chip's conformance sheet for capabilities and the distinction between corpus parity and physical measurements.

A game's own Mega Drive driver

A game written for the Mega Drive alone can skip the portable score and write for the machine: six FM channels with hard pan, three PSG tones, the noise at any of its rates, PCM drums on the DAC. compileMdVoices turns voices into register writes, arrangeMdTracker turns a song written as text into voices, MD_BANK is a bank to start from, and renderMdEvents plays the writes on a fresh chip. trimRender, levelRender, packSprite and renderOnset are the steps between a render and the files a game ships.

import { arrangeMdTracker, compileMdVoices, renderMdEvents, levelRender, toWav, MD_BRIGHT_PROFILE } from "chipvoice";

const song = arrangeMdTracker({
  bpm: 160, order: ["A"], loop: "A",
  channels: { lead: { voice: "fm1", patch: "lead", vibrato: {} }, drums: { voice: "dac" } },
  sections: { A: { bars: 1, lead: "^E5:4 G5:2 A5 B5:8", drums: "k.k.s..kk.k.S..." } },
}, { tailBars: 1 });
const { events } = compileMdVoices(song.voices);
const render = levelRender(renderMdEvents(events, { seconds: song.totalSeconds, profile: MD_BRIGHT_PROFILE }), { peak: 0.89 });
const wav = toWav(render); // loop from loopStart to loopEnd; the bar after is the tail

Punk Force's soundtrack and effects are written on it. The voices, the tracker's syntax, the bank and the decoder's shift are in A game's own Mega Drive driver.

Controlled variations

import { varyScore } from "chipvoice";
const variation = varyScore(score, {
  kind: "melody", // or "drums", "timbres"
  locked: ["bass", "chord"],
  seed: 42,
});

Variations are local and reproducible. Melody reuses existing pitch classes and keeps rhythm, including silent patterns; drums choose authored grooves. Locked roles retain notes and timbres. An edit drops an explicit playback ID so arrange derives a new one. The demo adds Undo, optional MIDI note input, aligned stems and five-machine ZIP exports with cancellation.

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.position(into?) | Current audible step and order index; optional caller-owned storage | | chip.quantizedPosition() | Nearest sixteenth at input time, including pattern/loop wrap; null before playback or in a scheduling gap | | 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.

Bringing your own sequencer. Chip is the whole thing, and what a game should reach for. A game that already has a music state machine - pause, hold, resume - wants only the part that turns notes into register writes and talks to the worklet, and that is exported on its own:

import { APU, type NoteSink } from "chipvoice";

const apu = new APU(ctx);
await apu.init(master);          // loads the worklet, connects it
apu.playNote("p2", { note: "B6", instrument: LASER, duration: 0.1, at });

OfflineDriver is the same class writing into a chip core instead of a worklet, which is what renderSong uses.

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.

cd packages/chipvoice
npm version patch --no-git-tag-version      # or minor, or major
git commit -am "chipvoice $(node -p 'require("./package.json").version')"
git tag -a "v$(node -p 'require("./package.json").version')" -m "chipvoice $(node -p 'require("./package.json").version')"
git push --follow-tags                         # follows annotated tags only, hence -a

Three steps rather than one because npm version only commits and tags when the package sits at the root of the repository, and this one sits in a workspace: run bare, it bumps the file and quietly does nothing else.

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 for the original code; LGPL-2.1-or-later for the derived YM2612 and S-DSP cores. The package licence is (MIT AND LGPL-2.1-or-later). See the source headers and bundled licence notices. Console marks retain their owners’ rights.

Demo and transport

The playable demo preserves the complete score through editing, drafts and publication. Copy its score and runnable browser example, or export stereo WAV. Local mute/solo and unrecorded live effects are not part of an exported score.

chip.play(song, { step, orderIndex }) starts at a musical position. Use chip.position() when replacing an instance to compare the same passage on another machine. arrange(score) retains its target chip, which renderSong honors unless explicitly overridden. Always dispose replaced instances.

Server audio links use the currently deployed renderer and revalidate their bytes. For reproductions across releases, retain the score and pin the package version. See the demo specification.

Shape a composition

shapeScore(source, { transpose: 7, drums: 60 }) moves pitched parts by seven semitones and retains 60% of the source drum hits, with deterministic preference for kick/snare and strong beats. Chord intervals, rests and timing are preserved. The result is an ordinary score, usable by arrange, playback, recording and export. Apply each preview to the same source; {transpose: 0, drums: 100} returns that source exactly. transposeBounds(source) gives the allowed shift within one octave; out-of-range shifts throw rather than clip notes. See the repository's score workflow.

Straight notes and triplets (0.14.0)

Score.stepsPerBeat / Song.stepsPerBeat accept 4 (default) or 12. A token is one grid step: twelve steps per quarter note can represent both sixteenths and triplets without changing BPM. loopSeconds, live capture, rendering, pad quantization and score transformations use this grid. Leave an unused role silent with = followed by . tokens; an arranger never needs an invented bass line.

Offline renderSong and recordSong start at musical time zero. A full-loop duration therefore includes the final note; live playback retains its preparation lookahead. The audio golden snapshots changed intentionally with this correction. Captures use the same sample-rounded duration as WAVs. When rendering at a rate other than 44100 Hz, pass the same sampleRate to recordSong for exact replay through the final sample, including loops whose duration is fractional in samples. The capture shares the cores' event queue: rests cancel obsolete future writes without rescanning the already captured history.

See the source comparison workflow for deterministic melody checks, their tolerances and the distinction from acoustic/DSP fidelity.

Offline arrangement progress

renderPerformance(plan, chip, {onProgress: fraction => { /* update your UI */ }}) reports completed audio frames as a fraction from 0 to 1. Run long renders in a worker and throttle UI messages; this callback does not change the produced PCM. The arrangement lab demonstrates parsing, rendering and playback preparation states. MIDI import preserves UTF-8 names and reports a Windows-1252 fallback for legacy Western text.

MIDI imports infer musical roles from the notes rather than track order; the web player lets you review and override them. Adaptation mixing excludes source-silent notes from voice allocation and reserves shared SNES dry headroom. Evaluation checks complete performances, dynamic contrast and calibration error; deterministic output is not a guarantee of original-game timbre or subjective mix quality.

Native FM ports use measured patch pitch and amplitude descriptors rather than interpreting local patch IDs as MIDI instruments. The SNES factory palette is dry from 0.16.3. Sonic DAC ports distinguish observed kick/snare attacks instead of mapping every burst to a kick. These improve adaptations; they do not make cross-console timbres identical. See the measured-projection workflow in the mixing API.

Progressive interactive playback

Since SDK 0.18.0, the web composer uses new ProjectPlayer({preview: true}). This opt-in SDK mode compiles the same project and renders the same chip cores as offline export, but schedules bounded PCM blocks as they become available. It does not encode/decode a complete WAV before playing. previewMetadata exposes duration, native status and mix results; losses works in both playback modes. prepared remains null in preview mode. Use prepareProject() or renderProject() explicitly when you need a downloadable file.

import {ProjectPlayer} from 'chipvoice';

const player = new ProjectPlayer({preview: true});
// Call play from a user gesture to unlock browser audio.
void player.play();
await player.load(project);
await player.update({tempoScale: 1.25});
player.setTitle('New title'); // Metadata only; no audio preparation.

The player keeps the current sound during preparation, preserves Play/Pause intent and follows the audio output clock. A warm worker and bounded variant/PCM/checkpoint caches accelerate repeated edits and seeks. Cold mid-song changes still need to reconstruct DSP history: an emulator's envelopes, samples, filters and echo cannot be restored from note positions alone. Browser audio unlock, uncached network assets and device latency remain real costs. Default ProjectPlayer() keeps the existing whole-buffer behavior for compatibility.