ext() — Score Extensions

The extension system lets composers add custom JavaScript to a score without modifying Oscilla's core. Extensions are ES modules in scores/myproject/extensions/.

Directory Structure

Extensions live in your score's extensions/ folder:

scores/
  myproject/
    score.svg
    extensions/
      myextension.js
      visuals.js

The ext() Cue

The ext() cue connects SVG elements to extension functions.

Syntax

ext(fn:moduleName.functionName, on:eventType)
ext(fn:moduleName.setup, location:fixed, offsetX:100, on:load)

Parameters

Parameter Description
fn: Function path: moduleName.functionName (required)
on: Event trigger: load, click, select, play, stop, or custom event name (default: load)
extra params Any other key:value pairs are forwarded to the function via ctx

Extra DSL parameters via ctx

Any parameter in the ext() cue beyond fn: and on: is forwarded to the function through the context object. This lets the DSL configure extension behaviour without writing separate function variants:

<!-- location and offsetX are forwarded to ctx -->
<rect id="ext(fn:p5sketch.setup, location:fixed, offsetX:100, on:load)"
      x="600" y="400" width="200" height="200" fill="none"/>
export function setup(ctx) {
  const location = ctx.location || 'scroll';   // from DSL
  const offsetX  = Number(ctx.offsetX) || 0;   // from DSL
  // ...
}

Trigger element sizing

The ext() trigger element can have real dimensions — these are used to anchor visual output (e.g. a canvas) to the element's position on screen. Use opacity="0.01" with pointer-events="all" for invisible-but-interactive elements:

<!-- Invisible but draggable trigger element -->
<circle id="ext(fn:p5sketch.setup, on:load) drag(1, uid:myCanvas)"
        cx="500" cy="400" r="10"
        fill="#6af" opacity="0.01" style="cursor:grab;"/>

Note: opacity="0" can disable hit-testing in some browsers even with pointer-events="all". Use opacity="0.01" for invisible-but-interactive elements.

Example Usage in SVG

<!-- Call setup once when score loads -->
<rect id="ext(fn:visuals.setup, on:load)"
      x="0" y="0" width="200" height="200" fill="none"/>

<!-- Call onClick when this element is clicked -->
<g id="ext(fn:myextension.onClick, on:click)">
  <rect x="0" y="0" width="100" height="100" fill="red" />
</g>

<!-- Call onSelect when polygon selection changes -->
<g id="ext(fn:myextension.onSelect, on:select)">
  <rect x="0" y="0" width="0" height="0" opacity="0" />
</g>

<!-- Compound ID: ext + drag on the same element -->
<circle id="ext(fn:sketch.setup, location:scroll, on:load) drag(1, uid:canvas)"
        cx="400" cy="300" r="8" fill="#6af" opacity="0.01" style="cursor:grab;"/>

Writing Extensions

Loading CDN libraries

Do not use static top-level import for CDN URLs — they fail in the dynamic-import context Oscilla uses to load extensions. Instead inject a <script> tag:

// extensions/confetti.js
const CDN = 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js';

function _load() {
  return new Promise(resolve => {
    if (typeof window.confetti === 'function') return resolve();
    const s = document.createElement('script');
    s.src = CDN;
    s.onload = () => resolve();
    s.onerror = () => resolve();   // fail gracefully
    document.head.appendChild(s);
  });
}
_load();   // preload immediately

