← Home
petri-nets music beats-bitwrap

A browser music sequencer where every note is a Petri-net transition firing

Live at beats.bitwrap.io. Source: github.com/stackdump/beats-bitwrap-io.

That picture is the runtime, not a render of it. Each cluster on the ring is a sub-net (kick, snare, hihat, bass, melody, plus four hit slots) with its A/B/C variants beside it; the spokes meeting at the centre are the connector places that compose the song. The audio engine is reading exactly this state to fire transitions — at any moment you're hearing one variant per slot, chosen from the denser structure on screen.

The thing I wanted to share isn't that we built a beat generator — there are plenty. It's the implementation choice underneath it: the sequencer is a Petri net executor. There is no separate timeline data structure, no event list, no "schedule note at tick N." A drum pattern is a ring of places with one token circulating; each transition fire is a note. Polyrhythm is two rings of different length sharing a tempo. Song structure is a control net that fires mute-track / unmute-track / activate-slot actions at section boundaries. Macros are short linear-chain control nets injected at runtime with a restore action on the terminal transition.

Once you commit to that, several properties fall out for free:

Deterministic by construction. Same (genre, seed) produces a byte-identical token-flow trace. We have a Go port of the JS composer that produces the same bytes; the parity test is in CI. This isn't "we test for determinism" — there isn't a non-deterministic codepath to begin with. The web worker drives the tick loop at 60000 / (BPM × PPQ) ms and the Petri net engine does the rest.

Share URLs are content-addressed. A track is a small JSON envelope (@context + genre + seed + optional overrides), canonicalized, sha-256'd, encoded as a CIDv1 in base58btc. The URL is ?cid=z…. Same canonical bytes ⇒ same CID, always. Different bytes can't pretend to be an existing one. There's also a ?cid=…&z=<base64url-gzip> form that inlines the gzipped envelope so the link works offline, from a local copy, or after the share store is purged. ~80 chars for the short form, ~1.5 kB for the self-contained form.

The visualizer is the runtime. Press M and the app turns into a Petri-net display: nine sub-nets in a ring, each showing the active variant for its slot, joined through central connector places. The visuals aren't reading the audio — they're reading the same place/transition state the audio engine is reading. Token particles pulse toward the composition core on each fire; per-panel flames ignite on every transition. It's the first time I've shipped a music tool where the thing on screen is literally the data structure making the sound.

Boring stack. No bundler, no npm install, no React. Vanilla ES modules, Tone.js from CDN, a single Go binary that serves the static files and the share store. Production has no audio renderer running — .webm renders are uploaded by listeners' browsers via PUT /audio/{cid}.webm (rate-limited, first-write-wins, hash-checked). The site doesn't transcode; it stores bytes other browsers produced.

No backend during playback. Once a share envelope arrives, every sound is generated in the listener's tab. The server's job is content addressing and feed indexing; the audio path is browser-only.

There's a /feed gallery, a Winamp-flavoured sidebar player, an Auto-DJ that picks random macros every N bars, and a regen mode that cross-fades into newly-generated tracks on a one-bar pre-render so the swap is a pointer flip with no clicks. Those are nice but they aren't the point — they're what falls out after you commit to the runtime model. The longer launch post with screenshots and details is at /posts/beats-launch-jambox.

We still haven't added a piano roll.

Things I'd be most curious to discuss:

Code, generator, share-store sealing, and the Go/JS parity tests are all in the repo.

×

Follow on Mastodon