← Home
petri-nets music beats-bitwrap mcp swift

beats-btw-dj: the Petri-net sequencer, native on the Mac

Site: dj.bitwrap.io. Source: github.com/stackdump/beats-btw-dj (MIT; sets made with it are CC BY 4.0).

When Petri Nets as a Music Sequencer went to Hacker News, the thread split two ways. Some commenters questioned whether a ring of places beats a piano roll, and that argument is about the model. The other objection was plainer: the demo was off the beat. Triggers landed at the wrong token position or drifted, and it didn't work well on phones. The answers pointed at the platform. A browser gives a sequencer coarse, jittered timers, and the mixing happens in a tab that is also doing everything else. The post now links the tracking issue, beats-bitwrap-io#2, and in the thread we said we would try this again outside the browser. The runtime post, the earlier Show HN, describes the browser design in more detail.

beats-btw-dj is that second attempt. It is a Mac app written in Swift: SwiftUI for the views, CoreMIDI for controllers, AVAudioEngine for the audio graph. The browser version stays what it was, a deterministic, shareable studio. This one is for performing.

The generator, ported

Each deck plays either an audio file (a scratchable turntable) or a live net composed on the spot from a genre and a seed. The composer, the arrange and overlay passes, the share envelopes and URLs, and the wave engine are ported from beats.bitwrap.io to Swift. The test suite holds them to the same CIDs and the same bytes by running fixtures generated from the real JavaScript: compose.json, arrange.json, share-urls.json, wave.json, macro-fx.json and the others under Tests/DJCoreTests/Fixtures/beats. make fixtures-beats regenerates them by running beats' own scripts, a Chrome-rendered fixture covers the master chain, and make share-url-check runs the other direction: the real JavaScript must accept the share URLs Swift builds. A share link made in the browser loads in the app and names the same track; a composed track in the app has the CID the browser would give it.

Two paths come out of that. WaveOptions() is the parity path, beats exactly. WaveOptions.extended is what the app plays: an instrument table, a groove and voicing pass, a limiter, and a level match to −12 LUFS. compose() still runs the beats composer byte for byte; the extras apply afterwards to bindings and initial markings. The README and manual say plainly that the musicality changes are measured, not yet confirmed by ear, and that the scopes and the Stage view have only been tested by rendering offscreen. We would rather say that here than have you find it.

A deck is a net you can touch. Jump a section, mute a role, fire a stinger, play a keyboard into the track's scale, run any of beats' macros. Scratching a net deck runs its clock backwards. Wander makes a deck walk by itself, one move per phrase, and AUTO hands the set between the two decks on a phrase boundary. Every press from the screen, a controller or a script goes into a journal, so a set is a recorded sequence of moves over deterministic configurations. It re-renders to the same bytes, and published sets are content-addressed.

GarageBand's sample library

The browser version synthesizes every voice with Tone.js. On the Mac there is a library of sampled instruments already installed with GarageBand and Logic. The app can use the installed EXS instruments and any SoundFonts in place of the synth voices, per part, picked by the track's seed from the palette's options. Palettes are named sound worlds: Synthwave, Acoustic band, Orchestral, Lo-fi tape, Jazz club, Ambient and so on, each greyed out with the reason if this Mac lacks the sounds. Every sampled slot is level-matched and tuning-checked on load, and one that is silent or more than 30 cents out is turned away for the next choice.

This is the one place determinism has a footnote. Synth voices and General MIDI programs are byte-identical on replay. A sampled render depends on what is installed on the Mac that renders it, and some instruments vary between renders on purpose. The parity path never touches samples.

The same library supplies stock loops. Apple Loops installed here can sit on the loops row beside loops you record from a deck's own output. A set with one on the row cannot be published, because Apple's licence covers playing them in your music, not distributing them.

Writing a track with Claude

dj_generate takes a genre and a seed, which explores the generator's space but cannot ask for a particular song. The app takes a second input, a track written as text.

dj_compose takes a piano roll as JSON. Each part is keyed by role (kick, snare, hihat, clap, bass, melody, harmony, arp), time is sixteenth-note steps (16 steps is a 4/4 bar), and a note is [step, midi, dur?, vel?]. Drums may leave the note out and get General MIDI 36, 38, 42 and 39. A part can repeat a pattern with {every, from, until, notes}, so a bar of drums is written once and a part can enter at bar five. The smallest case from the test suite:

{"name": "T", "tempo": 96,
 "parts": {
   "kick": {"notes": [[0], [8]]},
   "bass": {"instrument": "acid", "notes": [[0, 36, 4, 90], [16, 43]]}
 }}

The app turns that into a raw-nets share: every part becomes a ring of step places with one token, and the transition leaving step i carries that step's MIDI binding. The bass above has notes at steps 0 and 16, so the roll rounds up to two bars and both rings are 32 places long. The ring closes, and the token circulates. The envelope loads through the same path as any beats share, which gives it a CID like any other. It gets no seed, no genre and no musicality pass, so it plays exactly the notes written. It is a specific song where dj_generate gives a seed. Every composition is saved under the app's compositions/ directory, the request that produced it next to the envelope, so Claude can list and reload them later.

Two siblings share the format. dj_inspired takes an artist or a track as a style brief: tempo, key, progression type, groove, palette, form. It writes an original piece and records the read in the share's note. It never writes the artist's melody, hook or lyric, and a request to transcribe a specific copyrighted song is declined. dj_import reads a .mid or MusicXML file into the same roll, reports what the sixteenth-step grid dropped, and takes a source with origin and licence for the share's note. It is meant for material you own or that is public domain or Creative Commons.

The MCP server

The running app listens on a local Unix socket (~/Library/Application Support/DJApp/control.sock, mode 0600). djctl mcp is a stdio MCP server over it, registered as dj-vevr-se by make mcp-install (the repo was renamed from dj-vevr-se, and that name survives in code identifiers). Every call goes through the same path as the app's buttons, so it lands in the journal and in a recorded set. The server has 27 tools:

Claude cannot hear the speakers. The server's instructions say so, and the substitute is dj_state: it reports each deck's short-term and momentary loudness, its limiter, the current section, which roles are on and what the auto-mix is doing. Publishing is deliberately not a tool. Putting a set on the public CDN is a button in the app that asks first, since the result is public.

Build it

You need a Mac on macOS 15 and Swift 6.2 or newer. The Xcode command line tools are enough; the project is SwiftPM with no Xcode project.

git clone https://github.com/stackdump/beats-btw-dj
cd beats-btw-dj
make run-app-release

run-app-release builds an optimized build/DJApp.app and opens it. Use that one to judge the audio; make run-app builds the debug variant. Both decks start with a built-in demo loop, and a deck's Generate section composes a first track from a genre and a seed. make mcp-install registers the MCP server. Controllers are optional. The profiles cover a Hercules DJControl Inpulse 200 MK3, an Akai APC mini mk2 and an MPK mini mk2, and every control is bound or explicitly parked with a reason. Several of the Inpulse's bindings are still marked unverified against the hardware.

The manual covers decks, sections, loops, FX and sessions. Sets are published at dj.bitwrap.io. The browser original is still at beats.bitwrap.io.

×

Follow on Mastodon