Live

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:

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:

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)

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.

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 beats while r1 is at the start of its turn and half a beat 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:1 means once, so a continuous spin simply does not name loop at all.

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 showed fader.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 120 beats (left) to 1 beat (right) — inverted for speed control

sinOsc() and its family — a parameter that moves by itself

follow() makes a parameter follow something else. Sometimes there is nothing to follow: the pan should just swing, the filter breathe, the level wander. Written straight into the parameter, an oscillator does that, made for each voice and played by the audio engine (smooth whatever the frame rate, and light).

synth(uid:noise, wave:pink, poly:4, amp:0.3,
  pan:sinOsc(0.4, rand(0, 1)),                       // each voice its own phase
  filter:{type:bp, freq:sinOsc(0.1, 0, 200, 4000), q:0.3})
synth(uid:pad, wave:saw, freq:110, amp:triOsc(2, 0, 0.05, 0.2))
audio(uid:rain, src:rain.wav, loop:1, pan:lfNoise(0.5))
call arguments moves like
sinOsc (rate, phase, lo, hi) a sine
triOsc (rate, phase, lo, hi) a triangle
sawOsc (rate, phase, lo, hi) rising from lo to hi, then jumping straight back to lo -- for a smooth sweep there and back, triOsc (at half the rate for the same speed)
pulseOsc (rate, phase, lo, hi, width) on and off; width is the share of each cycle at hi (0.5)
lfNoise (rate, lo, hi) wandering smoothly to a new random point rate times a second
lfStep (rate, lo, hi) jumping to a new random value rate times a second

rate is in Hz, phase 0–1 of a cycle (0), lo–hi the range (−1–1, which is pan's whole width). Any argument can be rand(...): drawn for each voice, so with poly: every voice moves its own way. The names are SuperCollider's (SinOsc, LFTri, LFSaw, LFPulse, LFNoise1, LFNoise0).

They work on a synth's pan, amp, freq and filter:{freq, q}, and an audio file's pan and amp. On amp the sound peaks at hi and dips to lo. Running a synth's line again with the same call keeps it moving where it is; another call takes over. In the live console, sinOsc( shows its arguments as you type them.


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.

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 called link(). 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 because follow() names a behaviour: this parameter tracks that signal continuously, for as long as both exist. That is exactly what distinguishes it from fade(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.

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.

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
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
the beat beat.phase through the beat, 0-1 — the tempo() grid, the same on every client
the beat beat.hit, every(4, beat.hit), every(8, beat.hit) ... 1 for a moment on each beat; on every 4th (each bar's first), every 8th ...
the beat beat.div(2), beat.div(4), beat.div(3) ... 1 for a moment on each 8th, 16th, triplet ... of the beat
the beat every(3, beat.div(4)) ... the same on every 3rd 16th only (dotted 8ths), every 5th ... -- counted on the shared clock
the beat beat.n beat.bar the beat's and the bar's (4 beats) number, counted from the shared clock's start
the beat beat.bpm the tempo
the mouse mouse.x mouse.y the pointer, 0-1 across and down the window (a touch too); the middle until it moves
the mouse mouse.down 1 while a button (a finger) is down
the mouse mouse.hit mouse.n a press: 1 for ~0.1 s, and how many -- trig:mouse.hit, every(4, mouse.hit)
the camera cam.motion cam.level how much of the picture moves, 0-1; the same against its own most lately (cam())
the camera cam.bright cam.x cam.y how bright; where the movement is, 0-1 across and down
the camera cam.hit cam.n a burst of movement, and how many
MIDI in midi.note60 0-1 the velocity while the key is down, 0 when it is let go
MIDI in midi.cc64 0-1 the controller's value
MIDI in midi.pc3 1 for a moment on a program change
MIDI in midi3.cc64, midi3.note60 ... the same, on channel 3 only
text {uid}.line which line it shows
synth {uid}.freq the step's pitch, Hz
synth {uid}.amp the step's amp
synth {uid}.t the pitch between the lowest and highest it has played, 0-1 (each octave an equal share)
synth audio audioPool audioImpulse {uid}.level how loud, 0-1 of its own loudest lately
synth audio audioPool audioImpulse {uid}.rms how loud, unscaled
synth audio audioPool audioImpulse {uid}.fader its fader, set by level(): 1 as written, 0 silent (level and rms are read after it)
synth audio audioPool audioImpulse {uid}.hit {uid}.n an event — a step, a file or grain starting: 1 for ~0.1 s, and how many
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.band(2, 4) band 2 of 4 of the spectrum (log-spaced, 30 Hz-16 kHz), against its own loudest lately; any sound's too: bass.band(1, 8)
audio adc.level level against its own loudest lately
audio adc.hit, adc.n 1 for a moment on each attack; how many so far (every(4, adc.hit) on every 4th)
audio adc.pitch detected pitch in Hz
audio adc.note detected note number
audio adc.pitchConf pitch confidence, 0-1

Some of these have no element behind them. beat is the tempo() grid, with no cue to declare; adc is the audio input.

A sound's channels are under its own name, whatever plays it: a poly: synth's voices and an audioPool's grains all count as their cue's uid. level is scaled to that sound's own loudest of the last several seconds, so follow(drums.level, lo, hi) reaches hi whatever the drums' amp; rms is the plain measure.

synth(uid:bass, freq:Pwhite(60, 90), dur:0.25, envPerStep:{a:0.005, d:0.1, s:0})
bass > scale(follow(bass.level, 0.6, 1.4))     the box pumps with the bass

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"

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).


Bindable Parameters

Anything below takes a follow() in place of a number.

Cue Parameter Meaning
rotate dur: beats per revolution
scale sx: x scale factor
scale sy: y scale factor
scale dur: beats per step
color dur: beats per cycle
fade from: start opacity, sampled at trigger
fade to: end opacity, sampled at trigger
fade dur: beats, sampled at trigger
o2p dur: beats 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
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

  1. Signal Publishing Rate: Signals are published at ~60fps to avoid overwhelming the system.

  2. Binding Lifecycle: Bindings are automatically cleaned up when the cue stops (if you call the unbind functions).

  3. No Circular Dependencies: The system doesn't prevent circular signal routing. Avoid creating feedback loops unless intentional.

  4. Static Parameters: Some parameters can't be changed after cue start (e.g., wave type, audio src). These will use the initial value even if bound.

  5. Signal Range: All signals are assumed to be in the 0-1 range. Use the -min-max syntax to map to your desired output range.

  6. 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.

  7. 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:

  1. ✅ Import publish from control/paramBinding.js
  2. ✅ Add publish("type", uid, { channel: value }) to update callback
  3. ✅ Done!

To Make a Cue Accept Signal Bindings:

  1. ✅ Import bindParam from control/paramBinding.js
  2. ✅ Wrap parameter initialization with bindParam()
  3. ✅ Store unbind functions for cleanup
  4. ✅ Call unbind functions when cue stops
  5. ✅ Update parser to recognize signal refs (one-time)

Version History

Tip: use ← → or ↑ ↓ to navigate the docs