Oscilla Control Plane — Architecture & Usage Guide
synth(uid:pad, freq: follow(fadeSineFreq.t, 90, 2000), env:{a:4})
o2p(path:fadeSineFreq, trig:touch, osc:1, uid:fadeSineFreq, oscAddr:fadeSineFreq)
Overview
The Oscilla Control Plane enables bidirectional signal flow between cues. Any animation can publish its values as signals, and any synth or audio cue can subscribe to those signals to control its parameters in real-time.
This transforms Oscilla from a trigger-based score system into a dynamic, signal-driven, executable score environment.
Core Concept
┌─────────────────┐ ┌─────────────────┐
│ O2P Fader │ ──publishes──────▶ │ ParamBus │
│ uid: slider1 │ t, x, y, angle │ (signal store) │
└─────────────────┘ └────────┬────────┘
│
│ subscribes
▼
┌─────────────────┐
│ Synth │
│ freq: follow(slider1.t)│
└─────────────────┘
Quick Start
1. Create a Controller (O2P Fader)
o2p(path:faderTrack, trig:touch, uid:myFader)
This fader automatically publishes:
o2p:myFader.t— position along path (0-1)o2p:myFader.x— normalized X position (0-1)o2p:myFader.y— normalized Y position (0-1)o2p:myFader.angle— tangent angle in degrees
2. Bind a Synth Parameter
synth(uid:pad, freq: follow(myFader.t, 200, 800), amp:0.2)
The freq: follow(myFader.t, 200, 800) syntax means:
- Subscribe to signal
o2p:myFader.t - Map the 0-1 value to 200-800 Hz (linear)
3. Result
Moving the fader changes the synth's frequency in real-time!
Signal Reference Syntax
A binding is written as a follow() call in the parameter's value.
Basic Binding
param: follow(source.channel)
- param — The parameter to control (freq, amp, pan, dur …). Any parameter: there is no list of "bindable" names, because the call says what it is.
- source — The uid of the publishing cue
- channel — Which signal to subscribe to (t, x, y, angle …)
The signal arrives as 0-1 and is passed straight through.
Binding with Range
param: follow(source.channel, min, max)
Maps the 0-1 signal to the output range, linearly.
Range and Curve
param: follow(source.channel, min, max, curve)
| Curve | Description |
|---|---|
lin |
Linear (default) |
exp2 |
Quadratic (x²) — slower at start, faster at end |
exp3 |
Cubic (x³) — more pronounced exponential |
exp4 |
Quartic (x⁴) — very steep curve |
log |
Logarithmic — faster at start, slower at end |
Default Value
param: follow(source.channel, min, max, default)
param: follow(source.channel, min, max, default, curve)
The fourth argument is read by type: a number is a default, a name is a curve, and both may appear in either order. With a default, the fader initialises to the position producing that output. Without one, presets and localStorage control the initial state.
Seeing what is connected. The connections view (topbar button) draws every binding in the score as a patch cord, and shows every port each object has — including the free ones you could connect to. The
demo-connectionsdemo is built to be read with it open.
One Animation Driving Another
Any animation that publishes can drive any parameter that binds, including
another animation's speed. A rotate() publishes its angle as .norm (0-1 per
revolution); a rotate() also accepts a bound dur:
rotate(uid:r1, dir:1, dur:6, trig:auto)
rotate(uid:r2, dir:1, dur: follow(r1.norm, 8, 0.5), trig:auto)
r1 turns steadily. r2's revolution takes 8 seconds while r1 is at the start of
its turn and half a second by the end of it — so r2 breathes, slow to fast and
back, once per turn of r1. Note the range runs high to low: a long duration is
a slow rotation, so 8, 0.5 reads as "gets faster".
Neither cue names the other's type. r1 could be replaced by a fader, a controlXY handle or an incoming OSC address without touching r2.
A driven object is still a source. r2 publishes its own angle while following r1, so a third object can follow r2:
scale(uid:box, sx: follow(r2.norm, 0.6, 1.8), sy: follow(r2.norm, 0.6, 1.8))
giving r1 → r2 → box. There is no special status for being both; a signal is a signal.
Loop counts are infinite by default.
loop:1means once, so a continuous spin simply does not nameloopat all.
When a Binding Names Something That Does Not Exist
A follow() whose source has no matching uid, or whose channel is misspelt,
cannot work — and used to say nothing at all. The score loaded cleanly and the
binding simply never happened.
Both are now reported when the score loads, with the cue that wrote them:
[refs] 1 unresolved reference, 1 suspect — these do nothing at all
✗ signal binding source: "nosuchfader.t" matches no element
in: synth(uid:pad, freq: follow(nosuchfader.t, 90, 2000), …)
? signal binding source: "fader.tt" — unknown channel ".tt"
The second is the subtler failure: the source is real, so nothing looks wrong,
but .tt is not a channel anything publishes and the binding listens to a path
nobody writes. Channel names are checked against what the cue types actually
publish; an unknown one is a warning rather than an error, because an ext()
extension may publish channels Oscilla cannot know about.
The same references appear in the connections view as dotted red cords ending in an open ring, so a broken connection is visible in the score as well as in the console.
Binding to Incoming OSC
param: follow(/pedal/1, 0, 1)
A source beginning with / is an OSC address rather than a uid. Everything
else — range, curve, default — works the same.
Syntax Summary
| Format | Example | Description |
|---|---|---|
follow(source.channel) |
follow(fader.t) |
Basic binding (0-1) |
follow(source.channel, min, max) |
follow(fader.t, 200, 2000) |
With range, linear |
follow(source.channel, min, max, curve) |
follow(fader.t, 0, 4800, exp3) |
With range and curve |
follow(source.channel, min, max, default) |
follow(fader.t, 200, 2000, 440) |
With range and default |
follow(source.channel, min, max, default, curve) |
follow(fader.t, 0, 4800, 60, exp3) |
Everything |
follow(/osc/address, min, max) |
follow(/pedal/1, 0, 1) |
Incoming OSC |
A note on the older syntaxes. Two forms preceded this one. The parser accepted
fader.t-200-2000-440-exp3, where the arguments ran together and were told apart by type; much of the documentation instead showedfader.t[200,2000], which the parser never accepted at all — it parsed to nothing and the binding silently did not happen. Neither form is recognised any more.[...]now means only what it means everywhere else in the DSL: an array literal.
Examples
| DSL | Meaning |
|---|---|
freq: follow(slider.t) |
Freq follows slider position (0-1) |
freq: follow(slider.t, 200, 2000) |
Freq mapped to 200-2000 Hz (linear) |
freq: follow(slider.t, 200, 2000, 440) |
Freq 200-2000 Hz, starts at 440 Hz |
rotspeed: follow(knob.t, 0, 4800, exp3) |
Rotation speed with cubic curve |
rotspeed: follow(knob.t, 0, 4800, 60, exp3) |
Rotation speed, starts at 60°/s, cubic curve |
amp: follow(fader.y, 0, 0.5) |
Amplitude mapped to 0-0.5 |
pan: follow(knob.x, -1, 1) |
Pan mapped to -1 to 1 — a negative minimum needs no special casing |
dur: follow(speed.t, 120, 1) |
Duration 120s (left) to 1s (right) — inverted for speed control |
map() — when the source is too slow, or too narrow
follow(src, min, max) assumes the source sweeps the whole 0–1. Often it does
not, and then the binding barely moves. transport.t is the clearest case: it
spans the entire piece, so across one screen of playhead travel it changes
by a fraction and whatever it drives looks static.
map() is follow() with the input range spelled out — you say which part of
the source to use, and that slice is stretched across the full output:
map(source, inMin, inMax, outMin, outMax [, default] [, curve])
freq: follow(fx.t, 90, 2000) # 0-1 of the source → 90..2000
freq: map(fx.t, 0, 1, 90, 2000) # identical, just written out
freq: map(fx.t, 0.3, 0.35, 90, 2000) # a 5% slice → the whole range
follow()was calledlink(). The old spelling still parses and always will — there are scores carrying it — but everything here uses the new one. The rename is worth the churn becausefollow()names a behaviour: this parameter tracks that signal continuously, for as long as both exist. That is exactly what distinguishes it fromfade(from:, to:), which samples its endpoints once, at trigger. A noun hid that difference; a verb shows it.
Arithmetic — mul() and add()
A binding can read more than one source:
sx: mul(follow(adc.amp, 0, 2), follow(lfo.norm, 0.5, 1))
freq: add(follow(pad.y, 200, 800), follow(adc.pitch, 0, 200))
sx: mul(follow(adc.amp, 0, 2), 0.5)
Each term carries its own range, because a term is simply a follow() or a
map() — the thing that already knows how to read a source. That is what makes
it compose: anything you can bind to a parameter, you can multiply by anything
else you can bind to a parameter, and a plain number is a term too. They nest,
so mul(add(a, b), c) is fine.
The result is clamped to the target's own range. Each term is inside its range, but a product is not: two terms topping out at 4 and 3 give 12, which would put a scale somewhere absurd.
A term that has not published yet is skipped rather than counted as zero — the
identity is 1 for mul and 0 for add — so one silent source does not flatten
the whole expression.
Each term draws its own cord in the connections view, and a broken term is reported at load like any other reference.
Arithmetic on two constants is refused:
mul(2, 3)is a number, not a binding, and making a cord that can never move would be worse than leaving the value alone.
So follow is map with the input range assumed 0–1. Everything else is the
same: the same sources, the same curves, the same cords in the connections
view — map() produces exactly the same binding, it just also carries the
window.
Input outside the window pins to the nearer end. A window is a window, not
an extrapolation: below inMin you get outMin, above inMax you get
outMax, and it moves only in between.
That is what makes a slow source usable. Window it to the stretch you care about and it spends its whole range there:
# sweeps its full range only while the playhead crosses one section
sx: map(transport.t, 0.605, 0.67, 0.2, 3)
map() reads a leading slash as an OSC address, exactly as follow() does:
cutoff: map(/pedal/1, 0.2, 0.8, 200, 8000, exp2)
Published Signals by Cue Type
A published signal is addressed uid.channel — the uid you gave the cue,
then the channel. There is no cue-type prefix: fader1.t, not o2p:fader1.t.
| Cue | Signal | Meaning |
|---|---|---|
rotate |
{uid}.angle |
degrees, 0-360 |
rotate |
{uid}.rad |
radians |
rotate |
{uid}.norm |
0-1 per revolution |
rotate |
{uid}.vel |
angular speed, degrees/s |
rotate |
{uid}.tvel |
turns per second |
rotate |
trig:{uid} |
fires a cue it passes over |
scale |
{uid}.sx |
x scale factor |
scale |
{uid}.sy |
y scale factor |
scale |
{uid}.uniform |
mean of the two |
scale |
trig:{uid} |
fires a cue it passes over |
color |
{uid}.hNorm |
hue, 0-1 |
color |
{uid}.sNorm |
saturation, 0-1 |
color |
{uid}.lNorm |
lightness, 0-1 |
fade |
{uid}.opacity |
0-1 |
o2p |
{uid}.t |
how far along the path, 0-1 |
o2p |
{uid}.x |
x position, 0-1 |
o2p |
{uid}.y |
y position, 0-1 |
o2p |
{uid}.angle |
heading in degrees |
o2p |
{uid}.p |
rotation handle, 0-1 |
o2p |
{uid}.vel |
speed, score units/s |
o2p |
{uid}.vx {uid}.vy |
signed velocity components, score units/s |
o2p |
{uid}.tvel |
path lengths (laps) per second — the rate of t |
o2p |
trig:{uid} |
fires a cue it passes over |
oscCtrl |
{uid}.t |
position along the lane, 0-1 |
oscCtrl |
{uid}.v |
lane value, 0-1 |
controlXY |
{uid}.x |
pad x, 0-1 |
controlXY |
{uid}.y |
pad y, 0-1 |
controlXY |
{uid}.p |
handle rotation, 0-1 |
image |
{uid}.index |
which plate, 0-based |
image |
{uid}.t |
position through the folder, 0-1 |
trans |
{uid}.t |
progress through the whole waypoint list, 0-1 |
trans |
{uid}.legT |
progress along the current leg, 0-1 |
trans |
{uid}.x |
x across the travel, 0-1 |
trans |
{uid}.y |
y across the travel, 0-1 |
trans |
{uid}.vel |
speed, score units/s |
trans |
{uid}.vx {uid}.vy |
signed velocity components, score units/s |
trans |
{uid}.tvel |
the rate of t: whole waypoint lists per second |
trans |
trig:{uid} |
fires a cue it passes over |
drag |
{uid}.x |
where it sits across the score, 0-1 (clamped) |
drag |
{uid}.y |
and down it, 0-1 (clamped) |
drag |
{uid}.vel |
speed, score units/s |
drag |
{uid}.vx {uid}.vy |
signed velocity components, score units/s |
drag |
trig:{uid} |
fires a cue it passes over |
transport |
transport.t |
the playhead through the score, 0-1 |
transport |
transport.elapsed |
seconds into the score |
transport |
transport.speed |
the transport's speed multiplier |
stopwatch |
{uid}.elapsed |
seconds since it started |
stopwatch |
{uid}.t |
through its hold, 0-1 |
stopwatch |
{uid}.remaining |
seconds left of the hold |
video |
{uid}.time |
playback position in seconds |
video |
{uid}.t |
through the file, 0-1 |
metro |
{uid}.beat |
which beat of the bar, 1-based |
metro |
{uid}.phase |
through the bar, 0-1 |
metro |
{uid}.bar |
bars since it started |
audio |
adc.amp |
level |
audio |
adc.peak |
peak level |
audio |
adc.centroid |
spectral centroid |
audio |
adc.onset |
onset detection |
audio |
adc.low |
low band |
audio |
adc.mid |
mid band |
audio |
adc.high |
high band |
audio |
adc.pitch |
detected pitch in Hz |
audio |
adc.note |
detected note number |
audio |
adc.pitchConf |
pitch confidence, 0-1 |
Two of these have no element behind them. transport is the score's own
playhead — follow(transport.t, …) works in any score, with no cue to declare.
mic is the audio analyser, published under that fixed uid.
Velocity
Everything that moves — o2p, trans, drag, rotate — also publishes how
fast it is moving. vel is the speed in the units the object actually moves
in: score (SVG user) units per second, or degrees per second for a rotation.
vx/vy are its signed components. A stopped object reads 0 about a tenth of
a second after its last movement, so a released fader or a paused animation
does not freeze at its last speed.
vel is not 0-1, so a plain follow(obj.vel) would pin at 1. Use map() and
name the window you care about — the vals overlay mode shows the raw v:
number on an o2p so you can pick it:
amp: map(fader.vel, 0, 400, 0, 1) # silent at rest, full at 400 units/s
tvel is the same speed measured against the path instead of the score: the
rate of t, in path lengths per second — laps per second for an o2p, turns
per second for a rotate, waypoint lists per second for a trans. It depends
only on the geometry, never on dur:, so four cyclists on four lanes of
different lengths but each "one lap" can share one mapping:
display(v: map(cyc1.tvel, 0, 1, 0, 1440), fmt:"{v} km/h", dec:0) # 400 m lap: 1 lap/s = 1440 km/h
cutoff: map(wheel.tvel, 0, 2, 200, 4000) # up to two turns a second
A drag() has no path to be a fraction of, so it has no tvel.
Sinks: sending a signal out, showing a signal
A binding normally lands on a parameter. Two cues exist only to take signals somewhere else. Both are always on — registered when the score loads, never on the playhead — and both accept any signal, not just the ones they were written for.
oscOut() — signals → OSC
oscOut(addr:/obj/vel, v: map(fader.vel, 0, 500, 0, 1))
oscOut(addr:/obj, x: follow(a.x), y: follow(a.y), laps: follow(a.tvel), uid:s1)
Every parameter except addr: and uid: becomes one argument of the message,
in the order written; the second example sends /oscilla/obj [x y laps]. A
follow()/map() is followed and the message re-sent on every change, so the
range and curve of the binding are the range and curve on the wire; a plain
number is a constant argument. Addresses follow the same rule as everywhere
else: /oscilla/<addr>, unless addr: starts with /.
display() — signals → text in the score
display(v: map(fader.vel, 0, 500, 0, 100), fmt:"speed {v} %", dec:0)
display(x: follow(pad.x), y: follow(pad.y)) → "0.42 0.77"
Put it on a <text> element and its content is rewritten in place — the font,
size and position stay whatever they are in Inkscape. On any other element a
<text> is added at its top-left corner. Every parameter except fmt:,
dec: and uid: is a named value; fmt: places them as {name}, and
without one they are joined with spaces. dec: is the number of decimals
(default 1).
Give either a uid: and the connections view
draws its cords like any other target.
Bindable Parameters
Anything below takes a follow() in place of a number.
| Cue | Parameter | Meaning |
|---|---|---|
rotate |
dur: |
seconds per revolution |
scale |
sx: |
x scale factor |
scale |
sy: |
y scale factor |
scale |
dur: |
seconds per step |
color |
dur: |
seconds per cycle |
fade |
from: |
start opacity, sampled at trigger |
fade |
to: |
end opacity, sampled at trigger |
fade |
dur: |
seconds, sampled at trigger |
o2p |
dur: |
seconds for one traverse |
o2p |
rotspeed: |
handle rotation speed |
synth |
freq: |
pitch in Hz |
synth |
amp: |
level, 0-1 |
synth |
pan: |
-1 to 1 |
synth |
cutoff: |
filter frequency in Hz |
synth |
q: |
filter resonance |
image |
opacity: |
0-1 |
image |
index: |
pick the plate by position in the folder |
text |
speed: |
scroll speed in px/s, sampled at trigger |
ui |
opacity: |
0-1, sampled at trigger |
ui |
scale: |
scale factor, sampled |
ui |
rotate: |
degrees, sampled |
ui |
x: |
pixels, sampled |
ui |
y: |
pixels, sampled |
ui |
dur: |
seconds of transition, sampled |
video |
opacity: |
0-1 |
video |
speed: |
playback rate |
dur: on rotate(), scale(), color() and fade(), and from:/to: on
fade(), are sampled when the cue fires rather than followed: the tween is
built once from those numbers, so there is nothing for a later value to change.
Everything else follows the signal continuously.
A synth also binds detune, and audio cues bind amp, pan and pitch/rate;
effects within a synth bind delayTime, feedback and mix.
Architecture
Module Overview
┌─────────────────────────────────────────────────────────────────┐
│ control/paramBus.js │
│ Central signal store with pub/sub │
│ • set(path, value) — publish a signal │
│ • get(path) — read current value │
│ • subscribe(path, callback) — react to changes │
└─────────────────────────────────────────────────────────────────┘
▲
│
┌─────────────────────┴─────────────────────┐
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ control/ │ │ Animation Modules │
│ paramBinding.js │ │ │
│ │ │ • publish() call │
│ • bindParam() │ ◀──── used by ────│ in update loop │
│ • publish() │ │ │
│ • applyCurve() │ └───────────────────┘
│ • mapRange() │
└───────────────────┘
│
│ used by
▼
┌───────────────────┐
│ cues/ │
│ cueParamBinding.js│
│ │
│ • setupSpeedBinding│
│ • setupParamBinding│
│ • fader defaults │
└───────────────────┘
File Descriptions
| File | Purpose |
|---|---|
control/paramBus.js |
Central signal store with pub/sub |
control/paramBinding.js |
bindParam(), publish(), applyCurve(), mapRange() |
parser/parserSignalRef.js |
Parser extension for signal ref syntax |
cues/cueParamBinding.js |
Cue-level binding helpers, fader defaults |
Integration Guide
Adding Signal Publishing to a Cue
To make any animation publish signals, add one line to its update callback:
import { publish } from '../control/paramBinding.js';
// In your animation's update callback:
update: () => {
// ... existing animation logic ...
// ADD THIS LINE:
publish("o2p", cfg.uid, { t: pathT, x: normX, y: normY, angle });
}
Example: O2P Animation
Location: cues/o2p.js, in startContinuousO2P()
// BEFORE (existing code):
emitO2POsc({ cfg, uid: cfg.uid, path, point, pathT });
// AFTER (add this line):
import { publish } from '../control/paramBinding.js';
// ... then in update callback:
emitO2POsc({ cfg, uid: cfg.uid, path, point, pathT });
publish("o2p", cfg.uid, { t: globalT, x: normX, y: normY, angle });
Note: You'll need to calculate normX and normY from the path bbox:
const bbox = path.getBBox();
const normX = (point.x - bbox.x) / bbox.width;
const normY = (point.y - bbox.y) / bbox.height;
Example: Rotate Animation
Location: cues/rotate.js, in handleRotateContinuous()
import { publish } from '../control/paramBinding.js';
update: () => {
// ... existing code ...
const angle = getCurrentAngle(animEl, 0);
// ADD THIS:
publish("rotate", cfg.uid, { angle: angle });
}
Example: Scale Animation
Location: cues/scale.js, in handleScaleContinuous()
import { publish } from '../control/paramBinding.js';
update: () => {
// ... existing code ...
const sx = parseFloat(tr.match(/scale\(([^,]+),/)?.[1] || 1);
const sy = parseFloat(tr.match(/,\s*([^)]+)\)/)?.[1] || sx);
// ADD THIS:
publish("scale", cfg.uid, { sx, sy, uniform: (sx + sy) / 2 });
}
Adding Signal Binding to a Cue
To make parameters bindable in a cue, use bindParam():
import { bindParam, isSignalRef } from '../control/paramBinding.js';
function startMyCue(params) {
const unbinders = [];
// Bind frequency - works with both static and signal ref
const freqBinding = bindParam(
params.freq,
(hz) => oscillator.frequency.setTargetAtTime(hz, 0, 0.02),
{ min: 20, max: 20000, default: 440 }
);
unbinders.push(freqBinding.unbind);
// Use initial value
oscillator.frequency.value = freqBinding.value;
// ... later, on cleanup:
unbinders.forEach(fn => fn());
}
Example: Synth Integration
Location: cues/synth.js, in startSynthVoice()
import { bindParam } from '../control/paramBinding.js';
function startSynthVoice(uid, ast, cueElement, opts) {
const ctx = sharedAudioCtx;
const params = extractParams(ast);
const unbinders = [];
// Frequency binding
const freqBinding = bindParam(
params.freq,
(hz) => {
if (voice.source?.kind === 'osc') {
voice.source.node.frequency.setTargetAtTime(hz, ctx.currentTime, 0.02);
}
},
{ min: 20, max: 20000, default: 440 }
);
unbinders.push(freqBinding.unbind);
// Amplitude binding
const ampBinding = bindParam(
params.amp,
(amp) => {
voice.graph.gain.gain.setTargetAtTime(amp, ctx.currentTime, 0.02);
},
{ min: 0, max: 0.5, default: 0.1 }
);
unbinders.push(ampBinding.unbind);
// ... create voice with initial values ...
const freqHz = freqBinding.value;
const amp = ampBinding.value;
// Store unbinders on voice for cleanup
voice._unbinders = unbinders;
}
function cleanupVoice(uid, voice) {
// Unbind all signal subscriptions
voice._unbinders?.forEach(fn => fn());
// ... rest of cleanup ...
}
Parser Integration
Modifying the Parser
The parser needs to recognize signal references in parameter values. Add this to the value extraction logic:
Location: parser/parser.js, in cstToAst() synth/audio sections
import { maybeConvertToSignalRef } from './parserSignalRef.js';
// When extracting a parameter value:
let val = extractRawValue(valueNode);
val = maybeConvertToSignalRef(val, paramName);
What the Parser Outputs
Input DSL:
synth(uid:pad, freq: follow(fader1.t, 200, 800, 440, exp2), amp:0.2)
Output AST:
{
type: "cueSynth",
args: [
{ type: "uid", value: "pad" },
{
type: "freq",
value: {
type: "signalRef",
source: "fader1",
channel: "t",
range: [200, 800],
default: 440,
curve: "exp2"
}
},
{ type: "amp", value: 0.2 }
]
}
Note: Fields default and curve are only present when specified in the DSL.
Debugging
Enable Debug Mode
// In browser console:
oscillaParamBus.setDebugMode(true);
This logs all signal changes.
Inspect Current Signals
// List all signals:
oscillaParamBus.list();
// List signals from a specific source:
oscillaParamBus.list("o2p:");
// Get current value:
oscillaParamBus.get("o2p:fader1.t");
// Get snapshot of all values:
oscillaParamBus.snapshot();
Test Signal Publishing
// Manually publish a signal:
oscillaParamBus.set("o2p:test.t", 0.5);
// Watch a signal:
const unsub = oscillaParamBus.subscribe("o2p:test.t", (value, path) => {
console.log(`${path} = ${value}`);
});
// Later: unsub() to stop watching
Common Patterns
XY Pad → Synth
o2p(path:xyPad, trig:touch, uid:xy)
synth(uid:pad, freq: follow(xy.x, 200, 2000), amp: follow(xy.y, 0, 0.5))
Rotation → Filter
rotate(dur:4, loop:0, uid:wheel)
synth(uid:drone, freq:220, cutoff: follow(wheel.norm, 200, 4000))
Speed Control with Curve
o2p(path:speedFader, trig:touch, uid:speed)
rotate(dur: follow(speed.t, 120, 1), rotspeed: follow(speed.t, 0, 4800, exp3), uid:spinner)
Using exp3 curve gives finer control at lower speeds.
Multiple Controllers → One Synth
o2p(path:freqSlider, trig:touch, uid:fSlider)
o2p(path:ampSlider, trig:touch, uid:aSlider)
synth(uid:lead, freq: follow(fSlider.t, 100, 1000), amp: follow(aSlider.t, 0, 0.3))
One Controller → Multiple Synths
o2p(path:masterFader, trig:touch, uid:master)
synth(uid:bass, freq: follow(master.t, 50, 200), amp:0.2)
synth(uid:mid, freq: follow(master.t, 200, 800), amp:0.15)
synth(uid:high, freq: follow(master.t, 800, 4000), amp:0.1)
Fader with Default Position
o2p(path:volumeFader, trig:touch, uid:vol)
synth(uid:pad, amp: follow(vol.t, 0, 1, 0.5))
Fader starts at 0.5 (middle). Without -0.5, the fader position is restored from presets/localStorage.
Limitations & Notes
-
Signal Publishing Rate: Signals are published at ~60fps to avoid overwhelming the system.
-
Binding Lifecycle: Bindings are automatically cleaned up when the cue stops (if you call the unbind functions).
-
No Circular Dependencies: The system doesn't prevent circular signal routing. Avoid creating feedback loops unless intentional.
-
Static Parameters: Some parameters can't be changed after cue start (e.g.,
wavetype, audiosrc). These will use the initial value even if bound. -
Signal Range: All signals are assumed to be in the 0-1 range. Use the
-min-maxsyntax to map to your desired output range. -
Default Values & Presets: When no default is specified in the signal ref, the fader's initial position is controlled by presets or localStorage. This allows the same score to have different initial states. When a default IS specified, the fader will always start at that position.
-
Curve Inversion: When setting a default value with a curve (e.g.,
exp3), the fader position is calculated by inverting the curve so that the output equals the default value.
Summary Checklist
To Make an Animation Publish Signals:
- ✅ Import
publishfromcontrol/paramBinding.js - ✅ Add
publish("type", uid, { channel: value })to update callback - ✅ Done!
To Make a Cue Accept Signal Bindings:
- ✅ Import
bindParamfromcontrol/paramBinding.js - ✅ Wrap parameter initialization with
bindParam() - ✅ Store unbind functions for cleanup
- ✅ Call unbind functions when cue stops
- ✅ Update parser to recognize signal refs (one-time)
Version History
-
v1.1 — Extended signal reference syntax
follow(source.channel, min, max, default, curve)- Non-linear curves:
exp2,exp3,exp4,log,sqrt - Optional default values (presets/localStorage control initial state when omitted)
- Fader default positioning respects curve for correct output mapping
-
v1.0 — Initial control plane implementation
- ParamBus signal store
- bindParam/publish helpers
- Signal reference syntax support
Tip: use ← → or ↑ ↓ to navigate the docs