Widgets API
Every Stage widget is addressable from a script by its name (the script identifier you set in the inspector, not the user-visible label). One top-level helper returns a handle:
widget(name: string): SwWidgetwidget(name) always returns a handle. The handle is cheap (a fresh one is created on every call) and holds no state of its own, every read and write goes straight through the engine's widget table. If the name isn't bound to anything, get() simply reads back whatever the engine has for it (0 by default) and set() is a no-op mirror.
SwWidget
interface SwWidget {
get(): number; // current value (booleans read as 0/1)
set(value: number): void; // write the value
on(event: 'change', fn: (value: number) => void): void;
}That is the entire surface. There is no toggle(), setLabel(), setColor(), type, label, or off(), the handle only reads, writes, and subscribes.
get() and set()
Continuous widgets (knob, fader, drawbar, expression pedal): get() returns 0..1 and set(v) takes the same range. Your widget's scaling curve still applies to what the binding sees (see Stage → Scaling curves).
Boolean widgets (button, toggle, LED, LED-button, sustain pedal): get() returns 0 or 1 and set() takes 0 or 1.
widget('GAIN').set(0.5); // half
const tempoOn = widget('TEMPO_LED').get(); // 0 | 1
// No toggle() helper, read-invert-write:
const mute = widget('MUTE_1');
mute.set(mute.get() > 0.5 ? 0 : 1); // flipon('change', fn)
Fires when the widget's value changes for any reason: mouse, MIDI, OSC, or a script write. The handler receives the new value (a number). Subscribers are removed automatically on script reload, nothing to clean up by hand.
const muteOne = widget('MUTE_1');
muteOne.on('change', (v) => {
console.log(`MUTE_1 is now ${v >= 0.5 ? 'ON' : 'OFF'}`);
});Loop guard
A handler that writes to its own widget from its own on('change') can re-fire itself. The engine's widget bridge dedupes exact-value writes, but a handler that writes a slightly different value each time can still ping-pong. Prefer writing to a different widget, or use the bind / exclusive helpers, which carry a re-entry guard.
Common patterns
A mute panel driven by hardware
const mutes = [1, 2, 3, 4, 5, 6, 7, 8].map((i) => widget(`MUTE_${i}`));
const akai = midi.input('MIDI Mix');
akai.on('cc', (m) => {
if (m.value !== 127) return; // ignore release
const i = m.controller - 22;
if (i >= 0 && i < mutes.length) {
const w = mutes[i];
w.set(w.get() > 0.5 ? 0 : 1); // toggle
}
});LED feedback to the controller
const out = midi.output('MIDI Mix');
const mutes = [1, 2, 3, 4, 5, 6, 7, 8].map((i) => widget(`MUTE_${i}`));
const setLed = (i: number, on: boolean) =>
out.sendNoteOn(1, 1 + i * 3, on ? 127 : 0);
mutes.forEach((w, i) => {
w.on('change', (v) => setLed(i, v > 0.5));
});
onInit(() => {
mutes.forEach((w, i) => setLed(i, w.get() > 0.5));
});Radio-style switch across ungrouped widgets
set() writes plain values, so a single toggle can drive two mixer strips in opposition:
// One toggle named PIANO_SRC: ON = audio source, OFF = MIDI source.
const src = widget('PIANO_SRC');
const audioMute = widget('PIANO_AUDIO_MUTE'); // mixer strip 1 mute
const midiMute = widget('PIANO_MIDI_MUTE'); // mixer strip 2 mute
const apply = (toAudio: boolean) => {
audioMute.set(toAudio ? 0 : 1);
midiMute.set(toAudio ? 1 : 0);
};
src.on('change', (v) => apply(v > 0.5));
onInit(() => apply(src.get() > 0.5));For a plain radio group with no script, set the same radio-group field on the widgets in the inspector, the cascade handles exclusivity. See Stage → Groups. Reach for a script when each target needs its own value or scaling.
Next: MIDI input & output.