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.
Maintainers
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. Seescores/gbs-corpusfor 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 chipvoiceimport { 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:
PeriodicWaveis 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
falseon a hat's instrument for the BRR burst instead.Instrument.samplenames a sample in the driver's bank. Its sheet isdocs/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 tailPunk 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 -aThree 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.