export function onClick(ctx) {
  _load().then(() => {
    window.confetti?.({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
  });
}

For libraries with multiple CDN fallbacks, try them in sequence:

const CDNS = [
  'https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js',
  'https://cdn.jsdelivr.net/npm/p5@1.9.0/dist/p5.min.js',
];

function _loadP5() {
  if (typeof window.p5 === 'function') return Promise.resolve();
  return _tryNext(CDNS, 0);
}

function _tryNext(urls, i) {
  return new Promise(resolve => {
    if (i >= urls.length) { console.warn('[ext] all CDNs failed'); return resolve(); }
    const s = document.createElement('script');
    s.src = urls[i];
    s.onload = () => resolve();
    s.onerror = () => _tryNext(urls, i + 1).then(resolve);
    document.head.appendChild(s);
  });
}

Basic Extension

// extensions/myextension.js

export function onSelect(ctx) {
  const { selected, selectedSet, wasActivation, wasDeactivation } = ctx;
  console.log('Selected groups:', selected);
}

export function onClick(ctx) {
  const { element, clickEvent } = ctx;
  console.log('Clicked:', element.id);
}

console.log('[myextension] Extension loaded');

Context object

export function myFunction(ctx) {
  ctx.event;     // event type ('load', 'click', etc.)
  ctx.element;   // the SVG element carrying the ext() cue
  ctx.fnPath;    // 'moduleName.myFunction'
  ctx.paramBus;  // Oscilla control signal system
  // ...plus any extra DSL params: ctx.location, ctx.offsetX, etc.
}

For on:select events:

Property Description
selected Array of currently selected group IDs
selectedSet Set of selected group IDs
groupId The group that was just toggled
wasActivation True if this was a selection
wasDeactivation True if this was a deselection
all Array of all selectable elements
selectors All path selector objects

For on:click events:

Property Description
clickEvent The native click event
target The clicked element

For on:play / on:stop events:

Property Description
playing Boolean playback state
playheadX Current playhead position

Positioning visual output (canvases, overlays)

When an extension creates a DOM overlay (canvas, div), anchor it to #scoreInner (the score's position-relative container) to make it scroll with the score. Reposition each animation frame using getBoundingClientRect() so it tracks the anchor element as the score scrolls:

export function setup(ctx) {
  const scoreInner = document.getElementById('scoreInner');
  const container  = document.createElement('div');
  Object.assign(container.style, {
    position:      'absolute',   // inside scoreInner → scrolls with score
    left:          '0px',
    top:           '0px',
    width:         '500px',
    height:        '500px',
    pointerEvents: 'none',
    zIndex:        '100',
  });
  // Store anchor for per-frame repositioning
  container._anchor = ctx.element;
  container._mount  = scoreInner;
  scoreInner.appendChild(container);
}

Reposition in your animation loop (e.g. inside p5's draw()):

if (container._anchor && container._mount) {
  const eR = container._anchor.getBoundingClientRect();
  const mR = container._mount.getBoundingClientRect();
  if (eR.width > 0 || eR.height > 0) {
    container.style.left = `${eR.left - mR.left + eR.width  / 2 - 250}px`;
    container.style.top  = `${eR.top  - mR.top  + eR.height / 2 - 250}px`;
  }
}

For a viewport-pinned overlay that stays fixed regardless of score scroll, use position:fixed appended to document.body instead:

const fixed = ctx.location === 'fixed';   // from DSL: location:fixed
container.style.position = fixed ? 'fixed' : 'absolute';
(fixed ? document.body : scoreInner).appendChild(container);

Using ParamBus

Extensions can read and subscribe to Oscilla's control signals:

// extensions/reactive.js

let unsubscribe = null;

export function setup(ctx) {
  const { paramBus } = ctx;

  // Subscribe to a fader value
  unsubscribe = paramBus.subscribe('myFader.t', (value) => {
    console.log('Fader value:', value);
  });
}

export function cleanup(ctx) {
  if (unsubscribe) {
    unsubscribe();
  }
}

Custom Events

You can listen for any custom event by specifying its name:

<g id="ext(fn:myext.onCustom, on:mycustomevent)" />

Then dispatch from anywhere:

window.dispatchEvent(new CustomEvent('mycustomevent', {
  detail: { foo: 'bar' }
}));

Your extension receives the event detail:

export function onCustom(ctx) {
  const { detail } = ctx;
  console.log('Custom event data:', detail);
}

Tips

  1. CDN imports: Use script-tag injection, not static import ... from CDN_URL. Static CDN imports fail in the dynamic-import context Oscilla uses.
  2. Trigger element: Give it real dimensions so getBoundingClientRect() works for positioning. A zero-size element returns a zero bbox.
  3. Invisible handles: opacity="0.01" keeps hit-testing alive; opacity="0" can disable pointer events in some browsers even with pointer-events="all".
  4. Compound IDs: An element can carry both ext(...) and drag(1) in the same id, separated by a space: ext(fn:sketch.setup, on:load) drag(1, uid:canvas).
  5. on:load timing: on:load retries until the extension function is actually available, accommodating slow CDN fetches or async module resolution.
  6. DSL params: Any extra key:value in the ext() cue beyond fn: and on: reaches your function as ctx.key. Use this instead of writing multiple function variants.

Complete Example

Here's a complete extension that dims unselected polygons:

// extensions/polygonfield.js

const POLYGON_GROUP_IDS = [
  'g_triangleA', 'g_triangleB', 'g_triangleC',
  'g_square', 'g_pentagon', 'g_hexagon', 'g_heptagon', 'g_octagon'
];

const SELECTED_OPACITY = 1;
const DIMMED_OPACITY = 0.4;

function getGroupId(selected) {
  return `g_${selected}`;
}

export function onSelect(ctx) {
  const { selected } = ctx;

  const selectedGroups = new Set(
    Array.isArray(selected) ? selected.map(getGroupId) : []
  );

  const hasSelection = selectedGroups.size > 0;

  for (const groupId of POLYGON_GROUP_IDS) {
    const el = document.getElementById(groupId);
    if (!el) continue;

    el.style.transition = 'opacity 0.2s ease-in-out';

    const opacity = !hasSelection ? SELECTED_OPACITY
      : selectedGroups.has(groupId) ? SELECTED_OPACITY
      : DIMMED_OPACITY;

    el.setAttribute('opacity', opacity);
  }
}

export function reset() {
  for (const groupId of POLYGON_GROUP_IDS) {
    const el = document.getElementById(groupId);
    if (el) el.setAttribute('opacity', SELECTED_OPACITY);
  }
}

console.log('[ext:polygonfield] Extension loaded');
<!-- In score.svg -->
<g id="ext(fn:polygonfield.onSelect, on:select)">
  <rect x="0" y="0" width="0" height="0" opacity="0" />
</g>

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