MarbleMagnificence

Dev log

A soundtrack that ships as zero bytes

Audio commit 12110d7

The music is written in Haskell, played live in TidalCycles, and then compiled into the app as a table of numbers. No audio file ever gets bundled.

The constraint came first, and it was blunt: no audio ships. A two-minute rendered loop would roughly double a 2.1 MB app and instantly be the largest thing in it by an order of magnitude. The impact thocks were already synthesised from oscillators at launch, so the question was just whether that trick scales from one percussive hit to six simultaneous parts.

It does. It just takes a strange pipeline to get there.

The pipeline

A MIDI file becomes GameMusic.hs — TidalCycles patterns, one per part. That is a real live-coding environment: SuperCollider makes the sound, Tidal sends it OSC, and you can re-evaluate a single line to swap one instrument at the next cycle boundary while everything else keeps playing.

d1 $ jux rev $ pianoP # s "superpiano"
d4 $ every 3 (fast 2) $ bassP # s "superbass"
d5 $ degradeBy 0.3 $ celloP # s "supersquare" # lpf 900

That is the whole point of having done the conversion. The score isn’t a file you re-render, it’s a thing you can mangle in real time and hear immediately.

Then tools/export-score.hs queries those same patterns and emits Swift sourceGameMusicScore.swift, 1080 notes across 33 cycles at 150bpm in 4/4, one bar per cycle. Each part is a flat [Float] of (startCycle, durationCycles, semitonesFromMiddleC) triples.

Two decisions inside that file

Swift, not JSON. MarbleCore imports nothing but simd — not even Foundation — so there is no JSONDecoder in there and there is not going to be one. Generated source honours that rule and drops the bundle resource, which is the mechanism by which the soundtrack costs zero bytes on disk. Two constraints solved by the same choice.

Flat and explicitly typed. Nested or untyped literals at this size make the Swift typechecker crawl. Six parts of grouped tuples would compile eventually, in the way that glaciers eventually reach the sea.

The exporter is also the one place the legato arithmetic lives. Tidal’s legato scales an event’s own step length, so sounding duration is step × legato — and that is exactly the sort of expression where an octave or a note length goes quietly wrong. One exporter, two pieces, one copy of the subtle part.

Playback is a buffer and nothing else

ScoreRenderer mixes the entire score, once, at launch, into a single block of mono samples that then loops. All the polyphony gets resolved offline, so playback needs no scheduler, no voice allocation and no clock — the audio hardware just plays a buffer.

Cost: about 9 MB of RAM for 52.8 seconds of 44.1 kHz float. On disk, nothing.

The thing that simplicity buys you is also the thing it takes away. Layers can’t fade in as you climb, and the underwater passage can’t filter the music, because there is no per-note scheduling against a sample clock to hang either on. That is a bigger job, and it wants this one to prove the voices sound right first.

The number that isn’t a bug

In a debug build the render takes 15–45× longer — 0.2s in release on a Mac, 2.6s debug, 9.1s in the simulator. Unoptimised Swift keeps every bounds check and inlines nothing. It looks alarming and it is a development annoyance only: the render runs on a background thread and the game is playable throughout.

Two attempts to close that gap — an exp-free envelope, then unchecked buffer writes — each bought under 60ms in release, and both were reverted for the complexity they added.

The debug number describes the compiler. It does not describe the code.