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
- CDN imports: Use script-tag injection, not static
import ... from CDN_URL. Static CDN imports fail in the dynamic-import context Oscilla uses. - Trigger element: Give it real dimensions so
getBoundingClientRect()works for positioning. A zero-size element returns a zero bbox. - Invisible handles:
opacity="0.01"keeps hit-testing alive;opacity="0"can disable pointer events in some browsers even withpointer-events="all". - Compound IDs: An element can carry both
ext(...)anddrag(1)in the same id, separated by a space:ext(fn:sketch.setup, on:load) drag(1, uid:canvas). - on:load timing:
on:loadretries until the extension function is actually available, accommodating slow CDN fetches or async module resolution. - DSL params: Any extra
key:valuein the ext() cue beyondfn:andon:reaches your function asctx.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