SuperCollider side
The split
Section titled “The split”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.
// generatedOSCdef(\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.
Load order
Section titled “Load order”(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.
Re-evaluating without stacking responders
Section titled “Re-evaluating without stacking responders”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 definedCmdPeriod.add({ ~oscFreeAll.value });If the wall ever responds twice to one touch, this is why.
Sending back
Section titled “Sending back”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);Patterns worth adopting
Section titled “Patterns worth adopting”Keep handlers short and non-blocking
Section titled “Keep handlers short and non-blocking”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.
Treat unmatched releases as normal
Section titled “Treat unmatched releases as normal”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 };};Clamp, do not reject
Section titled “Clamp, do not reject”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);Ask for state after a restart
Section titled “Ask for state after a restart”~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);Place sounds where the hold is
Section titled “Place sounds where the hold is”~handlers[\modeChaseHit] = { |zone, holdId, activated, holdCount| Synth(\chime, [ \pan, ~holdPan.value(holdId), // live wall, not constants \degree, activated, // rises toward completion ]);};Constants you get for free
Section titled “Constants you get for free”~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 loggingUse 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.