Skip to content

SuperCollider side

sc/Handlers.scd hand-written. Your logic. Never regenerated.
sc/generated/OSCResponders.scd generated. Unpacks and dispatches. Never edit.

The generated file defines one OSCdef per inbound address. It pulls the arguments out of msg, names them, and calls into ~handlers. That is all it does — there is nothing of yours in it, so regenerating after a schema change costs nothing and you never have to merge anything.

// generated
OSCdef(\wallHoldTouch, { |msg, time, addr, recvPort|
var holdId = msg[1]; // i hold index, 0..holdCount−1 of /wall/config
var velocity = msg[2]; // f 0.0..1.0, normalized
var timestamp = msg[3]; // f seconds
~handlers[\wallHoldTouch].value(holdId, velocity, timestamp);
}, '/wall/hold/touch');
// yours, in sc/Handlers.scd
~handlers[\wallHoldTouch] = { |holdId, velocity, timestamp|
Synth(\holdVoice, [
\freq, ~holdFreq[holdId],
\amp, velocity.linexp(0, 1, 0.05, 0.8),
\pan, ~holdPan[holdId]
]);
};

npm run gen:sc writes sc/Handlers.scd once, as a stub with an entry per inbound message, and then never touches it again.

(
s.waitForBoot({
"sc/SynthDefs.scd".loadRelative;
"sc/Handlers.scd".loadRelative; // must come before the responders
"sc/generated/OSCResponders.scd".loadRelative;
~osc[\sysReady].value(~oscProtocolVersion, Main.version);
});
)

~handlers has to exist before the responders reference it. The generated file starts with ~handlers = ~handlers ? (); so an out-of-order load will not throw — it will just silently do nothing on every message, which is worse. Load it in the right order.

Evaluating an OSCdef twice replaces it rather than duplicating it, as long as the key is the same — which is why the generated keys are derived from the address. But a renamed address leaves the old responder alive and listening, so:

~oscFreeAll.value; // frees every responder this protocol version defined
CmdPeriod.add({ ~oscFreeAll.value });

If the wall ever responds twice to one touch, this is why.

Do not write an address literal. The generated senders coerce every argument to the type the contract declares, which is what makes it impossible to send an Integer where the engine expects a float:

~osc[\sysReady].value("0.2.0", Main.version);
~osc[\sysPong].value(seq, s.avgCPU / 100);
~osc[\modeLoaded].value("synth", 1, "");
~osc[\sampleEnded].value("savanna/narration-01", 1);

Every OSC responder runs on the single language thread, alongside your scheduling and any GUI. A handler that blocks is a handler that makes the whole wall stutter — including the touch that arrives 3 ms later.

Never do file I/O, .sync, or a long loop inside a handler. Hand the work to a Routine and return.

A /wall/hold/release can arrive with no matching touch, because SuperCollider may have started mid-grab. Guard for it rather than treating it as an error:

~handlers[\wallHoldRelease] = { |holdId, timestamp|
~voices[holdId] !? { |synth| synth.release; ~voices[holdId] = nil };
};

An out-of-range value is a small bug on the sender’s side. Dropping the message turns it into silence, which is a much larger bug on yours.

var gain = value.clip(0, 1);
~osc[\sysReady].value(~oscProtocolVersion, Main.version);
~osc[\sysSync].value; // "I have no state — re-send everything"

The engine answers with every rate: state message. This is what makes a SuperCollider restart invisible to the person on the wall.

The synth pattern is a whole list, every time

Section titled “The synth pattern is a whole list, every time”
~handlers[\modeSynthNotes] = { |keys|
// keys: key indices in slot order, 0..steps long. Replace the pattern,
// never merge: this message IS the pattern.
~arp.keys = keys;
};

Slots beyond keys.size are rests. Report every step, silent or not, so the engine’s lights stay locked to your clock:

~osc[\modeSynthStep].value(stepIndex, keys[stepIndex] ? -1);
~handlers[\modeChaseHit] = { |zone, holdId, activated, holdCount|
Synth(\chime, [
\pan, ~holdPan.value(holdId), // live wall, not constants
\degree, activated, // rises toward completion
]);
};
~oscProtocolVersion // "0.2.0"
~oscTransport // (scLangPort: 57120, scSynthPort: 57110, enginePort: 9000, ...)
~oscWall // LIVE: starts as the reference wall, updated by /wall/config
~oscState // latest args of every rate: state message, by handler key
~holdIndex.value(col, row) // -> flat index, matching the wall map exactly
~holdColRow.value(index) // -> [col, row]
~holdPan.value(index) // -> -1.0 .. 1.0 across the wall
~zoneColumns.value(zone, n) // -> [firstColumn, lastColumn]
~holdZone.value(index, n) // -> zone, or nil for a decorative column
~oscKnownAddresses // every address in this version, for logging

Use these rather than writing row * 20 + col by hand. The wall is a runtime setting: ~oscWall is overwritten by the generated /wall/config responder before your handler runs, so the helpers are right on any wall.

npm run gen:sc also reports which inbound messages have no handler in sc/Handlers.scd, and which handlers there refer to addresses that no longer exist — that file is never regenerated, so this is how it stays in step.