Oscilla Control Plane — Architecture & Usage Guide

Oscilla Dynamic Signal Control
Published signals are shared across the system, so animations can control audio parameters, slider-like interfaces can control animations, and multiple cues can influence each other in any combination. Because values are updated continuously, this also allows feedback systems where motion, sound, and interaction form coupled, dynamic behaviours within the score.
synth(uid:pad, freq: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: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:myFader.t-200-800, amp:0.2)

The freq:myFader.t-200-800 syntax means:

3. Result

Moving the fader changes the synth's frequency in real-time!


Signal Reference Syntax

Signal references use hyphen-separated segments to specify range, default, and curve options.

Basic Binding

param:source.channel

Binding with Range

param:source.channel-min-max

Maps the signal (assumed 0-1) to the specified output range using linear interpolation.

Binding with Range and Curve

param:source.channel-min-max-curve

Maps the signal to the output range using a non-linear curve. Available curves:

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
sqrt Square root — faster at start

Binding with Default Value

param:source.channel-min-max-default
param:source.channel-min-max-default-curve

When a default is specified, the fader will initialize to the position that produces that output value. When no default is specified, presets and localStorage control the initial state.

Syntax Summary

Format Example Description
source.channel fader.t Basic binding (0-1)
source.channel-min-max fader.t-200-2000 With range, linear
source.channel-min-max-curve fader.t-0-4800-exp3 With range and curve (no default)
source.channel-min-max-default fader.t-200-2000-440 With range and default
source.channel-min-max-default-curve fader.t-0-4800-60-exp3 Full syntax

Examples

DSL Meaning
freq:slider.t Freq follows slider position (0-1)
freq:slider.t-200-2000 Freq mapped to 200-2000 Hz (linear)
freq:slider.t-200-2000-440 Freq 200-2000 Hz, starts at 440 Hz
rotspeed:knob.t-0-4800-exp3 Rotation speed with cubic curve
rotspeed:knob.t-0-4800-60-exp3 Rotation speed, starts at 60°/s, cubic curve
amp:fader.y-0-0.5 Amplitude mapped to 0-0.5
pan:knob.x--1-1 Pan mapped to -1 to 1 (note double hyphen for negative min)
dur:speed.t-120-1 Duration 120s (left) to 1s (right) — inverted for speed control

Published Signals by Cue Type

O2P Animation

Signal Description Range
o2p:{uid}.t Position along path 0-1
o2p:{uid}.x Normalized X in frame 0-1
o2p:{uid}.y Normalized Y in frame 0-1
o2p:{uid}.angle Tangent angle degrees

Rotate Animation

Signal Description Range
rotate:{uid}.angle Current angle 0-360
rotate:{uid}.rad Angle in radians 0-2π
rotate:{uid}.norm Normalized angle 0-1

Scale Animation

Signal Description Range
scale:{uid}.sx Scale X factor varies
scale:{uid}.sy Scale Y factor varies
scale:{uid}.uniform Average scale varies

Bindable Parameters

Synth

Parameter Default Range Description
freq / frequency 20-20000 Oscillator frequency (Hz)
amp / amplitude 0-1 Output amplitude
pan -1 to 1 Stereo position
cutoff / filterFreq 20-20000 Filter cutoff (Hz)
q / resonance 0.1-30 Filter resonance
detune -1200 to 1200 Detune in cents

Audio / AudioPool / AudioImpulse

Parameter Default Range Description
amp 0-1 Playback amplitude
pan -1 to 1 Stereo position
pitch / rate 0.25-4 Playback rate

Effects (within synth)

Parameter Default Range Description
delayTime 0-2 Delay time (seconds)
feedback / fb 0-0.99 Delay feedback
mix / wet 0-1 Effect mix level

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: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:xy.x-200-2000, amp:xy.y-0-0.5)

Rotation → Filter

rotate(dur:4, loop:0, uid:wheel)
synth(uid:drone, freq:220, cutoff:wheel.norm-200-4000)

Speed Control with Curve

o2p(path:speedFader, trig:touch, uid:speed)
rotate(dur:speed.t-120-1, rotspeed: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:fSlider.t-100-1000, amp:aSlider.t-0-0.3)

One Controller → Multiple Synths

o2p(path:masterFader, trig:touch, uid:master)
synth(uid:bass, freq:master.t-50-200, amp:0.2)
synth(uid:mid, freq:master.t-200-800, amp:0.15)
synth(uid:high, freq:master.t-800-4000, amp:0.1)

Fader with Default Position

o2p(path:volumeFader, trig:touch, uid:vol)
synth(uid:pad, amp: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