MusicBrain — Design State
This is where the design sits. Some of it is settled and has been tested against scrutiny. Some of it compiles and boots but doesn't do anything yet. Some of it is deliberately deferred until a piece actually demands it. The page tries to be honest about which is which.
What It Is
MusicBrain is a coordination layer for algorithmic composition in SuperCollider. It sits above the node tree and below the compositional logic, providing a pub/sub architecture where nodes — musical processes, hardware controllers, analysis engines — communicate by subscribing to each other's output.
The idea that drives the design is that a feedback loop is just a subscription path that returns to its origin. If that path can carry rhythmic information, or harmonic information, or timbral information, or arrangement-level decisions, then feedback at any of those substrates is the same mechanism. The architecture doesn't care what the feedback is about. It just routes subscriptions.
That means the coordination layer and the compositional idea are the same thing. You build the system correctly and you've stated the musical claim. Whether that claim holds up is what the compositions are for.
Three Layers
The system has three layers. Each one knows about the layer below it and nothing about the layer above.
Substrate. OSC is the internal communication format, sitting above the wire. The substrate has no knowledge of MIDI, no knowledge of synthesis, no knowledge of hardware. All of that is sealed inside individual nodes' transformation functions. If a node happens to talk to an Akai controller, that's the node's business. The bus never sees it.
Node. The universal unit. There is one node class. A hardware button and a virtual analysis process are the same type — hardware is just one possible body of a transformation function, not a subtype. A node has six concerns: inbox, outbox, transformation, generator, state, lifecycle. Most nodes leave some of these dormant. A button listener uses inbox and transformation. A pattern source uses generator and outbox. Same class, different concerns active.
Brain. MusicBrain is the top-level coordinator. It owns routing and feedback rules and speaks only OSC-on-subjects. ControllerBrain sits underneath it as a subsystem that makes heterogeneous control surfaces look uniform. The brain never sees MIDI.
Ports and Timing
This is the part of the design that took the longest to get right, and it's the part that matters most for implementation.
A node's inboxes and outboxes are typed by timing regime. There are exactly two regimes, and in SuperCollider terms they correspond to two API surfaces you already use:
| immediate | No clock involved. In SC this is .set for discrete values and .map or an Ndef connection for continuous streams. Operates at control rate, below the transformation membrane. A continuous-immediate connection is idiomatically a standing .map — not a message stream. It may not need the node event model at all. |
| scheduled | Stamped with a logical time (a beat). In SC this is clock.schedAbs(beat, fn). The TempoClock queue fires events in beat order regardless of when you scheduled them. For sample-accurate triggers, wrap in s.bind. That's not a third regime, just a precision level on the same one. |
The regime is structural. It's the port's class, not a flag on a message. An immediate port has no clock reference, so you can't accidentally call schedAbs from one — it doesn't have the handle. The distinction is enforced by what the object was given at construction.
A few commitments follow from this:
- A node holds a collection of typed ports, not one inbox and one outbox. The collection is declared at construction and there is no method to add a port afterward. It's immutable.
- The timing fork is in the topology, not in the code. Which port a message arrives on determines its regime. The transformation function has no conditional that checks whether something is immediate or scheduled. A knob wired to an immediate inbox and a pad wired to a scheduled inbox are different because they're different ports, not because of a branch.
- An LED write and a parameter write use the same immediate outbox class. The difference is what they're subscribed to. LED output subscribes to committed state — what the clock has actually reached — not to the keypress. The surface never shows a state the grid hasn't arrived at yet.
How It Maps to SuperCollider
The coordination model looks like an actor system — async messages, private state, no shared memory — but it's assembled from SuperCollider primitives, not imported from somewhere else. Each role in the model lands on something already in the language:
| identity / address | OSC subject path — /player/3/pos/out |
| fixed behavior | declared port set + transformation, set at construction |
| private state | per-node vars or dictionary (the state concern) |
| message receipt | OSCdef / OSCFunc |
| immediate action | .set / .map — no clock in scope |
| scheduled action | schedAbs → TempoClock / LinkClock |
| clean lifecycle | doneAction — synth self-frees on envelope end |
Three things this needs that SC doesn't give you out of the box:
First, a time-indexed mailbox. This is already solved: TempoClock is a sorted priority queue keyed by beat. Scheduled ports delegate to schedAbs and never reimplement queuing.
Second, both regimes kept deliberately. The split between .set/.map and schedAbs is the boundary between where untimed messaging is fine and where music needs a time coordinate. Collapsing them would lose the distinction that makes the timing model work.
Third, graceful per-node death. SC's CmdPeriod is a global panic — kill everything. That's the wrong granularity. If a node fails, it should release its voices cleanly, go silent, and rejoin at a phrase boundary. This is the same problem as a malformed envelope where doneAction:2 never fires and synths stack up on the CPU, lifted one layer up. MusicBrain is what provides per-node supervision. SC only gives you global teardown and synth-level self-cleanup.
Scheduling
There is no scheduler to build. TempoClock is the scheduler. One master clock, phrase boundaries as control flow.
The hard question per piece is stamping: which beat to hand the clock. That question lives in piece logic, not infrastructure. Every difficult case turns out to be stamp arithmetic: when to advance to the next cell (generator and state concerns), how to translate between metric frames for polymeter deferred, how to quantize an off-grid event onto the grid when a control surface is involved deferred.
The one infrastructure primitive that's probably needed early is event invalidation — a way to cancel a stamped-but-unfired event. When a node dies, its pending notes need to be withdrawn, or they fire into silence. This is clean death wearing scheduling clothes. Build it when a player first needs to stop mid-phrase.
What a Node Looks Like
Everything above is prose. Here is what the general shape looks like in sclang, stripped to the load-bearing parts. Nothing here is tied to a specific piece.
Ports
The two regimes are two classes. The regime is not a flag the port checks at runtime — it is what the port is.
Inbox {
var <name, <node;
*new { |name, node| ^super.newCopyArgs(name, node) }
receive { |msg| ^this.subclassResponsibility(\receive) }
}
ImmediateInbox : Inbox {
// No clock. The message is handled now.
receive { |msg|
node.transform(name, msg);
}
}
ScheduledInbox : Inbox {
var <clock;
*new { |name, node, clock|
^super.new(name, node).initScheduled(clock)
}
initScheduled { |argClock| clock = argClock }
// The message carries a beat. The clock fires it in beat order.
receive { |msg|
clock.schedAbs(msg[\logicalTime], {
node.transform(name, msg);
nil
});
}
}
ImmediateInbox has no clock variable. That is not an omission. An immediate port structurally cannot touch the scheduler because it was never given one. The regime is enforced by what the object holds, not by what the programmer remembers.
Outboxes follow the same split. The interesting thing is that they are simpler — both just propagate to subscribers. The regime distinction on the outbox side is mostly about what subscribes to them, not about what they do internally.
Outbox {
var <name, <node, <subscribers;
*new { |name, node|
^super.newCopyArgs(name, node, IdentitySet.new)
}
subscribe { |port| subscribers.add(port) }
emit { |msg|
subscribers.do { |port| port.receive(msg) };
}
}
ImmediateOutbox : Outbox { }
ScheduledOutbox : Outbox { }
The Node
A node builds its ports once from a spec and then never adds to them. The port collection is the node's protocol — what it receives, what it emits, and on what timing regime. You can read a node's character off its port declaration without reading its transformation logic.
MBNode {
var <inboxes, <outboxes, <state;
*new { |portSpec, clock|
^super.new.initNode(portSpec, clock)
}
initNode { |portSpec, clock|
inboxes = IdentityDictionary.new;
outboxes = IdentityDictionary.new;
portSpec[\inboxes].do { |spec|
var port = switch(spec[\regime],
\immediate, { ImmediateInbox(spec[\name], this) },
\scheduled, { ScheduledInbox(spec[\name], this, clock) }
);
inboxes[spec[\name]] = port;
};
portSpec[\outboxes].do { |spec|
var port = switch(spec[\regime],
\immediate, { ImmediateOutbox(spec[\name], this) },
\scheduled, { ScheduledOutbox(spec[\name], this) }
);
outboxes[spec[\name]] = port;
};
// No addInbox, no addOutbox. The absence of a mutator
// is the immutability guarantee.
state = IdentityDictionary.new;
}
inboxAt { |name| ^inboxes[name] }
outboxAt { |name| ^outboxes[name] }
transform { |portName, msg|
^this.subclassResponsibility(\transform)
}
}
The six concerns show up here as: inboxes (inbox), outboxes (outbox), transform (transformation), state (state). Generator and lifecycle are left for subclasses that need them. Most won't.
A Concrete Subclass
This is a node with one scheduled inbox and two outboxes — one scheduled, one immediate. It receives events on a beat grid, does something with them in the transformation, emits the results on a scheduled channel, and writes a parameter on an immediate channel. The specifics of what it computes don't matter here. The point is the shape.
ExampleNode : MBNode {
*new { |clock|
^super.new((
inboxes: [
(name: \events, regime: \scheduled),
],
outboxes: [
(name: \notes, regime: \scheduled),
(name: \param, regime: \immediate),
]
), clock)
}
transform { |portName, msg|
switch(portName,
\events, {
// do something with the incoming event.
// emit a note on the scheduled outbox.
outboxes[\notes].emit((
freq: msg[\freq],
dur: msg[\dur],
logicalTime: msg[\logicalTime]
));
// also write a parameter immediately.
outboxes[\param].emit((
param: \brightness,
value: msg[\freq].explin(100, 2000, 0, 1)
));
}
);
}
}
Notice that transform has no timing logic. It doesn't check whether the outbox is immediate or scheduled, and it doesn't call schedAbs. The scheduled inbox already fired the transform at the right beat. The immediate outbox already emits without a clock. The timing fork happened in the port wiring, before the transform ran. That is what "the fork is topology, not logic" means in practice.
Where Things Stand
- ✓ Class library compiles and boots. 466 files, ~0.23 seconds.
- ✓ Lighting abstraction layer built:
LedColor,LedColorMap,LedIntent,Illuminable,Illumination,APCMiniColor,APCMiniLightButton,APCMiniSceneButton,MidiMixColor,MidiMixLightButton. - ◔ Lighting does not yet drive hardware. The controller factories build elements with no arguments, so a node's out-subject is never populated. This is the immediate next step.
- ◔ Node abstraction is partial.
Elementhas inbox, outbox, and transformation. The six-concern unified node is the target, not the current shape. - ✗
ControllerBrainis hollow. Compiles as a Singleton, methods are stubs. - ✗
MusicBraindoes not exist as a class. - ✗ Feedback / clock loop-breaker layer not built.
Build Order
Each layer's first real consumer exists before the layer is built.
1. lighting abstractions done
2. wire controller factories -> physical LED next
3. generalize Element -> six-concern node
4. MusicBrain coordinator
5. feedback + clock (loops close on a tick)
6. further node types (pattern, scale/event, analysis)
Composition Roadmap
Four series, ordered by complexity. The first series holds C major constant throughout, so if something is hard, it's the architecture's fault, not the material's.
- Bach series. Proves the architecture can carry received structure. In C (Terry Riley) → Prelude in C → Invention in C → Fugue in C.
- Techno series. Escalates from simple grids through polymetric, non-periodic, and odd-metered structures. No UFOs → Blue Monday → Aegispolis → Slip → Theme from Ernest Borgnine → Intense Demonic Attacks.
- Jazz lead sheets. Tests realization of underspecified material — voicing and comping decisions on the node side. Blue Bossa → Maiden Voyage → Spain → Jungle Juice.
- Open. Generalize the lead sheet into a process descriptor: a thin spec and a realization function. The format language emerges from built instances, not designed upfront.
In C — First Piece
In C is the proof-of-architecture piece. It uses about a third of the model.
A mesh of identical player nodes. Each one subscribes to all others via a wildcard (/player/*/pos/out), hears their positions, updates its own state, and advances only at phrase boundaries on one master clock. In SC terms: scheduled regime, declared ports, TempoClock. No immediate path, no control surface, no LED, no failure model.
The scheduling is schedAbs per note, which is trivial. The only real content is the advance decision — am I ready for the next cell. That's composition, not infrastructure.
playCell { |startBeat|
cells[cellIndex].do { |note|
clock.schedAbs(startBeat + note[\beatOffset], {
outboxes[\notes].emit((freq: note[\freq], dur: note[\dur]));
nil
});
};
}
Principles
Prose first. Specify in writing before implementing. Build the smallest legible thing, record it, analyze the recording, make targeted fixes, write down why the rules survived, and scale only from a base that works.
Infrastructure holds no policy. If it's a compositional decision, it belongs in the piece, not in the shared library. There is one place where one representation becomes another, and that translation is sealed in the node.
Empirical abstraction discovery over premature specification. This applies to the architecture itself — build only what the current piece demands. Once the pieces exist, the abstractions reveal themselves. Designing the abstraction before the instances is guessing.
Randomness touches timbre and transformation, never the grid.
Two-repo structure: one shared infrastructure library, one repository per composition.