Documentation
The open-audiovisual handbook
Everything you need to run, write and perform a show, on one page. Every API name, option and signal here is checked against the source; when they disagree, the code is right.
19 chapters · 193 sections · oav-p5 0.8 · framework 0.1
Written in Markdown in docs/; this page is generated from it. GitHub ↗ · For language models: /llms.txt · /llms-full.txt
Introduction#
open-audiovisual (OAV) is a JavaScript framework for live audiovisual performance that runs in the browser. It is plain ES modules: no build step, no dependencies, no account. You clone the repository, run one file, and open a page.
It handles the part every show rebuilds from scratch: MIDI plumbing, turning inputs into parameters, a timeline with scenes, a director's console, a performance mode for the performer, and a backstage page for the stage manager. You write the world, the piece itself. The pitch, a live instrument and the twenty-six example works are on the homepage.
Who it is for#
- Performers play the examples with a MIDI keyboard, a microphone, a camera, a phone, or the on-screen piano, and drive a show from the console.
- Coders write a world in about twenty lines and get inputs, mapping,
timeline, console, sound and backstage from one
createShow()call. - AI agents read AGENTS.md, connect to the bundled MCP server, and write worlds inside a chassis that already works. See For AI agents.
Four layers and two spines#
L1 INPUT "what is happening" midi · audio · chord · pose · keys · drums · phones
→ every input publishes named SIGNALS: midi/cc/74, chord/consonance, audio/rms
L2 MAPPING "what it means" routes: signal → param, with curve, range, smoothing, learn
L3 WORLD "how the system behaves" your piece; reads PARAMS, never inputs; any renderer
L4 OUTPUT "how it leaves" screen · in-browser sound · MIDI out · OSC to UDP
spines timeline (automation, scenes, cues) · monitor (backstage over WebSocket)
around them console (the director's desk) · relay, surface, remote (phones as controllers)Each layer has one job and a written contract (Architecture), so any layer can be replaced without touching the others. Two rules keep it that way; they are explained in Core concepts.
Where it comes from#
The core was extracted from The Last Input (Che-Yu Wu, 2026), a performance for piano and a living digital world at the IRCAM residency × C-LAB Taiwan Sound Lab in Taipei, played on a 49.4-channel speaker dome. The timeline, the chord semantics, many-to-many MIDI learn and OSC batching all ran a real fourteen-scene show before they were generalized here. The rule since then: the framework only grows features that a real show asked for.
The mapping layer's ideas descend from libmapper's research on signal namespaces. The sister project WebToe is a node-based dataflow engine that imports TouchDesigner projects; WebToe is the engine, open-audiovisual is the show.
How to read this handbook#
- New here: Quick start, then Core concepts, then Writing a world.
- Building a show: Inputs, Mapping, Show control, Phones, relay & surfaces.
- Looking something up: Signals reference and Package reference.
- Something is wrong: Troubleshooting.
Everything here is checked against the source in packages/. When this page and the code disagree, the code is right and this page is a bug: please open an issue.
Quick start#
Three ways in#
Pick the one that matches what you already have. They get bigger as you go down.
- One line in a p5.js sketch. Any p5.js sketch (OpenProcessing, the p5 editor, your own page) can be played from a MIDI controller, from a controller drawn on screen, or from a phone, by adding one line at the top. Read p5.js in one line, and play the sketch that carries it: example 15. The same line also plays sound, runs a drum machine you can write as one line of rhythm, listens to the microphone and records a video; each has a ready-made sketch among the examples (16 to 26).
- One controller in a page.
<oav-controller profile="akai-lpd8">puts a controller you can play into any web page. Read MIDI controllers & embedding, and pick yours in the controller library. - A whole show. A World plus one
createShow()call assembles inputs, mapping, timeline, console, sound and backstage. Start from example 01 and keep reading below.
Run it#
You need Node.js (any current version; CI runs the tests on Node 22) and a
browser. There is nothing to install: no npm install, no bundler.
git clone https://github.com/frank890417/open-audiovisual.git
cd open-audiovisual
node serve.js # → http://localhost:8080serve.js is a static file server with caching turned off (stale code on stage
is a nightmare) that also hosts the phone relay at ws://<host>:8080/relay.
Pass a port to use another one: node serve.js 3000. The same commands exist as
npm scripts:
| command | what it runs |
|---|---|
npm start | node serve.js |
npm run monitor | node packages/monitor/server.js, the backstage monitor on port 7457 |
npm run bridge | node packages/osc/bridges/osc-bridge.js, the OSC → UDP bridge on port 7456 |
npm test | node --test tests/*.test.js |
Open http://localhost:8080/examples/01-hello-particles/. Every example runs
without hardware.
Which browser#
Not sure what your device can do? Open the device check. It looks at your browser, Web MIDI, microphone, camera and graphics, and says what to do about each. Nothing runs until you press a button, and nothing leaves your device.
Web MIDI is the one that differs most between browsers (sources checked 2026-10-15):
- Chrome and Edge have it. Use them for a show. Since Chrome 124 any use of MIDI asks for permission, where before only system-exclusive access did (Chrome blog).
- Firefox has Web MIDI on desktop from version 108, after you install Firefox's site-permission add-on (Firefox 108 release notes).
- Safari on Mac, iPhone and iPad has no Web MIDI (caniuse). WebKit lists it as not implemented because of fingerprinting concerns (WebKit), and bug 107250 is still open and unassigned. Chrome on an iPhone or iPad is WebKit underneath, so it has none either. On a Mac there is a third-party Safari extension, Safari-WebMIDI; we have not tested it.
Where the browser has no Web MIDI, there are three routes:
- Play on screen. Every controller in the library plays with a finger, and every event still fires.
- Use a phone as the controller. See Phones, relay & surfaces.
- A browser app for the iPad. MIDIWeb Browser is a third-party app for iOS 17.6 and later that gives pages Web MIDI. We have not tested it with open-audiovisual.
On Windows 11 the built-in Windows MIDI Services (released 2026-02-17) give apps shared MIDI ports and a loopback, so loopMIDI is no longer needed to send MIDI from the browser to a DAW (Windows blog); see Play your DAW with it.
Two more rules apply to every browser:
- Camera and microphone only work in a secure context:
http://localhostorhttps://. Open the show on the machine that runsserve.jsthroughlocalhost, not through its LAN address. - Phones can join over plain
http://on the local network for touch, keys and the control surface. iOS only gives motion sensors and the camera tohttps://pages (see Phones, relay & surfaces).
What you are looking at#
An example page has two parts: the stage on the left (the world renders there) and the side panel on the right. From the top, the side panel holds:
- The piano, with two checkboxes: keyboard turns on QWERTY playing, simulate performance starts a hands-free player.
- Transport: play/pause, previous/next scene, reset, the clock, the current scene, and a scrubber with one block per scene.
- Layers: what every layer is doing right now (events per second per input, active routes, world and FPS, what is leaving the browser).
- L1 · Input: MIDI devices (untick to mute one) and buttons for sources that need a click: 🎤 mic, 🖐 hands, 🕺 body.
- L2 · Mapping: every route, with a mute box and ✕ to remove it.
- L3 · Params: a slider per param, ✕ to clear an override, learn to map.
- L4 · Output — sound, when the show has sound: enable sound.
- Signals: a live meter for every signal.
Each panel title collapses its panel.
| key | action |
|---|---|
| Space | play / pause the timeline |
| ← → | previous / next scene |
| R | stop and rewind the timeline to 0:00 |
| T | performance mode (teleprompter) |
| F | fullscreen |
| M | show/hide the on-screen MIDI controller (only in shows that set modules.midi.controllers) |
Play without hardware#
- On-screen piano: click or touch the keys; drag across them for a glissando.
- QWERTY: tick keyboard first (capture is off by default so letters do not
steal the console's hotkeys). The keys
A W S E D F T G Y H U J K O L P ;play 17 notes a semitone apart, starting at the piano's base note; Z/X shift an octave; hold Shift for a louder note. - Simulate performance: a hands-free player noodles in a pentatonic scale with steps, leaps, triads and rests, so you can watch the mapping breathe.
- Drum machine (examples 03 and 06): a step sequencer whose signals have the same shape as the microphone's drum detection.
- On-screen MIDI controller (example 09): a picture of a known controller you can play with the mouse (the supported devices are listed on the controllers page); plug the real one in and the picture moves with your hands.
All of these publish the same signals as real hardware, so nothing downstream can tell the difference.
Your first world#
A world is an object with params and four lifecycle functions. Hand it to
createShow() and the rest of the show is assembled around it:
import { createShow } from '@openav/show';
import { createCanvas } from '@openav/stage';
const pulse = {
name: 'pulse',
// what the piece is performed WITH: sliders, knobs, hands, the timeline
params: [{ key: 'size', min: 10, max: 200, def: 40 }],
init({ container, signals }) {
this.view = createCanvas(container);
this.r = 0;
// a struck note is an event, so it may come straight from a signal
signals.on('midi/note/on', ({ vel }) => { this.r = 120 * vel; });
},
update(dt, state) { this.r = Math.max(0, this.r - dt * 90); this.size = state.size; },
render() {
const { ctx } = this.view, { w, h } = this.view.fit();
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(w / 2, h / 2, this.size + this.r, 0, 7); ctx.fill();
},
dispose() { this.view.dispose(); },
};
await createShow({ world: pulse, modules: { sound: true } });Play a note: the circle jumps and decays. Drag the size slider, or click learn next to it and turn a knob. That is the whole model: params for continuous control, signals for events.
Make a new example#
- Copy
examples/01-hello-particles/toexamples/<nn>-<name>/(the next free number). Itsindex.htmlalready has an import map for every@openav/*package; the?v=suffixes are cache stamps written bytools/stamp-version.mjsbefore a deploy. - Write your world in
main.js(Writing a world). - Change the
<title>inindex.html. Add CDN script tags there if you use p5 or another renderer. - Open
http://localhost:8080/examples/<nn>-<name>/.
An AI agent can do steps 1–2 with the MCP tool scaffold_world
(For AI agents).
Check your work#
npm testpasses (it covers the pure logic: chords, timeline, mapping, relay, surfaces, MIDI parsing and profiles, the site pages).- The page loads with no console errors. One message is expected: if the
backstage monitor is not running, the browser reports that
ws://localhost:7457refused the connection. The page retries quietly with a growing delay (up to 30 s). Runnpm run monitorto make it go away. - Press Space: the timeline plays and something changes over time.
- Tick keyboard and press A: the world reacts without any hardware.
window.openavin the devtools console is the whole show (signals, params, mapper, timeline…), for poking at it by hand.
Core concepts#
Six words carry the whole framework: signal, param, route, world, timeline, show. This chapter defines each one and the two rules that hold them together.
Signals#
A signal is a named value that says what is happening: a knob's position,
the loudness of the room, the height of a wrist, the consonance of the last
chord. Every input package publishes signals into one registry (Signals from
@openav/core) and does nothing else. Signals are the only currency of input.
There are two kinds:
| kind | holds | example | read it with |
|---|---|---|---|
| continuous | a current value, usually 0..1 | midi/cc/74 = 0.42 | signals.get(name), signals.norm(name) |
| pulse | the payload of the last event | midi/note/on → { note: 60, vel: 0.8, … } | signals.on(name, cb) |
Names are paths, source first: midi/cc/74, audio/band/low,
pose/hand/right/y, phone/ab12/tilt/x, surface/main/hue. Ranges are
normalized where that makes performance sense, and y axes are flipped so that
1 means raised, never raw pixels.
A signal is declared with its range so that meters and learn can scale it:
signals.define('breath/pressure', { min: 0, max: 1, source: 'breath' });
signals.set('breath/pressure', 0.37); // continuous
signals.define('breath/sigh', { kind: 'pulse' });
signals.pulse('breath/sigh', { depth: 0.8 }); // pulseset() on a name nobody declared defines it on the spot as continuous 0..1. If
your values live elsewhere (degrees, MIDI note numbers), declare the range first.
A knob, a raised hand and a chord's consonance are equal citizens in this registry. That equality is what lets you mix them: a hand can do whatever a knob does.
Params#
A param is a value the world listens to: hue, bloom, gravity. The
world declares its params; each one is a line of schema:
{ key: 'energy', label: 'Energy', min: 0, max: 1, def: 0.3 }
{ key: 'mode', label: 'Mode', min: 0, max: 3, def: 0, step: 1 } // stepped: snaps, holds on the timeline
{ key: 'burst', label: 'Burst!', pulse: true } // a trigger, not a levelEvery frame each param resolves to one number:
timeline automation at time t (the score; def if the param has no automation)
⬑ overridden by → the latest override (a slider, a mapped knob, a phone fader)An override is sticky. Once a hand or a route has touched bloom, the
timeline stops moving it until the override is cleared (✕ in the console, or
params.clearOverride('bloom')). That matches the stage: when a human grabs a
control, the score yields.
The full field list is in Writing a world.
Routes and mapping#
A route connects one signal to one param. Routes are plain data:
{ source: 'pose/hand/right/y', target: 'energy', curve: 'smooth', smooth: 0.15 }The mapper (@openav/mapping) holds the routes and applies them: it reads
the signal, clamps it into a window (inMin..inMax), optionally inverts it,
bends it with a curve, scales it into the param's range and, if asked, smooths
it over time. One signal may drive several params and one param may listen to
several signals. Learn binds the next signal that moves, which works for a
knob and for a waving hand alike. Routes serialize to JSON, so a wiring is a
file you can carry to the next venue. Details: Mapping.
Worlds#
A world is the piece: an object with a name, its params, and
init / update / render / dispose. It reads resolved params every frame
and may subscribe to signals for discrete events. It brings its own renderer:
2D canvas, p5, three.js, a WebToe patch, DOM, anything. The framework has no
opinion. Details: Writing a world.
Timeline and scenes#
The timeline (@openav/timeline) is the score: keyframed automation per
param, plus a list of scenes with titles and notes for the performer. It owns
time; worlds never read the clock. Scenes may start before zero for a pre-show
standby. Details: Show control.
The show#
A show is a world plus one createShow() call (@openav/show). It builds
the signals registry, the params, the stage, the timeline, the mapper, the
inputs you ask for, sound, the console, the backstage feed and the frame loop,
and returns all of them. Every example is a world plus this call, so when the
framework grows a module, every show can have it by declaring it.
const show = await createShow({
world,
timeline: { total: 120, automation, scenes },
routes: [{ source: 'audio/rms', target: 'energy', smooth: 0.1 }],
modules: { keys: { base: 48 }, chord: true, audio: true, sound: true },
});The two rules#
Two rules make the framework composable. They come in two forms: one says how the system is designed, the other says what that asks of anyone writing code for it.
1. Signals are the only currency of input. So: continuous control goes through params; discrete events may use signals.
A world that reads signals.get('midi/cc/74') inside update() still works,
today, with your controller. But it can no longer be driven by the timeline, by
a dancer's wrist, by a phone, or by someone else's controller. You have welded
the performance to one input. Declare a param instead and let a route connect
midi/cc/74 → yourParam. The same world then works for everyone, and the
console, the phone surface and learn all see the param.
Events are different. "A note was struck", "a chord resolved", "a kick hit" are
moments, not levels. Subscribing to them in init() is correct and encouraged;
it is what makes a world react rather than just drift.
2. Worlds never know who is performing them. So: the shell never knows the work.
A world reads params, and whether a value came from the timeline, a MIDI knob, a
dancer or a slider is invisible to it. The same holds for the framework: never
edit packages/* to make one example look right. A framework change has to make
every work better. Switching works means swapping a world and its data; the
shell stays.
Architecture#
open-audiovisual is a data-layer framework. Its entire value is that four layers and two spines stay cleanly separated, so any of them can be replaced without touching the others. This page states each layer's contract precisely.
The frame loop#
One Loop (@openav/core) drives everything. This is the order createShow()
runs each frame (packages/show/index.js):
const loop = new Loop((dt) => {
keys?.update(dt); // 0. on-screen performers: SimPlayer, drum sequencer
drums?.update(dt);
audio?.update(); // microphone analysis → audio/* signals
onFrame?.(dt, show); // your assembly-level hook
timeline.advance(dt); // 1. time moves
director?.update(timeline.t, dt, { holding, playing }); // with a score: segment modules enter / update / exit
mapper.update(dt); // 2. smoothed routes settle
const state = stage.frame(dt, timeline.state()); // 3. params resolve → world updates & renders
sound?.update(state); // 4. sound/* params reach the synth
remote?.frame(dt, state); // param values echo back to phones
consoleUI.render(state); // 5. the desk reflects reality
monitor.frame(snapshotOf({ timeline, params, signals, stage, loop }, state)); // 6. backstage hears about it
});Signals arrive between frames, whenever a device or a socket fires. An
unsmoothed route writes its param immediately; a smoothed route sets a target
that mapper.update(dt) glides toward. OSC output is not part of createShow();
if you use it, flush it from onFrame (Show control).
dt is in seconds and clamped to 0.1 (new Loop(fn, { maxDt })), so a stall
never makes a world explode. When the browser freezes requestAnimationFrame
(the projection window went to the background), a Web Worker heartbeat keeps the
loop ticking, about every 66 ms (~15 frames per second): the timeline, mapped signals and
the backstage feed must not stop because someone switched windows.
Param resolution order (the heart of the design):
timeline.state(t) — automation curves per param
⬑ rewritten by → director.param() — a segment module, inside its own segment (with a score)
⬑ overridden by → params.overrides — set by hand sliders, mapped signalsAn override is sticky: once a knob touched bloom, the timeline no longer moves
it until the override is cleared (✕ in the console). This matches performance
reality — when a human grabs a control, the score yields.
L1 · Input — Signals#
Contract: input packages publish named, normalized signals into a
Signals registry, and do nothing else. They never touch params or worlds.
- names are path-like:
midi/cc/74,chord/consonance,pose/hand/right/y - continuous signals declare
{min, max}and always hold a current value - pulse signals (
kind: 'pulse') fire events; their payload is the event object - anyone can subscribe:
signals.on(name, cb)orsignals.onAny(cb)
Adding a new input modality (breath sensor, gamepad, stock ticker, weather API)
means writing one class that calls signals.set() / signals.pulse().
Nothing downstream changes. That is the whole point.
The inputs today: @openav/midi (hardware and on-screen controllers), keys,
drums, audio (microphone), pose (camera body and hands), leap (Leap
Motion / Ultraleap hands) and phones through remote and surface. Hardware a
browser cannot open itself reaches it through a small local bridge that speaks
a web protocol (leap runs one on ws://127.0.0.1:6437); the browser side is
still one class that publishes signals.
Analyzers sit between raw sources and mapping: @openav/chord subscribes to
midi/note/on|off and publishes chord/*. It is still an input as far as the
mapper is concerned; it just listens to other signals instead of a device.
L2 · Mapping — Mapper#
Contract: the only component that connects signals to params. Routes are data:
{ source: 'pose/hand/right/y', target: 'energy',
inMin: 0, inMax: 1, curve: 'smooth', invert: false,
smooth: 0.15, outMin: 0, outMax: 1 }- many-to-many: one signal may drive several params; one param may listen to several signals (last writer wins). Learn adds, it never replaces.
- learn is generalized MIDI-learn:
mapper.learn('bloom')binds the next signal that moves ≥5% of its range — a knob and a waving hand both qualify. - routes serialize to JSON → mapping profiles are shareable files.
Full reference: Mapping.
L3 · World — Stage#
Contract: a World is { name, params, init, update(dt, state, io), render, dispose }.
It reads state (resolved param values). It may subscribe to signals for
discrete events (a note-on spawning a particle is an event, not a parameter),
but continuous control must flow through params — otherwise your world stops
being performable by the timeline or by other inputs.
The Stage owns lifecycle and param aggregation and deliberately does not own a renderer. p5, three.js, 2D canvas, SVG, DOM — the framework has no opinion.
Constitutional rule (inherited from The Last Input): the shell never knows the work. Switching works = swapping World + automation data. The shell stays.
Full reference: Writing a world.
L4 · Output — two branches, video and audio, both optional#
The output layer splits in two. Both branches hang off the same signals → mapping → params spine — sound is performable state, not a side effect.
Video branch
- screen is the default output; performance mode (T) gives the performer a teleprompter while the audience sees the stage window fullscreen (F).
- recording (
@openav/record): the work and the performer's camera composited into one canvas of an exact size (vertical 1080×1920 up to 4K) and recorded with the work's own sound by oneMediaRecorder, so picture and sound share a clock. MIDI takes record the playing itself and replay it into the show. - future: NDI gateway (see roadmap; groundwork lives in sister project WebToe).
Audio branch
- MIDI out (external synths/DAWs):
midi.send()/noteOn()/cc()— one throat, observable (onSend) so meters can watch everything, with a realpanic(). - In-browser synthesis:
@openav/sound— a small pluggable engine contract (params, enable, noteOn, noteOff, set, dispose) with a Tone.js engine first (CDN, loaded only on enable) that plays instruments from a registry: a sampled grand piano, mallets, strings, organ, synth presets, one file per sound, picked from the console. Engine params register assound/*— a knob, a hand, or the timeline plays the filter cutoff exactly the way it plays the visuals. The drum kit in@openav/drumsis a second engine behind the same contract (pure WebAudio, no samples needed). - OSC: browser → HTTP →
osc-bridge.js→ UDP (Spat, Reaper, TD, lights). Batched per frame; only the latest message per address survives a frame.
Spine · Timeline#
Pure logic, no DOM: params + automation keyframes + scenes → state(t).
Scenes may start at negative t (pre-show standby). onSceneChange is the cue
hook — fire sound, lights, prompts from it. A scene with hold: true stops
playback at its end until the operator releases it.
A show with structure adds the score to this spine (@openav/score): the show
written as segment lengths, from which Score computes every start, end, cue and
countdown, and Director runs the module that performs each segment (enter once,
exit once, a crash switches off only that module). The timeline takes its scenes
and total from the score, and the director plugs into it as a param layer:
cut (segment lengths) → Score ─┬─ scenes(), total ──→ Timeline ── state(t) ──→ params
└─ Director ── enter / update / exit ──→ segment modules ──→ the world (through `api`)
└─ param() ── timeline.layer ──────────────────────→ TimelineThe score only decides when. What happens still reaches the world as params. Full reference: Score & Director.
Spine · Monitor#
The performance page streams JSON snapshots (throttled, ~15 Hz) over WebSocket
to a dependency-free relay (server.js, hand-rolled RFC 6455). Any phone or
laptop on the LAN opens the backstage page and sees: clock, scene, world, FPS,
overrides, every signal and param, and a "stage feed lost" alarm. The monitor
is a first-class citizen because a show without a backstage is a rehearsal.
Around the spines: console and phones#
- Console (
@openav/console): the director's desk in the side panel, laid out by layer (Layers → L1 → L2 → L3 → L4 → Signals), plus performance mode. It reads the same objects the loop runs; it owns nothing. - Phones (
@openav/relay,@openav/surface,@openav/remote): phones and iPads join a room on a WebSocket relay and publishphone/*andsurface/*signals. To the mapper they are one more input. See Phones, relay & surfaces.
Writing a world#
A world is the only part of the framework you have to write. Everything else is assembly.
The interface#
export const myWorld = {
name: 'my-world',
// params: what the world can be performed WITH.
// These appear in the console, are automatable by the timeline,
// and are mappable to any signal.
params: [
{ key: 'energy', label: 'Energy', min: 0, max: 1, def: 0.3 },
{ key: 'mode', label: 'Mode', min: 0, max: 3, def: 0, step: 1 }, // step → snaps, holds on the timeline
{ key: 'reset', label: 'Reset', pulse: true }, // momentary trigger
],
init(ctx) {
// ctx = { container, signals, params }
// build your renderer here: p5, three, or the built-in 2D helper:
this.view = createCanvas(ctx.container);
// discrete events may come straight from signals:
this._unsub = ctx.signals.on('chord/event', (a) => this.onChord(a));
this._unpulse = ctx.params.onPulse('reset', () => this.reset());
},
update(dt, state, io) {
// state = resolved param values. This is your ONLY continuous input.
// io = { signals } plus any output handles the assembly added (see "Sound output")
},
render() { /* draw */ },
dispose() { this._unsub?.(); this._unpulse?.(); this.view.dispose(); },
};The contract#
| member | when it runs | what it gets |
|---|---|---|
name | — | a unique string; the stage registers worlds by name |
params | read once, when the world is registered | an array of param declarations (below) |
init(ctx) | when the world becomes active | { container, signals, params }. May be async; createShow() waits for it before the loop starts |
update(dt, state, io) | every frame | dt in seconds (≤ 0.1), state = { key: value } for every param, io = { signals, … } |
render() | every frame, right after update | nothing; draw from what update stored |
dispose() | when another world is activated | release GPU objects, DOM, subscriptions |
surface | optional | a phone control-surface layout; modules.remote uses it instead of generating one (Phones) |
container is an absolutely positioned layer that fills the stage. The stage
empties it before every init, so you never clean up DOM someone else left.
An exception thrown in update or render is caught and logged
([stage] update: / [stage] render:); the show keeps running.
Params#
| field | meaning |
|---|---|
key | the name routes, automation and the console use. Required. |
label | what the console shows. Defaults to key. |
min, max | the range. Overrides are clamped into it; routes scale into it. |
def | the value when there is no automation and no override. The core reads only def; autoSurface() alone also understands default. |
step | snap overrides to multiples of step; on the timeline a stepped param holds each keyframe instead of interpolating. |
pulse | true makes it a trigger instead of a level: the console shows a fire button, a mapped pulse signal (or a continuous signal crossing 0.5 upward) fires it, and the world listens with params.onPulse(key, cb). A pulse param has no value in state. |
group | a label for grouping. Engine params use 'sound'; autoSurface() makes one phone page per group. |
options | names for the values of a stepped param (['Circle', 'Petal', 'Star']); the phone surface shows them as a radio. |
surface | a hint for the phone surface: { type, color, label }. |
If two registered worlds declare the same key, they share one param (the first declaration wins).
The one rule people break#
Continuous control goes through params. Events may use signals.
If your world reads signals.get('midi/cc/74') directly in update(), it still
works — today, with your controller. But it can no longer be driven by the
timeline, by a dancer, or by someone else's controller profile. You've welded
the performance to one input. Route it: declare a param, let the mapper connect
midi/cc/74 → yourParam, and the same world works for everyone.
Events are different: "a note was struck", "a chord resolved", "an onset hit" are
moments, not levels. Subscribing to those in init() is correct and encouraged —
that's what makes worlds reactive rather than just modulated.
Useful event signals: midi/note/on and midi/note/off (any keyboard, the
on-screen piano, the simulated performer, a phone), chord/event (one analysis
per played gesture), audio/onset, audio/kick, drum/kick. The full list is
in the Signals reference.
Scenes and automation#
The timeline is configured next to the world, in the createShow() call:
await createShow({
world: gardenWorld,
timeline: {
total: 120, // seconds
automation: {
growth: [[0, 0.6], [40, 1.4], [90, 0.8], [120, 0.2]], // [t, value], linear between keyframes
wind: [[0, 0.05], [60, 0.3], [100, 0.7]],
},
scenes: [
{ id: 'dawn', t: 0, title: 'Dawn', note: 'single seeds · listen' },
{ id: 'bloom', t: 40, title: 'Full bloom', note: 'triads — stack real thirds' },
{ id: 'storm', t: 90, title: 'Storm', note: 'clusters welcome' },
],
},
modules: { keys: { base: 48 }, chord: true, sound: true },
});- Keyframes are
[seconds, value], sorted by time. Before the first keyframe the value is the first value; after the last, the last value. - Scenes are sorted by
t.titleandnoteare what the performer reads in performance mode (T); the next scene and a countdown show under them. - A scene with a negative
tis a pre-show standby: witht: -30the clock starts at-0:30. - Try performing your world with only the timeline. If it is boring, a param is missing.
More in Show control.
Renderers#
The stage does not own a renderer. Build one in init, destroy it in dispose.
2D canvas#
createCanvas(container, { alpha = false }) from @openav/stage returns
{ canvas, ctx, fit, dispose }. fit() keeps the canvas the size of its
container (device pixel ratio capped at 2) and returns { w, h } in CSS pixels;
call it at the top of render(). Examples 01, 02, 03, 07, 08 and 09 use it.
p5.js#
Load p5 with a script tag in the example's index.html, then run it in
instance mode inside the container. p5 draws on its own loop, so update only
stores the state and render stays empty (examples 05 and 06):
<script src="https://cdn.jsdelivr.net/npm/p5@1.9.4/lib/p5.min.js"></script>const world = {
name: 'p5-world',
params: [{ key: 'hue', min: 0, max: 360, def: 200 }],
init({ container }) {
const self = this;
this.s = {};
this.p5 = new p5((sk) => {
sk.setup = () => { sk.createCanvas(container.clientWidth, container.clientHeight); sk.colorMode(sk.HSB); };
sk.draw = () => { sk.background(self.s.hue ?? 200, 60, 20); };
sk.windowResized = () => sk.resizeCanvas(container.clientWidth, container.clientHeight);
}, container);
},
update(dt, state) { this.s = state; },
render() { /* p5 runs its own loop */ },
dispose() { this.p5?.remove(); },
};three.js#
No bundled example uses three.js yet; this is the pattern. Add three to the
example's import map ("three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"),
keep the renderer on this, and dispose everything:
import * as THREE from 'three';
const orbit = {
name: 'orbit',
params: [{ key: 'spin', min: 0, max: 3, def: 1 }],
init({ container }) {
this.container = container;
this.renderer = new THREE.WebGLRenderer({ antialias: true });
container.appendChild(this.renderer.domElement);
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
this.camera.position.z = 4;
this.mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(1, 2), new THREE.MeshNormalMaterial({ wireframe: true }));
this.scene.add(this.mesh);
},
update(dt, s) { this.mesh.rotation.y += dt * s.spin; },
render() {
const w = this.container.clientWidth, h = this.container.clientHeight;
if (w !== this.w || h !== this.h) {
this.w = w; this.h = h;
this.renderer.setSize(w, h); this.camera.aspect = w / h; this.camera.updateProjectionMatrix();
}
this.renderer.render(this.scene, this.camera);
},
dispose() {
this.mesh.geometry.dispose(); this.mesh.material.dispose();
this.renderer.dispose(); this.renderer.domElement.remove();
},
};A WebToe patch#
webtoeWorld() from @openav/world-webtoe turns a
WebToe node patch into a world. The
patch runs in an iframe; every frame in which a param changed, the resolved
params are posted into it, and any patch parameter written as
ext('name', fallback) listens (example 04):
import { webtoeWorld } from '@openav/world-webtoe';
const world = webtoeWorld({
name: 'garden-patch',
project: new URL('./garden.webtoe.json', location.href).href, // the app fetches it: CORS must allow it
params: [
{ key: 'speed', min: 0, max: 1, def: 0.5 },
{ key: 'hue', min: 0, max: 360, def: 205 },
],
// extra: (state, out) => { out.hueRad = state.hue * Math.PI / 180; }, // derive more ext() values
});Options: name (default 'webtoe'), app (default
https://webtoe.openaudiovisual.com/), project, params, extra.
DOM, SVG, video#
Anything you append to container is fine. The stage only calls your functions.
Several worlds in one show#
const show = await createShow({ world: calm, worlds: [storm, after], timeline });
// switch on a cue: the old world is disposed, the new one initialized
show.timeline.onSceneChange((i, scene) => {
if (scene.id === 'storm') show.stage.activate('storm');
});All worlds' params are registered up front, so the console and the timeline can
address any of them. stage.activate(name) does nothing if that world is
already active.
Sound output from a world#
update(dt, state, io) receives io. With createShow(), io holds only
signals; add output handles once the show exists, and every world sees them:
const show = await createShow({ world });
show.stage.io.midi = show.midi; // the show's @openav/midi engine (MIDI out)
// show.stage.io.osc = osc; // an OscOut you created (see Show control → OSC output)Then, inside the world:
// trigger a sampler note when something happens in the simulation
io.midi?.noteOn(48 + creature.species * 12, 90, 2); // note, velocity 0..127, channel 2
// stream a position to a spatializer
io.osc?.send(`/source/${i}/xyz`, [x, y, z]);Often it is simpler to do it at the assembly level instead, as example 05 does:
show.signals.on('midi/note/on', ({ note, vel }) => show.midi?.noteOn(note, Math.round(vel * 127), 1)).
For instruments inside the page (a grand piano, mallets, strings, organ, synth presets), use modules.sound (Show control).
Checklist before a show#
- every continuous control is a param (try performing your world with ONLY the timeline)
-
dispose()releases GPU objects and event subscriptions (worlds get switched live) - world survives
dtspikes (the Loop clamps to 100ms, but don't assume 16ms) - nothing in the world reads the clock directly — the timeline owns time
- the page loads with zero console errors, and works with only the on-screen piano
Inputs#
Every input does one thing: publish signals. With createShow() you switch
inputs on in modules; each one is also a plain class you can use on its own.
The exact names and ranges are in the Signals reference.
| input | package | modules key | default | publishes |
|---|---|---|---|---|
| MIDI hardware | @openav/midi | midi | on (false turns it off) | midi/… |
| on-screen MIDI controllers | @openav/midi | midi: { controllers } | off | midi/<device>/<control> |
| on-screen piano, QWERTY, simulated performer | @openav/keys | keys | on (false removes it) | midi/note/on, midi/note/off |
| drum machine | @openav/drums | drums | off | drum/…, midi/note/on on channel 10 |
| microphone | @openav/audio | audio | off | audio/… |
| chord analysis | @openav/chord | chord | off | chord/… |
| body (camera) | @openav/pose | pose | off | pose/… |
| hands (camera) | @openav/pose | hands | off | hand/… |
| hands (Leap Motion) | @openav/leap | leap | off | leap/… |
| phones and iPads | @openav/remote | remote | off | phone/…, surface/…, midi/note/… |
Sources that need permission or a user gesture (microphone, camera) are created
by createShow() but only start when you click their button in the console's
L1 · Input panel: 🎤 mic, 🖐 hands, 🕺 body.
MIDI#
createShow() creates a Midi engine and calls enable() on startup, so the
browser may ask for MIDI permission when the page opens. Devices can be plugged
in and out during the show; the L1 · Input panel lists them, and unticking
one mutes it without unplugging it.
Every incoming message publishes two families of names:
- legacy (what most worlds use, any channel):
midi/note/onandmidi/note/offpulses,midi/cc/<n>(0..1),midi/bend(−1..1); - per channel:
midi/ch/<ch>/cc/<n>,midi/ch/<ch>/note/<n>(velocity 0..1 while held, 0 on release),midi/ch/<ch>/bend,midi/ch/<ch>/pressure,midi/ch/<ch>/poly/<n>.
A note-on payload is { note, vel, velocity, ch, device }: vel is 0..1,
velocity is the raw 1..127, ch is 1..16, device is the port's slug.
With two or more devices connected, each also publishes under its own slug
so their controls do not collide: midi/<slug>/cc/<n>, midi/<slug>/note/on,
midi/<slug>/note/off, midi/<slug>/bend. The slug comes from the port name
(Arturia MiniLab 3 → arturia-minilab-3; a second identical unit gets -2),
so a device that drops out and comes back keeps its routes. The legacy names
keep publishing too, so a route on midi/cc/74 hears every device.
Clock messages are ignored. Program change, transport and SysEx are not published (SysEx access is never requested).
Using it without createShow():
import { Midi } from '@openav/midi';
const midi = new Midi({ signals }); // options: signals, filterOut = 'IAC', requestAccess
await midi.enable(); // false if the browser has no Web MIDI or permission was denied
midi.devices(); // [{ slug, name, listening }]
midi.setListening('nanokontrol2', false);filterOut is a case-insensitive regular expression on input port names that
are ignored. The default, 'IAC', keeps the macOS IAC bus out, because a show
that sends MIDI out through IAC would otherwise hear itself. See
Troubleshooting if you
want to receive from IAC. MIDI output is described in
Show control.
Known controllers on screen#
modules: { midi: { controllers: { profile: '<profile id>' } } } adds an
on-screen picture of a known controller. Plug that controller in and the picture
switches to it and moves with your hands; without one, play the picture with a
mouse or a finger. Its controls publish midi/<device>/<control> names, and
controllerRoutes() writes the routes that connect them to params. The
MIDI controllers & embedding chapter covers profiles,
learning unknown devices, LED feedback and embedding; the supported devices are
on the controllers page.
On-screen piano, QWERTY and the simulated performer#
@openav/keys is on by default. It mounts a piano above the console with two
checkboxes, keyboard (QWERTY capture) and simulate performance. Options
(modules.keys): base (lowest note, default 48), octaves (default 2),
sim (show the simulate checkbox, default true), capture (show the keyboard
checkbox, default true).
It publishes midi/note/on { note, vel, ch: 0 } and midi/note/off
{ note, ch: 0 }. Note the differences from hardware: ch is 0 and there is
no velocity or device field. A world that only reads note and vel works
with both.
- QWERTY (after ticking keyboard):
A W S E D F T G Y H U J K O L P ;play 17 notes a semitone apart, starting atbase; Z/X move an octave (base stays between 24 and 84); Shift adds 20 to the velocity. While capture is on, the piano claims every letter key (preventDefault); Space, arrows and digits pass through to the console. - The simulated performer (
SimPlayer) plays a pentatonic scale an octave abovebase: mostly steps, sometimes leaps and triads, real rests. From code:show.keys.sim.toggle(true).
import { mountKeys, KeysPiano, SimPlayer, PIANO_KEYMAP } from '@openav/keys';
const keys = mountKeys(el, { signals, base: 48, octaves: 2 }); // → { piano, sim, update(dt), dispose() }
// call keys.update(dt) every frame (it drives the SimPlayer)Drum machine#
modules: { drums: true } adds a 16-step, four-lane sequencer (kick, snare, hat,
clap) with patterns (four on floor, breakbeat, half time, latin,
sparse), a tempo slider (60–180 BPM, default 112), swing and humanized timing.
Tick drum machine to start it. It plays its own synthesized kit (no samples)
and publishes:
drum/kick,drum/snare,drum/hat,drum/clap: pulses{ level };drum/<lane>/env: a 0..1 envelope per lane that decays in about 0.12 s, ready to map straight onto a param;midi/note/on{ note, vel, ch: 10 }with General MIDI notes (kick 36, snare 38, clap 39, closed hat 42).
The shapes match what the microphone's drum detection publishes (audio/kick,
audio/kick/env…), so a world mapped to a kick cannot tell a real bass drum from
the machine. Example 06 routes both to the same params. Options
(modules.drums): engine (another sound engine), autoEnableEngine (default
true: the kit's audio engine is switched on by the first hit, no extra click).
Microphone#
modules: { audio: true } creates an AudioAnalyzer; the microphone starts when
you click 🎤 mic. (Example 06 writes audio: 'mic'; any truthy value works.)
Echo cancellation, noise suppression and auto gain are turned off so the
analysis hears the room as it is.
Published every frame: audio/rms (smoothed loudness), audio/peak,
audio/band/low (20–250 Hz), audio/band/mid (250 Hz–2 kHz),
audio/band/high (2–8 kHz), audio/centroid (brightness), and the pulse
audio/onset { rms } with a 100 ms refractory time. Drum detection splits
transients by band: audio/kick (20–120 Hz), audio/snare (150–800 Hz),
audio/hat (6–14 kHz), each a pulse { level } with a matching …/env
envelope.
import { AudioAnalyzer } from '@openav/audio';
const audio = new AudioAnalyzer({ signals, fftSize: 2048, smooth: 0.7 });
await audio.enableMic(); // or audio.enableElement(videoEl), or someNode.connect(audio.input())
// every frame: audio.update() → also returns { rms, peak, low, mid, high, centroid, onset, kick, snare, hat }Chords#
modules: { chord: true } (or { chord: { window: 80 } }) adds a
ChordDetector and feeds it every midi/note/on and midi/note/off. It groups
notes struck within window milliseconds (default 80) into one gesture and
publishes, per gesture:
chord/consonance(−1..1),chord/count(number of notes),chord/root(lowest MIDI note);chord/event, a pulse carrying the whole analysis:
| field | meaning |
|---|---|
notes, count, root, vel, pcs | sorted notes, how many, the lowest, mean velocity (0..1), pitch classes |
consonance | mean of pairwise interval weights, −1..1 (a single note is 1) |
isConsonant, isDissonant | two or more notes and consonance > 0.25 / < −0.15 |
isTriad | the chord type is major, minor, sus2, sus4, dim or aug |
thirdsFraction | share of adjacent gaps that are 3 or 4 semitones ("genuinely stacked thirds") |
dissonanceLevel | 0 none · 1 mild (a storm warning) · 2 severe (a true cluster) |
chordType | single, major, minor, sus2, sus4, dim, aug, maj7, min7, dom7, halfdim7, maj9, min9, dom9, six, min6, cluster, or chord |
This is not a music-theory library; it is a performance-semantics layer built
for a piece where consonance makes a garden flourish and clusters make it decay
(example 02). detector.analyze(notes, vels) is a pure function you can call
yourself.
Body and hands#
modules: { pose: true } adds a PoseTracker (one body), modules: { hands: true }
a HandTracker (two hands, 21 points each). The camera starts when you click
🕺 body or 🖐 hands. Both use MediaPipe Tasks Vision (0.10.14), loaded from
jsDelivr with its model from Google's storage the first time you enable it; the
inference runs on your machine and no video leaves it. The view is mirrored by
default (mirror: true), like a mirror on stage, and y is flipped so 1 means
raised.
- Body:
pose/present,pose/hand/left/x|y,pose/hand/right/x|y(wrists),pose/hand/left/v,pose/hand/right/v(wrist speed),pose/hands/spread,pose/height(nose height: crouch ↔ stand),pose/lean(−1..1). - Hands: per side,
hand/<side>/present,hand/<side>/x|y(palm),hand/<side>/pinch/indexandhand/<side>/pinch/middle(thumb tip to finger tip),hand/<side>/spread(index tip to pinky tip). Distances are divided by palm size, so moving toward the camera does not change a pinch. A closed pinch reads near 0: useinvert: trueon the route when closing should raise the value (example 03).
Both trackers draw their skeleton on a 2D context: tracker.skeleton(ctx, w, h).
Leap Motion#
modules: { leap: true } adds a LeapInput. Browsers cannot read the sensor,
so one small bridge per machine does: node packages/leap/bridge/leap-bridge.mjs
(needs Ultraleap Hand Tracking installed; --mock gives two animated hands
without a sensor; bash packages/leap/bridge/install.sh keeps it running at
login on macOS). It serves ws://127.0.0.1:6437 on loopback only. The console's
L1 · Input panel shows which link is missing (bridge, tracking service,
device) and draws the hands; its simulate button turns the mouse into hands.
Signals: per side, leap/hand/<side>/present, x|y|z (palm in the interaction
box, 0..1, y up, z toward you), pinch, grab, roll|pitch|yaw (radians),
speed, fingertips leap/hand/<side>/<finger>/x|y|z; leap/hands,
leap/both/distance, leap/status; pulses pinch-start|pinch-end,
grab-start|grab-end per side and leap/both/pinch-start|pinch-end, with
hysteresis (a pinch starts above 0.86 and ends below 0.5). Joints for drawing
the hand are on leap.hands. The same bridge answers old leap.js sketches on
/v6.json, and serves raw hand lines on /raw (example 12, package README).
Phones and iPads#
modules: { remote: true } lets phones join the show over the relay that
serve.js hosts. Their sensors publish phone/<id>/… (tilt, acceleration,
rotation, knocks, touch, light), mirrored to phone/any/…; the control surface
publishes surface/<page>/<widget>; the phone's piano publishes
midi/note/on|off like a keyboard. The show page itself can read its own motion
sensors on a phone with localSensors(). Everything is in
Phones, relay & surfaces.
Your own input#
An input is anything that calls signals.set() or signals.pulse(). Here is
the pointer as an instrument:
const { signals } = show;
signals.define('pointer/x', { source: 'pointer' }); // continuous, 0..1 by default
signals.define('pointer/y', { source: 'pointer' });
signals.define('pointer/tap', { kind: 'pulse', source: 'pointer' });
addEventListener('pointermove', (e) => {
signals.set('pointer/x', e.clientX / innerWidth);
signals.set('pointer/y', 1 - e.clientY / innerHeight); // 1 = top, like every other y
});
addEventListener('pointerdown', (e) => signals.pulse('pointer/tap', { x: e.clientX, y: e.clientY }));
show.mapper.addRoute({ source: 'pointer/x', target: 'hue', smooth: 0.25 });Name it source first (breath/pressure, weather/wind), declare its range,
and everything downstream (meters, learn, routes, the phone surface) works
without changes.
Mapping#
The mapping layer is where "what happened" becomes "what it means". The Mapper
(@openav/mapping) is the only component that connects signals to params, and
everything it knows is a list of routes.
Routes#
A route is a plain object. Only source and target are required; addRoute()
fills in the rest:
| field | default | meaning |
|---|---|---|
source | — | signal name, e.g. midi/cc/74, hand/right/pinch/index, phone/any/tilt/x |
target | — | param key, e.g. bloom, sound/cutoff |
inMin, inMax | 0, 1 | the window of the signal that is used; values outside it are clamped |
outMin, outMax | the param's min, max | the range written into the param |
curve | 'linear' | linear, exp, log or smooth |
invert | false | flip the normalized value (1 − x) before the curve |
smooth | 0 | glide time in seconds (0 = instant) |
enabled | (absent = on) | false mutes the route |
id | assigned | a number, used to remove the route |
show.mapper.addRoute({ source: 'midi/cc/74', target: 'bloom' });
show.mapper.addRoute({ source: 'phone/any/tilt/x', target: 'drift', inMin: -1, inMax: 1, smooth: 0.12 });
show.mapper.addRoute({ source: 'hand/right/pinch/index', target: 'tightness', curve: 'smooth', invert: true, smooth: 0.1 });In createShow(), the same objects go in routes: [...].
What happens to a value#
When a signal arrives, every enabled route whose source matches runs these
steps, in order:
- Window:
x = (value − inMin) / (inMax − inMin), clamped to 0..1. - Invert: if
invert,x = 1 − x. - Curve:
x = curve(x). - Scale:
out = outMin + (outMax − outMin) · x. - Write: with
smooth: 0, the param is overridden without(and clamped and snapped to itsstep). Withsmooth > 0,outbecomes a target thatmapper.update(dt)glides toward every frame.
Curves#
| curve | formula | feel |
|---|---|---|
linear | x | as the hand moves |
exp | x² | slow start, fast end: fine control near the bottom |
log | √x | fast start, slow end: fine control near the top |
smooth | x²(3 − 2x) | gentle at both ends (smoothstep) |
An unknown curve name falls back to linear.
Windows and ranges#
inMin/inMax say which part of the signal matters; outMin/outMax say
where it lands. Typical uses:
- a bipolar signal:
{ source: 'midi/bend', inMin: -1, inMax: 1 }, or a phone tilt{ source: 'phone/any/tilt/x', inMin: -1, inMax: 1 }; - a quiet signal:
{ source: 'audio/rms', inMax: 0.3 }uses the whole param range for the levels a voice actually reaches; - a knob that only sweeps part of a param:
{ source: 'midi/cc/1', target: 'hue', outMin: 180, outMax: 270 }; - an inverted relation without
invert:outMin: 1, outMax: 0.
Smoothing#
smooth is a time constant in seconds: after smooth seconds the param has
covered about 63% of the distance to the new value (a one-pole filter,
k = 1 − e^(−dt/smooth) per frame). 0.05–0.15 hides the steps of a MIDI knob or
a 30 Hz phone stream; 0.2–0.5 makes a hand feel heavy. The glide starts from the
param's current value, so the first movement never jumps.
Pulse targets#
When the target is a pulse param, the route fires it instead of writing a
value:
- a pulse source (
midi/note/on,audio/kick,drum/kick,phone/any/knock…) fires it on every event; - a continuous source fires it when the normalized value crosses 0.5 upward (a phone-surface button going from 0 to 1, a fader pushed past the middle).
curve, invert, smooth and the output range do not apply to pulse targets.
Many-to-many#
One signal may drive many params (add several routes with the same source),
and one param may listen to many signals. When two routes write the same param,
the last one to write wins. Learn adds a route; it never replaces one.
The patch bay design plans per-route combine
(last, max, add, avg) and label fields; they are not implemented yet.
Overrides and the timeline#
A route writes its param as an override, and overrides are sticky: as soon
as a mapped signal moves, the timeline stops driving that param. Clearing the
override (✕ next to the param, or show.params.clearOverride(key)) hands it back
to the timeline until the signal moves again.
A smoothed route keeps writing its last target every frame once its signal has arrived, so its param stays overridden even after ✕ and even when the route is muted. Remove the route (✕ in L2 · Mapping) to give the param back to the timeline for good.
Learn#
Click learn next to a param in L3 · Params (or call
mapper.learn('bloom')), then move something. The mapper binds the first signal
that:
- moves by at least 5% of its declared range from where it was when learn started (a knob, a fader, a hand, a phone tilt), or
- fires, if it is a pulse (a key, a pad, a knock).
The new route has the defaults: linear, no smoothing, the param's full range. Edit it afterwards if it needs a curve. Clicking learn again cancels. Learn adds: a param that already has a route keeps it and gets a second one.
To bind a whole list, the L2 · Mapping panel has 🎛 map knobs in order: it
arms learn for the first param, you turn one knob, it is bound and the next param is
armed. skip, back, stop (or Esc) steer it. In code it is LearnWizard
(details).
When one MIDI message publishes several names (midi/cc/74,
midi/ch/1/cc/74, and with two devices midi/<slug>/cc/74), learn binds the
first one, midi/cc/74, which hears every device. Change the source to the
per-device name if you need one device only.
Muting and removing#
The L2 · Mapping panel lists every route as source → target with a
checkbox (mute) and ✕ (remove). In code:
const r = show.mapper.routesFor('bloom')[0];
r.enabled = false; // mute
show.mapper.removeRoute(r.id); // removeSaved profiles#
The mapper saves its routes to localStorage under openav.map.<profile>.
createShow() uses the world's name as the profile (or the profile option) and:
- loads the saved routes if there are any, otherwise adds the declared
routes; - when a saved profile exists, still adds every declared route whose
source → targetpair is missing, so new routes in code arrive; - saves every 3 seconds.
So learned routes and edits survive a reload. The flip side: if you change the
curve or smoothing of a declared route in code, the saved copy of that
source → target pair wins. To start clean, pass profile: false (routes come
from code on every load, as in example 09) or remove the saved profile:
localStorage.removeItem('openav.map.particles'); // 'particles' = the world's nameTo carry a wiring to another machine or another world, use a map file (next section).
Map files: export, import and share#
The L2 · Mapping panel has three buttons under map knobs in order:
- ⬇ export downloads the routes as
oav-map-<world>.json. - ⬆ import picks a map file. You can also drop a
.jsonanywhere on the page. Import replaces the routes; the new set is remembered at once. - clear removes every route (click twice). On the next load the world's declared routes come back, as on a first visit.
Below the buttons the panel says what the last import did: how many routes came in, and every route it skipped and why. A file that cannot be read says so and changes nothing.
The format#
{
"format": "openav.map",
"version": 1,
"world": "particles",
"exported": "2026-10-15T08:30:00.000Z",
"routes": [
{ "signal": "midi/cc/74", "param": "bloom", "curve": "exp",
"range": { "in": [0, 1], "out": [0, 100] }, "smooth": 0.1, "invert": false },
{ "signal": "audio/rms", "param": "energy", "curve": "linear",
"range": { "in": [0, 0.3], "out": [0, 1] }, "smooth": 0, "invert": false, "enabled": false }
]
}Routes are named by signal and param, never by id, so a file travels between worlds:
- a route whose param this world does not have is skipped and listed;
- a route whose signal nothing in this show can produce (say
leap/palm/xwithout a Leap Motion module) is skipped and listed. Signals that only appear when the first message arrives (midi/…,surface/…,phone/…,osc/…,key…,take/…) are kept and counted as waiting; - when the file's
worldis a different one, therange.outis dropped so each param keeps its own range (theinwindow, curve, smoothing and invert come along); versionnewer than this build reads is refused with a message. A bare array frommapper.toJSON()(the old way) still imports, as version 0.- Files over 256 KB or with over 500 routes are refused.
Load on open: ?map=#
https://openaudiovisual.com/examples/01-hello-particles/?map=https://example.com/my-map.json
https://openaudiovisual.com/examples/01-hello-particles/?map=maps/stage-left.json?map=<url> fetches the file when the page opens and loads it like an import. It accepts a
same-origin address (relative paths included) or an absolute https one; http: to another
host, data:, javascript:, file: and addresses with a login are refused. A cross-site file
must be served with CORS (Access-Control-Allow-Origin: *; raw GitHub and gists do this).
The show starts regardless: if the address is refused, the fetch fails or the file is bad, the
panel shows the reason and the routes stay as they were. An explicit ?map= beats the remembered
wiring, and what it loads is remembered like any import. In code, createShow({ map: 'maps/a.json' })
does the same and map: false ignores ?map=.
Remembered for you#
The mapper saves its routes to localStorage (openav.map.<world>) every 3 seconds and right
after an import or clear, one slot per world name, so the next visit starts where you stopped.
Every read and write is wrapped: in a private window or with storage blocked nothing breaks,
the wiring just is not remembered.
In code:
import { loadMapUrl, parseMap } from '@openav/mapping';
const file = show.mapper.exportMap(); // the object above
const report = show.mapper.importMap(text); // text or object; { mode: 'add' } keeps current routes
// → { ok, added, skipped: [{ signal, param, reason: 'param' | 'signal' }], waiting, dropped, crossWorld }
await loadMapUrl(show.mapper, 'maps/a.json'); // what ?map= calls; never throws
parseMap(text); // validate without touching a mapperRoutes written for you#
Several packages generate routes in this same shape:
autoSurface(world.params)(@openav/surface) builds a phone control panel and thesurface/<page>/<widget> → paramroutes for it, withsmooth: 0.04;routesFromLayout(layout, params)does the same for a hand-written layout (Phones, relay & surfaces).controllerRoutes(profile, { knob1: 'hue', pad1: 'burst' })(@openav/midi) writes routes from an on-screen controller's controls (MIDI controllers & embedding).
Mapper API#
import { Mapper } from '@openav/mapping';
const mapper = new Mapper({ signals, params, profile: 'default', onChange, onLearn });
mapper.addRoute(route) // → the stored route (defaults filled in, id assigned)
mapper.removeRoute(id)
mapper.routesFor('bloom') // → routes targeting a param
mapper.routes // the live array
mapper.learn('bloom') // arm / cancel learn; mapper.learnTarget is the armed key
mapper.update(dt) // every frame: advances smoothed routes
mapper.toJSON(); mapper.fromJSON(array)
mapper.exportMap(); mapper.importMap(text|object, { mode }) // the portable file, see above
mapper.clear() // remove every route (and forget them)
mapper.report // what the last import / ?map= said
mapper.save(); mapper.load() // localStorage 'openav.map.<profile>'; load() → true if found
mapper.dispose() // stop listening to signalsonChange() runs when routes change (for UIs); onLearn(route, signalName) runs
when learn binds.
Show control#
The parts that turn a sketch into a performance: the one-call assembly, the timeline, the console, performance mode, the backstage monitor, and the ways sound and data leave the browser.
createShow#
createShow() (@openav/show) builds a whole show around a world and returns
every part. It is async because it waits for the world's init.
import { createShow } from '@openav/show';
const show = await createShow({
world: myWorld,
timeline: { total: 120, automation: { hue: [[0, 205], [60, 320]] }, scenes },
routes: [{ source: 'audio/rms', target: 'energy', inMax: 0.3, smooth: 0.1 }],
modules: { keys: { base: 48 }, chord: true, audio: true, sound: true },
artwork: { title: 'Untitled', artist: 'You', year: 2026 },
hint: 'play the piano · Space plays the timeline',
});| option | default | meaning |
|---|---|---|
world | null | the world to perform; it is activated first |
worlds | [] | more worlds to register (switch with show.stage.activate(name)) |
timeline | { total: 120 } | { total, automation, scenes } for the timeline |
routes | [] | routes to add to the mapper (Mapping) |
modules | {} | inputs and outputs to switch on (below) |
artwork | null | { title, artist, year, note }: a credit line on the stage and a <meta name="artwork"> |
hint | '' | a line of HTML at the bottom left of the stage |
map | ?map= in the page address | a map file to load on start: a same-origin or https URL; false ignores ?map= (Map files) |
profile | the world's name | the mapper's saved-profile name; false = ignore saved routes (Saved profiles) |
onFrame | null | (dt, show) => {} every frame, before the timeline advances |
mount | generated | { stage, side } elements or selectors to render into, instead of a full-page layout |
telemetry | true | one anonymous oav_show_start hit per page (see the controllers chapter, Telemetry); false sends nothing |
score | null | { cuts, cut, modules, api, midi, … }: the show's structure as segment lengths, performed by segment modules; ?cut=<id> in the URL picks the version (Score & Director) |
Without mount, the page body becomes a two-column grid: the stage, and a
340 px side panel with the piano and the console.
modules key | values | what it adds |
|---|---|---|
midi | on by default; false | the Midi engine (input and output) |
midi.controllers | true or { profile, profiles, open } | an on-screen MIDI controller docked on the stage, a 🎹 button and the M key; ?profile=<id> in the URL picks the device (controllers) |
keys | on by default; false; { base, octaves, sim, capture } | the on-screen piano, QWERTY and the simulated performer |
drums | true; { engine, autoEnableEngine } | the drum machine |
audio | any truthy value | the microphone analyzer (starts from 🎤 mic) |
chord | true; { window } | chord analysis of every played note |
hands | true | the hand tracker (starts from 🖐 hands) |
pose | true | the body tracker (starts from 🕺 body) |
remote | true; { room, auto, surface, feedbackHz, url } | phones and iPads as controllers (Phones) |
sound | true; an instrument id ('piano'); { instrument, remember, picker, engine, baseUrl } | instruments in the page with a picker, Tone.js (Sound) |
It returns { signals, params, stage, timeline, mapper, midi, controllers, midiPanel, keys, drums, sound, audio, hands, pose, chord, remote, score, director, console, app, loop }; parts you did not ask for are null. The same object is
window.openav, for the devtools console. The frame order is in
Architecture.
Timeline#
const timeline = new Timeline({
params,
total: 2400, // seconds (default 600 when used directly; createShow uses 120)
automation: {
growth: [[0, 0.2], [300, 1.0, 'ease'], [1200, 0.4, 'out']], // [t, value, curve?] keyframes; no curve = linear
mode: [[0, 0], [600, 1], [1800, 2]], // step params hold (no interp)
},
scenes: [
{ id: 'standby', t: -30, title: 'Standby', note: 'house lights · breathe' },
{ id: 'act1', t: 0, title: 'Act I', note: 'single seeds only' },
{ id: 'act2', t: 600, title: 'Act II', note: 'full harmony' },
],
});
timeline.onSceneChange((i, scene) => { /* fire cues: sound, lights, OSC */ });- negative scene times = pre-show standby (the clock shows -0:30)
jumpScene(±1)(← → keys) is your emergency navigation — rehearse with ittimeline.rateexists for rehearsal speed-through (rate = 4)- playback stops by itself at
total - a scene with
hold: truestops playback at its end until it is released (Space, →): see Hold loop: truewraps the show attotaland plays on;loop: 'scene'repeats the scene the playhead is in until you move on: see Curves, loop, cues, recordingreset()goes tostart(default 0, so a pre-show standby scene at −30 is reached with the scrubber; passstart: -30, orstart: 'first'for "the first scene", to make R return to it)- with
scorethe scenes, total and start come from a score; then automation is read atscore.baseTime(t), so a cut derived withscale: 0.5stretches it
| member | what it does |
|---|---|
play(), pause(), toggle() | transport. At a hold, play() and toggle() release it |
seek(t) | jump to t seconds (clamped between the first scene's negative time, or 0, and total) |
jumpScene(d) | go to the start of the scene d steps away |
next(), prev() | → and ←: next() releases a hold (and plays on), otherwise it is jumpScene(1) |
release() | let a hold go: the next scene starts and playback continues. false when nothing was holding |
reset() | stop, clear a hold and rewind to start (default 0) |
advance(dt) | per frame; moves t by dt × rate while playing |
state(t), valueAt(key, t) | the automation values at a time (no overrides) |
sceneIndexAt(t), currentScene(t), sceneEnd(i) | scene lookup |
onSceneChange(cb) | cb(index, scene, { cause }) whenever playback or a seek lands in a different scene; cause is 'play', 'release', 'seek', 'jump', 'reset' or 'loop' |
onCue(cb), cues(), fireCue(cue) | scene cues fired by playback, the list of them, and the manual trigger |
loop, onLoop(cb), loopRange(), loops | wrap the show, a scene or a range |
record(key, source), stopRecording(), onRecord(cb), mergeTake(key, take) | write a value source into a lane |
onSeek(cb) | cb(t, kind) when the playhead is moved by hand ('seek', 'jump', 'reset'), never while playing |
layer | (key, value, t) => value: rewrite a param before overrides (the director's param() plugs in here) |
t, playing, holding, rate, total, start, scenes, automation, score | state you can read (and set) |
Curves, loop, cues, recording#
Curves. A keyframe is [t, value, curve?]. The third item says how the segment that ends at that keyframe gets
there: 'linear' (the default: leave it out and you get what the timeline always did), 'ease' (slow, fast, slow),
'in' (starts slow), 'out' (starts fast), 'step' (hold the old value until the keyframe, then jump), or your own
function of 0..1. CURVES is exported. A param with step still holds, whatever the curve says.
automation: { speed: [[0, 0.3], [16, 1.6, 'in'], [20, 3.4, 'step'], [30, 0.8, 'out']] }Loop. loop: true wraps at total (back to 0) and keeps playing; 'scene' repeats the scene the playhead is in
(next() moves the loop on to the next scene); a scene id loops that scene; { from, to } loops a range in seconds or
scene ids. It is a property: timeline.loop = !timeline.loop is the whole L key. onLoop(cb) tells you each lap, and a
loop that wraps into another scene is an onSceneChange with cause: 'loop'. A hold inside the range still holds.
Cues. scene.cues is [{ name, t }] (the console scrubber already draws them as ticks). onCue((cue, { cause }) => …)
fires one when playback passes it, once, and never when you seek, jump, drag the scrubber or reset: a scrub is not a
performance. Each lap of a loop fires them again. fireCue(cue) sets one off by hand. With a score the director
fires its own cues; use onCue on plain timelines.
show.timeline.onCue((cue) => show.params.firePulse(cue.name)); // scenes: [{ …, cues: [{ name: 'flash', t: 20 }] }]Recording. record(key, source) arms a lane. While the timeline plays, source() is read every frame: a number is
written into a take, null means nobody is touching and closes it.
timeline.record('hue', () => params.isOverridden('hue') ? params.overrides.hue : null);
timeline.onRecord(({ key }) => params.clearOverride(key)); // the curve takes over againA take also closes on pause, seek, reset, a hold, the end of the show and the end of each loop lap, while the recorder
stays armed until stop() / stopRecording(). The merge rule (mergeTake()): the take is thinned (a point stays when
it moved more than 0.6 % of the param's range, or 0.5 s passed), and it replaces the keyframes inside its time span.
Keyframes outside stay, so the lane glides from the previous keyframe to the take's first point, and from its last point
to the next keyframe. Existing curves outside the take are untouched. With a score the keyframes are written in the base
cut's time, like the ones you declared.
A cue list is just a scene-change handler:
show.timeline.onSceneChange((i, scene) => {
if (scene.id === 'storm') show.stage.activate('storm'); // switch worlds
show.midi?.cc(20, i, 16); // tell the lighting desk
});The console#
mountConsole(el, app, { layers, signals }) (@openav/console) builds the
director's desk; createShow() mounts it in the side panel. app is the object
createShow() returns as show.app (timeline, params, mapper, signals,
stage, midi, sound, audio, hands, pose, …); missing parts simply
hide their section. Pass { layers: false } or { signals: false } to leave out
those panels. It gives you transport + scrubber with scene blocks, the Layers
overview, L1 Input, L2 Mapping, L3 Params (sliders with override and learn
chips), L4 sound, live signal meters and a MIDI log.
Keyboard: Space play/pause (at a hold: release) · →/← next / previous scene (→ at a hold: release) · R reset · T performance mode · F fullscreen · Esc leave keyboard-piano capture or stop the mapping wizard. Keys typed into an input field, held keys and ⌘/Ctrl/Alt combinations are ignored. While the on-screen piano captures the keyboard the letters belong to the piano (T and F are piano keys), so R, T and F do nothing until you untick it or press Esc; Space and the arrows always work.
With a score the desk also has the Director panel, cue ticks on the scrubber and a prompter with the operator's acts (Score & Director). The L2 Mapping panel has 🎛 map knobs in order, a wizard that arms learn for each param in turn (details).
Override semantics: touch a slider (or a mapped control) and that param leaves the timeline's hands until you clear it (✕). Yellow label = overridden.
Performance mode#
T flips to a fullscreen teleprompter: current scene title + note in stage-
readable type, what the operator does (the scene's acts), next scene preview, clock + scene countdown, a HOLD banner while the show waits, and a warning when a segment module was switched off. This is for the
performer; the audience-facing window is your stage in fullscreen (F).
Two windows of the same page = one desk, one stage.
Backstage monitor#
node packages/monitor/server.js # on the performance machine (npm run monitor)
# stage manager's phone/laptop: http://<performance-machine-ip>:7457/Every show page made with createShow() streams snapshots to
ws://<the page's host>:7457 about 15 times a second, so run the monitor
server on the machine that serves the show. Backstage shows clock, scene,
world, FPS, overrides, all signals and params live, and a red "stage feed
lost" banner if the stage stops sending for 4 s — which is exactly the moment a
stage manager earns their pay. Zero configuration, zero dependencies; both ends
reconnect by themselves. GET /health answers { ok, clients }.
When no monitor is running, the show page keeps retrying with a growing delay (about 5 s, growing to 30 s); the browser logs each refused connection. That message is expected.
For a hand-assembled show:
import { MonitorFeed, snapshotOf } from '@openav/monitor';
const monitor = new MonitorFeed({ url: 'ws://192.168.1.20:7457', hz: 15 }); // both optional
monitor.connect();
// every frame:
monitor.frame(snapshotOf({ timeline, params, signals, stage, loop }, state));OSC output#
node packages/osc/bridges/osc-bridge.js 7456 192.168.1.50 3456
# http port ↑ target host ↑ udp port ↑The defaults are 7456 127.0.0.1 3456. The browser cannot send UDP, so
OscOut posts batches to the bridge over HTTP and the bridge sends them as OSC
packets.
import { OscOut } from '@openav/osc';
const osc = new OscOut(); // http://127.0.0.1:7456
await osc.enable(); // probes /health, fails gracefully (false)
osc.send('/source/3/xyz', [x, y, z]);
osc.flush(); // once per frame (batches, dedupes per address)Numbers go out as OSC floats — what Spat, Reaper, and TouchDesigner expect;
anything else is sent as a string. Only the latest message per address survives
a frame. If the bridge disappears, OscOut reports it through onStatus(ok, info) and stops trying after 30 failed flushes.
createShow() does not create an OscOut. Add one yourself (and add
"@openav/osc": "../../packages/osc/index.js?v=b59edc6" to the page's import map):
const osc = new OscOut();
await osc.enable();
const show = await createShow({ world, onFrame: () => osc.flush() });
show.app.osc = osc; // the Layers panel shows the OSC rate
show.stage.io.osc = osc; // worlds can send from update(dt, state, io)TouchDesigner as the receiver#
OSC goes to TouchDesigner as above. TouchDesigner can also show an open-audiovisual page as a picture with its Web Render TOP, which draws a web page inside TouchDesigner on the same machine (Web Render TOP). We have not checked whether the Web Render TOP supports Web MIDI or WebGPU. Keep the control on OSC.
If the page comes from openaudiovisual.com, Chrome asks before the page may reach the bridge on 127.0.0.1: see Chrome asks about the local network (Chrome blog). Running node serve.js and opening the page from http://localhost:8080 avoids the prompt.
MIDI output and panic#
midi.noteOn/noteOff/cc/send go to the selected output port — and midi.panic() sends all-notes-off +
all-sound-off + sustain-off + per-note note-offs on all 16 channels, through the
same observable throat as everything else. Bind it to a pad you can find in the
dark. Every performer eventually needs it; ours is one call.
show.midi.outputs.map((o) => o.name); // available output ports
show.midi.selectOutput('IAC Driver Bus 1');
show.midi.noteOn(60, 100, 1); // note, velocity 0..127, channel 1..16
show.midi.noteOff(60, 1);
show.midi.cc(74, 64, 1); // controller, value 0..127, channel
show.midi.send([0xb0, 74, 64]); // raw bytes
show.midi.panic();The first output port is used until you choose another (enable(preferredOut)
matches a name exactly, then as a substring). Everything sent passes through
onSend, which feeds the per-channel MIDI OUT meter in the Layers panel.
Hot-plug. A cable pulled mid-show must not leave the DAW silent without a word. When the current output is unplugged, Midi moves to the next best live port: the one you chose with selectOutput() or enable(name), then the first match of the prefer list (new Midi({ prefer: ['IAC'] })), then the first port. When the port you chose is plugged in again, the output goes back to it. An unrelated device appearing never steals the output. Every switch prints one line in the MIDI log (the Signals panel, via midi.onMessage) and one in the browser console, flashes in the MIDI controller panel, and updates the MIDI out → row of the L1 Input panel. In code: midi.onOutput(({ text, reason }) => …).
Echo. What you send into a virtual bus must not come back as input. Inputs whose name matches filterOut (default IAC, the macOS bus) are never listened to, and a message identical to one just sent that arrives on any input within echoWindow milliseconds (default 40; 0 turns it off) is dropped as echo, once per message sent. midi.echoDropped counts them.
To make an external program follow the show, ScoreMidi sends the score's segment changes and cues as notes and CCs (Score to MIDI).
Sound#
modules: { sound: true } adds Sound (@openav/sound) with the Tone.js
engine. Browsers only start audio after a click, so the console shows
🔊 enable sound in the L4 · Output — sound panel, with an instrument
picker under it. Once enabled, every midi/note/on and midi/note/off plays
the chosen instrument, whatever sent it (a keyboard, the QWERTY piano, the
simulated performer, a phone). Tone.js (15.0.4) is loaded from jsDelivr only
when sound is enabled.
await createShow({ world, modules: { sound: true } }); // the warm pad, as before
await createShow({ world, modules: { sound: 'piano' } }); // start on the grand piano
await createShow({ world, modules: { sound: { instrument: 'synth/pluck', remember: false, picker: false } } });Instruments#
| id | sound | made of |
|---|---|---|
piano | grand piano | the Salamander Grand Piano: 30 samples (A, C, D♯, F♯ of every octave), 2.0 MB, loaded when picked |
epiano | electric piano | two FM pairs and a stereo tremolo |
xylophone, marimba, vibraphone, glockenspiel, music-box | mallets | modal synthesis: a few decaying partials at each bar's own ratios, plus a mallet click |
strings | string ensemble | detuned saws, bowed attack, chorus, vibrato |
harp | plucked string | Karplus–Strong |
organ | drawbar organ | nine drawbars as one additive waveform, rotary tremolo |
choir | choir pad | saws through vowel formants |
synth/pad | warm pad | the original voice, still the default |
synth/saw-lead, synth/square-lead, synth/pluck, synth/supersaw, synth/bass, synth/acid, synth/brass, synth/bell, synth/sub, synth/chip | synth presets | one poly synth, presets as data |
The piano samples are Salamander Grand Piano by Alexander Holm, licensed
CC-BY 3.0. The picker shows that
credit next to the piano, and a show that plays it in public credits it too.
They ship in packages/sound/samples/salamander/. If they cannot load, the
piano plays the electric piano instead of going silent.
Choosing a sound#
Pick from the console, or from code:
await show.sound.setInstrument('organ'); // true once it plays
show.sound.instrument; // 'organ'
show.sound.onChange((e) => console.log(e.type, e.id, e.progress)); // instrument · loading · ready · error
show.timeline.onSceneChange((i, scene) => {
if (scene.id === 'chorale') show.sound.setInstrument('organ'); // a cue may switch instruments
});Switching while you play releases the notes held on the old instrument, so
nothing hangs. A sampler keeps the old sound playing until its files are in,
and the picker shows the progress. Each switch also fires the pulse signal
sound/instrument ({ id, name }). The picker's last choice is remembered per
page and wins over the declared instrument on the next visit;
remember: false turns that off.
The instrument is a discrete choice. What you perform continuously stays a
param: the engine's params join the show's params under sound/.
sound/cutoff (100–8000 Hz, default 2500), sound/space (reverb wet, 0–1,
default 0.3) and sound/volume (−36–0 dB, default −8) are shared by every
instrument, so a knob, a hand or the score plays the filter the way it plays
the visuals. An instrument may suggest starting values (the piano opens the
filter to 8000 Hz) and add knobs of its own, which the console shows while it
plays: the organ's sound/drawbars and sound/rotor, sound/attack and
sound/vibrato on the strings, sound/vowel on the choir, sound/tremolo on
the electric piano and the vibraphone. Automation and overrides always win over
a suggested default. The sustain pedal (midi/cc/64) holds every instrument's
note-offs. Notes on MIDI channel 10 (the drum machine) are left to a drum engine.
Add your own instrument#
A sound you can pick is one file: data plus a create(Tone, ctx) that returns
noteOn, noteOff and dispose. Import it before createShow() and every
picker lists it; nothing in packages/* changes.
import { registerInstrument } from '@openav/sound';
registerInstrument({
id: 'kalimba',
name: { en: 'Kalimba', zh: '拇指琴' },
category: 'mallets',
params: [{ key: 'decay', label: 'Decay (s)', min: 0.2, max: 4, def: 1.2 }], // → sound/decay
create(Tone, { output }) {
const synth = new Tone.PolySynth(Tone.FMSynth, { harmonicity: 5.1, modulationIndex: 2,
envelope: { attack: 0.001, decay: 1.2, sustain: 0, release: 1 } }).connect(output);
const hz = (n) => 440 * 2 ** ((n - 69) / 12);
return {
noteOn: (note, vel, time) => synth.triggerAttack(hz(note), time, vel),
noteOff: (note, time) => synth.triggerRelease(hz(note), time),
set: (key, v) => key === 'decay' && synth.set({ envelope: { decay: v } }),
dispose: () => synth.dispose(),
};
},
});A duplicate id throws unless you pass registerInstrument(def, { replace: true }).
Optional fields: defaults (starting cutoff, space, volume), credit,
gain (dB), transpose (semitones), fallback (an id to play if loading fails)
and, on the returned object, ready (a Promise: the picker shows progress while
you call ctx.onProgress(0..1)), set(key, value) and releaseAll(time).
Mallet sounds can be pure data with modalInstrument({ id, name, partials, strike }).
A synth preset is data as well, and becomes synth/<id>:
import { registerSynthPreset } from '@openav/sound';
registerSynthPreset({
id: 'glass', name: { en: 'Glass', zh: '玻璃' },
voice: 'fm', harmonicity: 3.01, modulationIndex: 14,
envelope: { attack: 0.002, decay: 1.4, sustain: 0, release: 1.2 },
effects: [{ type: 'chorus', frequency: 1.5, depth: 0.4, wet: 0.3 }],
});Your own engine#
An engine is a small object, so you can replace the whole thing:
const sineEngine = () => {
let ctx = null, out = null;
const voices = new Map();
return {
params: [{ key: 'gain', label: 'Gain', min: 0, max: 1, def: 0.3 }], // → sound/gain
async enable() {
ctx = new AudioContext();
out = ctx.createGain(); out.gain.value = 0.3; out.connect(ctx.destination);
},
noteOn(note, vel) {
voices.get(note)?.stop();
const o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.value = 440 * 2 ** ((note - 69) / 12);
g.gain.value = vel * 0.3;
o.connect(g).connect(out); o.start();
voices.set(note, o);
},
noteOff(note) { voices.get(note)?.stop(); voices.delete(note); },
set(key, value) { if (key === 'gain' && out) out.gain.value = value; },
dispose() { ctx?.close(); },
};
};
await createShow({ world, modules: { sound: { engine: sineEngine() } } });The drum kit from @openav/drums is also an engine: drumEngine({ samples })
maps General MIDI drum notes to synthesized drums (or to your samples,
{ 36: 'kick.wav', … }) and has one param, kitVolume (−30–0 dB).
Without createShow#
createShow() is a convenience. When you need your own layout or loop, assemble
the parts by hand; this is roughly what it does:
import { Signals, Params, Loop } from '@openav/core';
import { Midi } from '@openav/midi';
import { Mapper } from '@openav/mapping';
import { Timeline } from '@openav/timeline';
import { Stage } from '@openav/stage';
import { mountConsole } from '@openav/console';
import { MonitorFeed, snapshotOf } from '@openav/monitor';
const signals = new Signals();
const params = new Params();
const stage = new Stage({ container: document.querySelector('#stage'), params, signals });
stage.register(myWorld);
await stage.activate(myWorld.name);
const timeline = new Timeline({ params, total: 120, automation, scenes });
const mapper = new Mapper({ signals, params, profile: myWorld.name });
if (!mapper.load()) routes.forEach((r) => mapper.addRoute(r));
const midi = new Midi({ signals });
midi.enable();
// mount the console after every world (and sound engine) is registered: it builds one row per param
const app = { timeline, params, mapper, signals, midi, stage };
const desk = mountConsole(document.querySelector('#desk'), app);
const monitor = new MonitorFeed();
monitor.connect();
const loop = app.loop = new Loop((dt) => {
timeline.advance(dt);
mapper.update(dt);
const state = stage.frame(dt, timeline.state());
desk.render(state);
monitor.frame(snapshotOf({ timeline, params, signals, stage, loop }, state));
});
loop.start();Pre-show checklist#
- Chrome, plugged in, screen-sleep off, notifications off (
chrome://settings) - WebMIDI permission granted for this origin (it's per-origin, per-port)
- mapping profile saved (mapper.save()) AND exported to a JSON file
- monitor server running; stage manager's device on the same network
- sound enabled with a click, and the panic pad mapped
- rehearse the failure: kill the tab, restart, be performing again in <30 s
Score & Director#
A show that outgrows one afternoon needs a structure that can grow. The score (@openav/score) writes a show down as segments with lengths, and the director runs the code that performs each segment. They are pure logic with no DOM, and createShow() wires them to the timeline, the console and the prompter for you.
The idea: lengths, never seconds#
A timeline written as a table of absolute seconds cannot grow. Make the second scene longer and every later scene, cue, curve and if (t > 466) in your code is now wrong, and you find them one rehearsal at a time. In a score you write only how long each segment is:
const cuts = [
{ id: 'full', title: 'A day', segments: [
{ id: 'standby', title: 'Standby', dur: 6, hold: true, acts: ['Check sound', 'Press Space when the room is ready'] },
{ id: 'dawn', title: 'Dawn', dur: 24, cues: { birds: 0.3 } },
{ id: 'noon', title: 'Noon', dur: 30, cues: { flare: { s: 8 }, peak: 0.5 }, acts: ['Open the haze slowly'] },
{ id: 'dusk', title: 'Dusk', dur: 24, linger: 8 },
{ id: 'night', title: 'Night', dur: 36 },
] },
{ id: 'short', from: 'full', scale: 0.5 }, // the same show at half the length
];Every start, end, cue time, countdown and scene is computed from those lengths. Change noon to dur: 40 and everything after it moves by ten seconds while everything before it stays put. A second version of the show is a few lines of data, not a second timeline to keep in step.
A cut#
A cut is one version of the show. It has an id, an optional title, and either segments or a base cut and a scale.
| cut field | meaning |
|---|---|
id | unique among the cuts; no . |
title | shown in the cut picker |
segments | the list below, in playing order |
from, scale | a derived cut: the cut named from with every length times scale (scale > 0). It can be derived again; the scales multiply |
start | the id of the segment the show opens on and reset returns to (default: the first) |
| segment field | meaning |
|---|---|
id | unique within the cut; no .. It is also the id of the module that performs it |
title, note | what the desk and the prompter show |
dur | length in seconds, greater than 0. The only time a segment has |
cues | named moments inside the segment: a ratio 0 ≤ r < 1 (a fraction of the segment) or { s: seconds } (seconds into it) |
acts | what the human operator does in this segment: an array of strings, shown on the desk and, large, on the prompter |
linger | seconds the segment's module keeps running after the segment ends (a decay, a tail). Default 0 |
hold | playback stops at the end of this segment and waits for the operator (Hold) |
module | the name of the module that performs it, when that is not the segment's own id |
A derived cut scales dur, linger and { s } cues, so a cue stays in the same place in the music. The ratio cues did not need to move.
A cut is checked when the score is built. Duplicate ids, a dur of 0 or below, a from that is not a cut, a scale of 0 or below, a cut derived from itself, a cue outside its segment, acts that are not strings: each throws a ScoreError that names the cut, the segment and the reason, and lists every problem at once. Nothing half-valid can reach the stage.
The Score#
import { Score } from '@openav/score';
const score = new Score({ cuts, cut: 'full' }); // cut defaults to the first| member | what it gives |
|---|---|
table() | one row per segment: { index, id, title, start, end, dur, linger, hold } |
total | the show's length: the sum of the segment lengths |
segmentAt(T), indexAt(T) | the segment playing at show time T. A segment owns [start, end); before 0 answers the first, after the end the last |
cueTime('dusk') | a moment in show seconds: 'dusk' is the segment's start, 'dusk.end' its end, 'dusk.glow' a cue. NaN for anything unknown |
cuesBetween(T0, T1) | the cues a playhead passed from T0 to T1: those with T0 < time ≤ T1, in time order. Nothing when T1 ≤ T0 |
cues() | every cue of the cut in time order, each with its number n |
scenes() | the segments as a scenes array for the timeline (with acts, hold and cues) |
validate() | throws a ScoreError when anything is wrong; returns true otherwise (the constructor already calls it) |
cut, title, segments, startT | the active cut, and where reset goes |
cuts | every cut as { id, title, total, from, scale, base }, for a version picker |
scale, base, baseTime(T) | the cut's total scale against the cut it was derived from, that cut's id, and T expressed on it |
Automation on a derived cut#
Automation curves are written once, against the cut they were derived from, and a derived cut stretches them. timeline.valueAt(key, t) reads the curve at score.baseTime(t), which is t / scale. On the short cut above, t = 20 reads the curve at 40. A cut that is not derived has scale 1 and nothing changes.
If two cuts differ in structure and not just in length, do not share automation between them. Drive the params from the segment modules instead (param()), where ctx.p already scales for free.
Cues#
A cue is a named moment: cues: { peak: 0.5 } in segment noon makes noon.peak, half way through. Three places see it:
- the scrubber draws a tick for it, and a click on the tick jumps there;
- the director calls
onCue({ name, cue, T, n, segment })once each time the playhead passes it while playing, and calls the active module'scue(name, ctx)hook; ctx.cue('peak')inside a module tells you where it sits, as a progress you can compare withctx.p('noon.peak'works for another segment's cue,ctx.cueT(name)answers in show seconds).
A seek does not pass the cues it jumps over, and playing back over a cue fires it again. A cue sitting exactly on the first instant of the show fires on the first frame.
Hold#
hold: true means: when playback reaches the end of this segment, stop and wait. A standby that waits for the room to be ready is the typical use. Exactly what happens:
- The timeline stops a microsecond before the boundary, so the show is still inside the held segment, and sets
timeline.holding = trueandtimeline.playing = false. The clock freezes there for as long as you like. - The held segment's module keeps getting
update()every frame, withctx.p === 1,ctx.holding === trueandctx.held, the number of seconds waited so far. Usectx.heldas the clock for anything that should keep breathing while the show is frozen. (This is the choice made: the show stops at the end of the segment rather than looping inside it. Looping would sendpback to 0, fire the cues again and break "every module enters once, exits once".) - Space, →, the ▶ button,
timeline.play()andtimeline.next()all release it: the next segment starts at once and playback continues.timeline.release()does the same in code. - A seek (scrubber,
goto, ←) or a reset clears the hold, and a seek never stops at a hold: only playback does. The last segment never holds (the show ends there). - The backstage monitor shows
⏸ HOLD; the desk and the prompter show a pulsing banner.
Segment modules#
A segment module is the code that performs one segment. Its id is the id of the segment (or the segment's module name):
const dusk = {
id: 'dusk',
enter(api, ctx) { api.sky.caption = 'dusk'; }, // the segment began, or a seek landed inside it
update(api, ctx) { /* every frame, playing or not */ },
exit(api, ctx) { api.sky.caption = ''; }, // it ended (after its linger) or a seek left it: give back what you took
param(key, value, ctx) { return key === 'sun' ? 1 - ctx.p : value; }, // optional: bend a timeline param while this segment is current
cue(name, ctx) { /* optional: one of this segment's cues was just passed */ },
reset(api) { /* optional: R was pressed; forget everything */ },
};enter, update and exit get the api object first (yours, see below). Every hook gets a ctx:
ctx | meaning |
|---|---|
p | progress through the segment, 0 to 1. Above 1 during linger; exactly 1 while holding |
t | seconds into the segment (p × dur) |
dt | seconds since the previous frame |
dur | the segment's length in seconds |
T | show time in seconds |
segment | the segment: id, title, start, end, dur, cues, acts, hold, linger, index |
cue(name), cueT(name) | where a cue sits, as a progress and in show seconds (NaN when unknown) |
phase | 'active', 'linger', or 'failed' (only inside exit() after a crash) |
holding, held | waiting at a hold, and for how many seconds |
playing | whether the timeline is running (update is called every frame regardless) |
api | the same api object, so param() and cue() can reach the show too |
- Enter once, exit once. The module of a segment is entered when the show enters it and exited when it leaves it, however it got there. A seek into the middle of a segment calls
enterwith the rightp, so the module must be able to start from the middle. linger. After the segment ends its module keeps gettingupdate()(withp > 1andphase: 'linger') until the linger is over, and only thenexit(). The next segment's module is already running meanwhile.param(). While a segment (or its linger) is current its module may rewrite any timeline param. Return a number to replace the value, anything else leaves it. The performer's hand still wins: an override is applied after this layer.- A module that throws is switched off, alone. Each hook runs inside its own
try/catch. If one throws, the director calls that module'sexit()once (withphase: 'failed'), prints oneconsole.error, and never calls it again. The other modules and the show carry on. The desk shows a red warning with the module's name and the error; R (reset) switches it back on. A frame loop is not allowed to stop because the youngest code in the room failed.
The Director#
import { Director } from '@openav/score';
const director = new Director({ score, modules: [dawn, noon, dusk], api: { sky } });
// every frame, after the timeline advanced:
director.update(timeline.t, dt, { holding: timeline.holding, playing: timeline.playing });
timeline.layer = (key, value, t) => director.param(key, value, t);
timeline.onSeek((t, kind) => (kind === 'reset' ? director.reset() : director.seek(t)));| option | meaning |
|---|---|
score | the Score |
modules | an array of modules (matched by id) or an object keyed by segment id |
api | the first argument of enter/update/exit (also ctx.api). A function is called once, the first time it is needed |
onStatus(status) | called when the segment, the holding state or the list of switched-off modules changes, not every frame |
onCue(cue) | called for every cue the playhead passes |
maxStep | a jump bigger than this many seconds between two update() calls counts as a seek, so cues in between are not fired (default 2). Raise it if you rehearse at a very high timeline.rate |
| member | what it does |
|---|---|
update(T, dt, { holding, playing }) | once per frame: enter, update and exit the modules, fire the cues passed |
param(key, value, T) | the timeline's param layer |
seek(T) | the playhead jumped: modules whose segment no longer covers T exit now, the next update() enters the new ones with the right p |
reset() | every active module exits, every module hears reset(api), switched-off modules come back |
status() | { cut, T, total, segment: { index, id, title, note, start, end, dur, t, p, remaining, hold, acts, cues }, next: { id, title, start, in } or null, holding, held, active, failed, errors, missing } |
register(module), holding, failed | add a module later; read the state |
Use it with createShow#
const show = await createShow({
world,
score: { cuts, cut: 'full', modules: [dawn, noon, dusk],
api: (show) => ({ sky: show.stage.active }), // what the modules may touch (default: the whole show)
midi: false }, // see "Score to MIDI"
timeline: { automation: { haze: [[0, 0.1], [60, 0.5]] } }, // optional: written against the base cut
});score option | meaning |
|---|---|
cuts, cut | the cuts, and the default one. ?cut=<id> in the URL wins over cut; an unknown id is warned about and ignored |
modules | the segment modules |
api | an object, or a function of the show; default is the show itself |
onStatus, onCue, maxStep | passed to the director |
midi | false (default), true or { channel, segment, cue, … }: Score to MIDI |
With a score, the timeline's scenes, total and start come from it (anything you pass as timeline.scenes or timeline.total is ignored), and the frame loop gains one step after the timeline advances, director.update(...). createShow also returns show.score, show.director and, for rehearsal and scripts, show.show (so window.openav.show):
window.openav.show | |
|---|---|
table() | prints the segment table with console.table and returns the rows |
goto('dusk'), goto('dusk.glow'), goto(2) | jump to a segment, a cue or a segment by number |
seek(T) | jump to show seconds |
cue('dusk.glow') | the cue's show seconds (it jumps nowhere) |
status() | the director's status |
next(), prev() | the same as → and ← |
cuts | the list of cuts |
The desk#
With a score the console adds:
- Scrubber. Segment blocks, a tick for every cue (click to jump) and a dashed end on a segment that holds.
- Director panel. A cut picker (choosing one reloads the page with
?cut=<id>, so nothing is half-switched), the current segment with a progress bar and the time left, the HOLD banner, the next segment and the countdown, the operator's acts, this segment's cues (ticked as they pass), and the red warning for modules that were switched off. - Prompter (T). Fullscreen, large, high-contrast: the segment title, what you do (
acts), seconds left, the next segment, a HOLD banner while the show waits, and a red line for switched-off modules. Click it or press T again to leave. It also works for plain timeline scenes that carryacts.
Keys#
| key | does |
|---|---|
| Space | play / pause. At a HOLD: release it |
| → | next segment. At a HOLD: release it |
| ← | previous segment |
| R | back to the start; stopped; switched-off modules come back |
| T | prompter |
| F | fullscreen |
| Esc | stop the knob-mapping wizard, or leave keyboard-piano capture |
Typing in a field, a held key (auto-repeat) and any key with ⌘, Ctrl or Alt are ignored. While the on-screen piano captures the keyboard (the keyboard box ticked in the piano bar) the letters belong to the piano: R, T and F do nothing, because T and F are piano keys, and an accidental R must not rewind a show you are playing. Space, the arrows and Esc always work, so you can play and run the show from one keyboard. The MIDI-controller key M follows the same rule.
Map the knobs in order#
The L2 Mapping panel has 🎛 map knobs in order. It arms learn for the first param, you turn one knob, it is bound and the next param is armed; skip, back and stop (or Esc) steer it. Learn only ever adds routes (Mapper.learn), so running it again puts a second knob on a param and keeps the first. The order is the declared order of the params, skipping hidden ones; pass mountConsole(el, app, { learnOrder: ['hue', 'size'] }) for your own. The logic is LearnWizard in @openav/mapping:
import { LearnWizard } from '@openav/mapping';
const wiz = new LearnWizard({ mapper, keys: () => params.schema.map((p) => p.key), onChange: (w) => draw(w) });
wiz.start(); wiz.current; wiz.skip(); wiz.back(); wiz.stop();Score to MIDI#
Software that should follow the show (a DAW launching a clip per segment, a lighting desk jumping scenes) cannot read the score, but it can read MIDI. ScoreMidi (@openav/midi) turns segment changes and cues into notes and CCs. It is off unless you ask:
score: { cuts, midi: { channel: 15,
segment: { note: 36, cc: 20 }, // segment i → note 36 + i, and CC 20 = i
cue: { note: 84, notes: { 'dusk.glow': 90 }, cc: 21 } } } // a cue → note 84 (or its own note), CC 21 = its number| option | default | meaning |
|---|---|---|
channel | 15 | MIDI channel 1 to 16 |
segment | { note: 36 } | note: base note, the note sent is note + index. cc: a CC whose value is the index. velocity. false turns segment markers off |
cue | { note: 84 } | note for every cue, notes: { 'seg.name': note } for particular ones, cc whose value is the cue's number in the cut. false turns cue markers off |
length | 0.15 | seconds before a marker note's note-off |
scrub | false | also mark when the playhead is moved by hand. By default markers go out only while the show plays (or a hold is released), so dragging the scrubber does not launch clips |
A reset never sends. Everything goes through the Midi engine's own noteOn/cc, so the MIDI OUT meter shows it and panic() silences it. In code: new ScoreMidi({ midi, channel, segment, cue }), then .segment({ index, cause }) and .cue({ name, n }).
Clocks: why there are none#
A show that keeps two time lines (a story time that old logic reads and a show time the audience lives) needs a way to say which engine follows which: weather that never stops, a city that pauses while an inserted segment plays. That is a clock per engine. This framework has one time line, the show's T, so there is nothing for a clock to choose between, and a segment module that wants to pause an engine simply stops advancing it (ctx.t is its own clock, ctx.held another). If a real show brings back the two-time-line case, a clocks table belongs in the cut next to segments. Until then it would be machinery with no user.
Without createShow#
import { Score, Director } from '@openav/score';
import { Timeline } from '@openav/timeline';
const score = new Score({ cuts, cut: new URLSearchParams(location.search).get('cut') || 'full' });
const timeline = new Timeline({ params, score }); // scenes, total and start come from the score
const director = new Director({ score, modules, api: { world } });
timeline.layer = (key, value, t) => director.param(key, value, t);
timeline.onSeek((t, kind) => (kind === 'reset' ? director.reset() : director.seek(t)));
const desk = mountConsole(deskEl, { timeline, params, mapper, signals, stage, score, director });
const loop = new Loop((dt) => {
timeline.advance(dt);
director.update(timeline.t, dt, { holding: timeline.holding, playing: timeline.playing });
mapper.update(dt);
desk.render(stage.frame(dt, timeline.state()));
});Rehearsal checklist#
-
show.show.table()reads like the running order you meant - play it once start to finish at
timeline.rate = 4; every cue fired, no module switched off - open
?cut=for every version and check the desk shows the right total - press Space on every hold, press → on every hold, press R during a segment
- break a module on purpose (throw in
update) and watch the show carry on - the prompter (T) reads from the position you will stand in
MIDI controllers & embedding#
open-audiovisual models MIDI controllers as data: one JSON file per device, with the faceplate layout, every control's exact MIDI message, LED feedback, how to recognise its port, and the document each number came from. The same file drives an on-screen controller you can play, follows the real device when you plug it in, and can be dropped into any web page. Every device is on the controllers page.
Play one in a page#
<script type="module" src="https://openaudiovisual.com/packages/midi/element.js"></script>
<oav-controller profile="akai-lpd8" readout></oav-controller>That is the whole integration. The element works without Web MIDI (Safari, iPad): it plays
and fires events; only hardware and MIDI out report that they are unavailable. It never asks
for MIDI permission unless you turn on hardware, midi-out or learn. Safari has none because WebKit has not implemented it (bug 107250, still open).
| Attribute | What it does |
|---|---|
profile | which device: an id, a short name, or a URL to your own profile JSON |
layout | auto, face (as it looks) or stack (re-gridded for a phone held upright) |
hardware | on, or ask for a button: the real device moves the on-screen one |
midi-out | a port name (IAC, loopMIDI), /regex/, or ask: what you play is sent there |
channel | 1–16, when your unit is set to another channel |
picker, readout, learn, follow | device picker, last-message line, learn button, show whatever known device you plug in |
Listen to it#
const el = document.querySelector('oav-controller');
el.addEventListener('control', (e) => {
const { id, value, signal, message, source } = e.detail;
// 'k1' 0..1 'midi/lpd8/k1' { type: 'cc', ch: 1, cc: 70, value: 53 } 'ui' | 'hardware'
});
await el.ready;
el.set('k1', 0.5); // move a control as a hand would
el.ingest([0xb0, 70, 64]); // MIDI from anywhere: the faceplate followsOther events: noteon, noteoff, connect, disconnect, status, profilechange,
ready, error. All bubble and cross the shadow root. Every field of every payload is in
packages/midi/README.md.
Without the element#
import { createController } from 'https://openaudiovisual.com/packages/midi/index.js';
const ctl = createController('akai-lpd8', { signals }); // signals: publish into a show
ctl.mount(document.querySelector('#pads')); // optional
ctl.on('control', (e) => console.log(e.signal, e.value));Inside a show, do not read midi/lpd8/k1 in update(): route it to a param with
controllerRoutes(profile, { k1: 'hue' }) (rule 1 in AGENTS.md).
Example 09 does this for six devices at once.
In an iframe#
For places that only take an iframe:
<iframe src="https://openaudiovisual.com/embed/controller/?profile=akai-lpd8&readout&frame=pads"
width="720" height="220" style="border:0" allow="midi" title="AKAI LPD8"></iframe>The frame posts { source: 'openav', type, frame, detail } to its parent for every event,
and accepts { target: 'openav', type: 'set' | 'ingest' | 'press' | 'release' | 'reset' | 'profile' | 'layout' | 'get', … }. Add origin=https://your.site to talk to your page only.
Play your DAW with it#
- macOS: Audio MIDI Setup → Window → Show MIDI Studio → IAC Driver → “Device is online”. Windows 11: the built-in Windows MIDI Services (released 2026-02-17, rolled out in stages to 24H2 and 25H2) share MIDI ports between apps and include a loopback, and Microsoft's announcement says web pages using Web MIDI can use it (Windows blog). We have not tested it with open-audiovisual. Older Windows: install loopMIDI and add a port; that still works.
<oav-controller profile="akai-lpd8" midi-out="IAC">(on Windows, give the name of the port you made or the one Windows shows).- In Ableton Live, turn on Track and Remote for that input; in TouchDesigner, a MIDI In CHOP on it; in Resolume, Preferences → MIDI.
Hands on the screen are sent; your real hardware is not forwarded (the DAW already hears it). The engine never listens to IAC inputs, and the element mutes an input named like its output, so nothing loops.
Telemetry#
So the project can see where its controllers and shows end up, the packages send one anonymous hit per page load to Google Analytics 4 (the openaudiovisual.com property, G-1YG2JHK2WT):
| event | when | params |
|---|---|---|
oav_embed_load | an <oav-controller>, createController() or the iframe starts | oav_kind (element · headless · iframe), oav_profile, oav_host, oav_version |
oav_hardware_connect | a real controller is plugged in and matched | oav_kind, oav_profile, oav_host, oav_version |
oav_show_start | createShow() starts | oav_kind (show), oav_host, oav_version |
oav_host is the origin of the page it runs on (for the iframe, the page that holds it), never the path, query or title. Nothing anyone plays is sent, no cookies are set, and the client id is random for every page load, so visitors can't be followed. No gtag.js is loaded: a page with its own Google Analytics keeps its dataLayer untouched. Nothing is sent from localhost, LAN addresses, file:// or openaudiovisual.com itself, or when the browser sends Do Not Track / Global Privacy Control.
Turn it off: <oav-controller telemetry="off"> · createController(p, { telemetry: false }) · createShow({ telemetry: false }) · the iframe's ?telemetry=0 · or globalThis.OPENAV_TELEMETRY = false before anything loads. Source: packages/midi/telemetry.js.
Add a controller#
- Copy the closest file in
packages/midi/profiles/, or a generic layout. - Write each control's type and message from the manual's MIDI implementation chart.
- Cite every source; mark anything unconfirmed
"verified": falsewith a note. validateProfile(profile)lists every problem;npm testchecks every shipped profile.- Try it with
<oav-controller profile="./my-device.json">, then register it inprofiles/index.jsand open a pull request.
No manual: choose a generic layout, press Learn, move your controls in order, and keep
controller.exportMapping() as the start of a profile.
p5.js in one line#
Any p5.js sketch (OpenProcessing, the p5 editor, your own page) can be played from a MIDI controller, from a controller drawn on screen, or from a phone, by adding one line at the top:
document.head.prepend(Object.assign(document.createElement('script'), { src: 'https://openaudiovisual.com/p5/oav-p5.js' }));On OpenProcessing you can instead paste the URL into Sketch → Libraries → + (custom library). Both work in p5 1.x
and 2.x global mode, and nothing has to be called in setup().
A sketch that carries this line, playable right now: example 15.
Read it like the mouse#
function draw() {
background(knob1 * 255); // knob 1, 0..1, like mouseX
circle(width / 2, height / 2, 50 + knob2 * 300);
}
function notePressed(note, velocity) { // like keyPressed(): any key, or a pad
circle(random(width), random(height), velocity * 100);
}| read | value |
|---|---|
knob1 … knob8, knob(n) | 0..1, numbered as printed on the controller |
fader1 … fader8, fader(n) | 0..1 |
pad1 … pad16, pad(n) | the velocity while the pad is held, 0 when released |
note, velocity | the last key (MIDI number) and how hard (0..1) |
noteIsPressed, noteIsDown(60) | like keyIsPressed and keyIsDown() |
| define | called when |
|---|---|
notePressed(note, velocity), noteReleased(note) | a key goes down / up (a pad too, unless you define padPressed) |
knobMoved(n, value), faderMoved(n, value) | a knob or fader moves |
padPressed(n, velocity), padReleased(n) | a pad is hit / let go |
Knob 1 is knob 1 on whichever controller played it: the controller profiles say which MIDI message
each knob sends (MiniLab 3's knob 1 is CC 74, LPD8's is CC 70). On a controller OAV has no profile for, the first
knob you turn becomes knob1, the next knob2. A sketch that already has its own note, velocity or knob1
keeps it.
Note names#
Wherever a note number goes, a note name goes too (0.8):
function draw() {
if (noteIsDown('C4')) circle(width / 2, height / 2, 100); // the same as noteIsDown(60)
}
function mousePressed() {
midi.play('C#4', 0.8); // sends note 61, as a key would
}| write | you get |
|---|---|
noteNumber('C4') | 60; a number comes back as it is, held to 0..127 |
noteName(61) | 'C#4'; noteName(61, { flats: true }) is 'Db4', noteName(61, { octave: false }) is 'C#' |
noteFreq('A4'), noteFreq(69) | 440 (Hz), for a sketch that makes its own sound; noteFreq(69.5) is a quarter tone up |
noteIsDown('C4'), midi.isDown('C4'), midi.play('C#4', 0.8) | the same as with 60, 61 |
A name is a letter A to G (either case), an optional # or b (♯ and ♭ too), and the octave number: C4, c#4,
Db3, F♯2, C-1 (0) up to G9 (127). C4 is 60, the scientific pitch notation the course modules use; some software
(Ableton, Yamaha) calls 60 "C3", so a name copied from there is one octave off. These are also the names Strudel writes
(c4 e4 g4, # for sharp, b for flat). Anything else, such as H4 or C-2, stops with a one-line card saying how to write it.
notePressed(note, velocity) still hands you the number: noteName(note) writes it back as a name.
With OP Configurator#
OP Configurator 3000 (OPC, built into OpenProcessing) names a variable and its range. With this line on the page the controller is wired to those variables in order, with no mapping step:
OPC.slider('radius', 60, 10, 300); // knob 1
OPC.slider('speed', 1, 0, 5, 0.1); // knob 2
OPC.toggle('trails', true); // pad 1 flips itKnobs drive the sliders in the order they are declared, the faders continue after the knobs, and pads drive toggles
and buttons. OPC's panel moves with the knob, and parameterChanged() fires as usual. OpenProcessing's OPC panel can
also learn hardware by itself (its MIDI switch); once that mapping is seen working, hardware is left to it and this
line keeps wiring the on-screen controller and the phone, which OpenProcessing cannot see. Avoid OPC names that are
also p5 functions (hue, fill): p5 1.x clashes with them.
One knob, one job. If the sketch also reads knob1 itself, knob 1 is the sketch's: the first OPC slider no longer
follows it (drag it on OPC's panel), the other knobs stay wired, and a hint says so once. The first line of the 🎹 panel
is the wiring, for example knob1 → knob1 (sketch; radius by its slider) · knob2 → speed (OPC).
The microphone#
The same line reads the microphone, and the sketch reads it the same way: as variables.
function draw() {
background(230, 40, 10);
fill(200 + bass * 160, 70, 100); // bass: the low end, 0..1
circle(width / 2, height / 2, 50 + micLevel * 300); // micLevel: how loud, 0..1
}
function beatDetected(level) { // like keyPressed(): a beat just landed
// flash, spawn, jump to the next scene
}| read | value |
|---|---|
micLevel | 0..1, how loud it sounds: the room's own noise is 0, talking is about 0.4–0.6, shouting is near 1 |
bass, mid, treble | 0..1, the energy in 20–250 Hz, 250–2000 Hz and 2000–8000 Hz |
beat | true for one frame per beat, like mouseIsPressed while a button is down |
micIsOn | whether the microphone is running |
startMic(), stopMic() | turn it on or off from code (they return a promise) |
micSensitivity(2) | twice as sensitive; micSensitivity() returns the current value |
| write this function | when it is called |
|---|---|
beatDetected(level) | a beat: a jump in loudness over what the room has been doing (level 0..1) |
kickDetected(level), snareDetected(level), hatDetected(level) | a hit in the low, middle or high band (20–120 Hz, 150–800 Hz, 6–14 kHz) |
It is off until the sketch asks. Nothing is requested when the page loads. The first time the sketch reads
micLevel, bass, mid, treble or beat, or calls startMic(), the browser asks for the microphone; until the
answer arrives every value is 0. A sketch that defines beatDetected() and reads nothing starts it on the first
click or key. In the 🎹 panel the 🎤 button turns it on and off by hand and shows the level, and it says so when
the browser blocked the microphone (click it again after allowing it from the address bar).
- Click first, if the browser wants one. Browsers keep audio quiet until the page has been clicked. This file
asks as soon as
draw()reads a value and starts the microphone again on the first click or key if the browser held it back, so nouserStartAudio()is needed. - Next to p5.sound. The analysis is
@openav/audio(anAnalyserNode), loaded from next to this file the first time it is needed, with its ownAudioContext.p5.sound(p5.AudioIn,p5.FFT) can run on the same page; the two microphones do not share anything, and none of these names is a p5 or p5.sound name. It works in p5 1.x and 2.x global mode. - How loud is loud.
micLevelis a loudness in decibels, measured from the room: its bottom follows the room's own noise (it settles in a second or two, and rises at most 1.5 dB a second, so a voice never becomes the new silence), and −10 dBFS, shouting into a laptop, is 1. So50 + micLevel * 300visibly grows when someone talks. A quiet or far microphone:micSensitivity(3)(+9.5 dB onmicLevel, ×3 on the bands, still capped at 1). The raw numbers areOAVp5.mic.rms(0..1),OAVp5.mic.dbandOAVp5.mic.floor(the room, in dBFS).OAVp5.mic.on('beat', fn)is the same as definingbeatDetected, and returns a function that stops it. - Your own names win. A sketch that already has a
bassormid(alet, a function, a library) keeps it, and this file never starts the microphone for it. - Off switch.
?mic=click(orwindow.OAV_P5 = { mic: 'click' }) means only 🎤 andstartMic()turn it on;mic=offmeans nothing can start it (the names stay0).httpsorlocalhostis required, as for any microphone page. - A sketch with a microphone, ready to open:
p5/examples/mic/.
Sound: what is played is heard#
function setup() {
createCanvas(windowWidth, windowHeight);
startSound('piano'); // every key is heard: a keyboard, the 🎹 on screen, a phone
}| call / read | value |
|---|---|
startSound(instrument) | turn sound on (default 'piano'), or switch instruments while it plays |
stopSound() | turn it off |
soundIsOn | whether notes are heard now |
The instruments are those of @openav/sound: piano (the Salamander grand; its 2 MB of samples load the
first time, and the electric piano plays meanwhile, or instead if they cannot load), epiano, organ, strings, choir,
harp, marimba, vibraphone, xylophone, glockenspiel, music-box and the synth/… presets. In the 🎹 panel,
🔊 turns sound on and off and shows a picker with every instrument and the loading progress.
- Off until asked. Nothing loads before
startSound()or 🔊; then@openav/soundfrom next to this file and Tone.js 15 from jsDelivr (the optiontone=<url>loads it from elsewhere). - The first click. Browsers play audio only after the page has been clicked, and a key on a MIDI keyboard does not count.
A sketch that calls
startSound()insetup()is heard from the first click or key; until then the 🔊 on the 🎹 button is yellow. - Pads are drums. Notes on MIDI channel 10 (MiniLab 3 and LPD8 pads, the General MIDI drum channel) play the drum kit, not the piano. The sustain pedal holds notes.
- No code at all.
?oav_sound=pianoon the page (any instrument id; on OpenProcessing?sketch_oav_sound=piano) makes any sketch heard from the first click;sound=offkeeps it silent. - A sketch that plays and draws, ready to open:
p5/examples/keys/.
Drum machine#
function setup() {
startDrums('breakbeat', 110); // a 16-step grid on screen; click a cell to change the beat
}
function kickDetected(level) { // the microphone's own callback
// swell, flash, spawn
}
function drumHit(lane, velocity) { // every hit: 'kick', 'snare', 'hat' or 'clap'
}| read | value |
|---|---|
drumsAreOn | whether the machine is playing |
drumStep | 0..15, the step that just played |
drumBar | 1, 2, 3 …, counted from startDrums() |
drumBpm | the tempo (the grid's slider changes it too) |
drumPhase | 0..1, how far through the bar, smoothly, as heard: rotate(drumPhase * TWO_PI) turns once a bar |
beatPhase | 0..1, how far through the beat (a quarter note): 1 - beatPhase swells on each beat and shrinks to the next |
startDrums(pattern, bpm), stopDrums() | start (or change the pattern and tempo while it plays), stop |
| write this function | when it is called |
|---|---|
drumHit(lane, velocity) | every hit, every drum (a line of rhythm's crash, ride or toms too) |
kickDetected(level), snareDetected(level), hatDetected(level) | a kick; a snare, a clap or a rim; a hat (closed, open or pedal) |
beatDetected(level) | a kick or a snare, once per step (and beat is true for a frame) |
rhythmChanged(src, how) | the line changed: startDrums(), a click on a cell, a line typed, ↶ (0.7) |
A microphone sketch moves over unchanged. Each hit calls the same functions the microphone does, so a sketch written for
the microphone reacts to the machine as it is. Once a sketch has started the machine, defining kickDetected() no longer
opens the microphone on the first click (a sketch that reads micLevel still asks, and then both call it).
- Patterns.
'four on floor',breakbeat,'half time',latin,sparse, your ownstartDrums({ kick: 'x...x...x...x...', snare: '....x.......x...', hat: 'x.x.x.x.x.x.x.x.' })(a lane left out is silent), or one line of rhythm (below). - The card. The lanes are named in the page's language (大鼓 小鼓 鈸 拍手 in a Chinese browser), the bar it is in shows beside the tempo, and the step that sounds is a yellow column (13:1 against the dark card, readable on a projector).
- Where the grid sits. At the top centre: on OpenProcessing between the 🎹 and OPC's icon and clear of the console, and
below the 🎹 panel while that is open. – folds it, × stops the machine, and 🥁 in the 🎹 panel opens and closes it with
no code at all. 🥁 brings the grid up stopped: tick its play box to hear it (a sketch's own
startDrums()plays at once). - The kit.
@openav/drums' synthesized kit (no samples), heard from the first click like sound; the callbacks run from the start either way.
On time, however heavy the sketch. The hits are handed to the kit ahead of time, on its audio clock: a heartbeat in a
Web Worker wakes every 25 ms and schedules what falls in the next 0.1 s at its exact time. Measured on the kit's own output
with a draw() that keeps the page busy 30 ms a frame, the kick-to-kick interval varies by 0.000 ms (0.5, which played each
hit in the frame it was due, varied by 15 ms). The callbacks still come in a frame: the one in which the hit is heard (its
time plus the output latency the browser reports), right before draw(), in the same order as before.
- A tab in the background keeps playing and counting:
drumBarfollows the clock on the wall and the cues fire, with onebarStarteda bar. The hits andstepStartedare not told while nobody can see them, and nothing is told twice when the tab comes back, so a sketch that adds a particle per hit does not burst. - A
draw()longer than 0.1 s makes the machine look 0.3 s ahead from then on (🩺 says so); a click on a cell is then heard up to 0.3 s later. - A Bluetooth speaker is late by 0.15–0.25 s; where the browser reports it the callbacks wait for it, and
?oav_drumlatency=200(milliseconds) sets it by hand. - The safety net:
?oav_drums=frame(on OpenProcessing?sketch_oav_drums=frame) brings back 0.5's machine, moved by the frames.
One line of rhythm#
function setup() {
startDrums("bd*4, [~ sd]*2, hh*8", 110); // a kick on every beat, a snare on 2 and 4, eight hats
}Any text that is not a pattern name is a line of rhythm; names, grids and numbers work exactly as before. The line shows over the grid, and the word that sounds lights up and fades.
| write | means | e.g. |
|---|---|---|
| a word | a drum: bd kick, sd snare, hh hat, oh open hat, cp clap, rim, lt mt ht toms, cr crash, rd ride, cb cowbell | "bd sd bd sd" |
~ | a rest | "bd ~ sd ~" |
| space | the words share the bar evenly | "bd hh sd hh" |
[ ] | a group shares one place | "bd [sd sd]" |
*n | n times in that place | "hh*8" |
, | another line on top | "bd*4, hh*8" |
< > | one per bar, in turn | "<bd*4 [bd ~ bd ~]>" |
(k,n) | k hits spread over n steps | "bd(3,8)" |
The whole list is in @openav/pattern, loaded from next to oav-p5.js with the drums. Drums the grid has no
lane for (crash, ride, toms, open hat) get a row of their own; a hit off the sixteen steps (a triplet) is drawn where it falls
inside its step. A mistake in startDrums("…") shows a card in the page's language with the character it is at, why, and
how to fix it, and the machine plays 'four on floor' meanwhile; 'x...x...' on its own (one lane of the grid) says to put it
inside { kick: … }.
A line copied from strudel.cc works too: startDrums('s("bd*4, [~ sd]*2").fast(2)') plays [bd*4, [~ sd]*2]*2.
s("…"), sound("…"), with ", ' or ` quotes, several of them ($: s(…)) as layers, .fast(n) and .slow(n);
setcpm(110/4) or setcps(…) sets the tempo when startDrums() is given none. .bank("…") is left out (OAV plays its own
synthesized kit) and anything else in the chain (.jux(rev), .every(…), .gain(…)) gets a card that says OAV does not do it
yet and points at Open in Strudel; the rest plays.
The way of writing one line of rhythm comes from TidalCycles / Strudel (strudel.cc); OAV implements it independently from their public documentation.
The cells and the line edit each other#
Since 0.7 the line over the grid is always there (a preset shows the line its grid is: 'four on floor' is
bd*4, [~ sd]*2, hh*8) and both ways work:
- Click a cell: the line is rewritten the way a person would write it, and the machine plays the new line at once.
Steady hits become
bd*4/hh*8, one hit per group[~ sd]*2, a Euclidean rhythmbd(3,8)(with its rotation:bd(3,8,2)starts two steps later), anything else beat by beat:bd [~ ~ ~ bd] bd*2 ~. Lines are joined with,. When the line holds more than sixteen steps can (triplets,< >,/2,?,bd:2), the first click shows a card saying what would go, and the next click rewrites it. - Type in the line: click it, change it; Enter, or a pause of 400 ms, plays it and the cells follow. A mistake marks the
word in red, a card says what is wrong, and the last line that worked keeps playing (not
'four on floor'). Escape drops what is not played yet. Typed keys never reach the sketch'skeyPressed(). - ↶ on the card (or ⌘Z / Ctrl+Z after touching the card) goes back one line;
OAVp5.drums.historylists them. - Copy puts
startDrums("…", 110)on the clipboard, ready to paste intosetup(). - Open in Strudel opens the same line at strudel.cc in a new tab, as
s("…")withsetcpm(bpm/4)(one OAV bar is one Strudel cycle of four beats; strudel.cc's docs:setcpm(bpm/bpc)).OAVp5.drums.strudelUrlis the link.
function rhythmChanged(src, how) { // how: 'code' · 'cell' · 'type' · 'undo' · 'menu'
line = src; // e.g. draw the line on the canvas, so a recording shows it
}OAVp5.drums.src is the line that plays, OAVp5.drums.edit("…") plays a line as if typed, OAVp5.drums.undo() goes back. Keys that swap the rhythm, step back and open Strudel: p5/examples/rhythm-lab/.
The rhythm on the canvas#
The card is part of the page, not the canvas, so a recording never shows it. drawRhythm() draws the rhythm on the canvas:
function draw() {
background(0);
drawRhythm(20, 20, 360, 90, 'grid'); // the sixteen steps, a playhead
drawRhythm(20, 130, 300, 300, 'circle'); // a ring of 16 per drum, a hand turned by drumPhase
drawRhythm(340, 130, 300, 300, 'spiral', { bars: 4, colors: { kick: '#ff7b54' } }); // the last 4 bars, wound outwards
}drawRhythm(x, y, w, h, style, options): style is 'grid', 'circle' or 'spiral'; a hit lights up the moment it is
heard (the clock drumPhase reads) and a triplet sits where it falls. Options: colors ({ kick: '#f66', … }, any p5 color),
color (one for all), off (the empty steps), playhead (a color, or false), bg, bars (spiral). Drawn inside
push() / pop() with p5's own shapes, in 2D and WEBGL, p5 1.x and 2.x; in WEBGL (0, 0) is the middle of the canvas:
drawRhythm(-width / 2, -height / 2, width, height, 'circle'). With no box it draws a strip along the bottom ('grid') or a
square in the middle. A sketch with all three: p5/examples/rhythm-visual/.
Timeline: bars and cues#
The drum machine is also the clock: sixteen steps a bar, counted from startDrums(). Declare what happens at which bar:
function setup() {
startDrums('breakbeat', 110);
cue(1, 'calm', 12); // bar 1, and again every 12 bars
cue(5, 'bloom', 12);
cue(9, 'storm', 12);
}
function cueFired(name, bar) { // or read cueName in draw()
look = SCENES[name];
}| call / read / define | |
|---|---|
cue(bar, name, every) | at bar bar (1 is the first, 4.5 is halfway through bar 4); every bars again if given |
cueName | the last cue that fired ('' before the first) |
cueFired(name, bar) | a cue's bar has come |
barStarted(bar), stepStarted(step, bar) | the first step of every bar; every step |
The same cue() twice adds nothing, so it may also sit in draw(). On one step the cues fire in the order they were
declared, then barStarted, stepStarted and that step's hits. A silent clock: startDrums({}). The whole of it, ready to
open: p5/examples/drums/; a line of rhythm with drumPhase and beatPhase: p5/examples/rhythm/; the rhythm drawn on the canvas: p5/examples/rhythm-visual/.
Values over time: lag, env, speed, spring, trail#
A knob or micLevel jumps; what looks good is how it moves between the jumps. TouchDesigner does this with CHOPs; the
same operators are here, one call each in draw(), nothing to declare, and seconds are seconds (the same curve at 30 and
at 144 fps):
circle(width / 2, height / 2, 50 + lag(micLevel, 0.03, 0.4) * 300); // a VU meter: opens at once, closes slowly
scale(1 + env('kick', 0.01, 0.3)); // the drum machine: every kick swells it, gone in 0.3 s
rotate(spring(knob1, 100, 6) * TWO_PI); // flick a knob: it overshoots and settles| TouchDesigner | oav-p5 | |
|---|---|---|
| Lag CHOP (Lag 1 / Lag 2) | lag(value, up, down) | follows value: up seconds while it rises, down while it falls (down = up if left out; 0.1) |
| Trigger CHOP (attack, decay) | env('kick', attack, decay), trigger('boom') | 0..1: up to 1 in attack seconds after each event, back to 0 in decay (0.01, 0.3). Events: 'kick' 'snare' 'hat' 'beat' (the microphone or the drum machine, the hits kickDetected hears), 'note' (any key), 'bar' 'step' (the drums), a cue's name, trigger('boom'); or a switch: env(mouseIsPressed, …) |
| Speed CHOP | speed(rate) | adds rate every second: rotate(speed(TWO_PI)) turns once a second |
| Spring CHOP | spring(value, stiffness, damping) | follows like a mass on a spring (100, 6: two or three bounces; damping 20: none) |
| Trail CHOP | trail(value, seconds) | the last seconds (2) as an array, 60 values a second, oldest first, the newest last |
| Slope CHOP | slope(value) | how fast it changes, per second |
| Count CHOP | count(beat), count(beat, 4), count('kick') | how many times it turned on; with 4: 0 1 2 3 0 …; a name: how many of those events so far |
| a CHOP's viewer | scope(value, 'label') | a small plot in the corner of the page (below) |
drawTrail(values, x, y, w, h) | the same plot on the canvas, in the work and in a recording |
Seconds, the same as in TouchDesigner. A lag's seconds mean what a Lag CHOP's do: about the time the value takes to
follow 90% of a jump, so lag(x, 0.1) here moves like a Lag CHOP set to 0.1 (Lag CHOP).
Underneath it is a one-pole lag with a time constant of seconds ÷ 2.3 (ln 10); @openav/mapping's smooth keeps its
own 63% time constant. Attack and decay are lengths in both.
Nothing to declare. The first lag() in draw() keeps the first lag's memory, the second the second, every frame;
spring(), speed(), trail() … are counted each on their own. Put one inside an if, or in a loop whose length
changes, and the order slips: a card says so, and from then on oav-p5 matches each call by the line it is written on. The
sure way is a name, a string at the end: lag(p.size, 0.1, 0.5, 'p' + p.id). A named one is the same wherever it is called,
draw(), setup() or mousePressed(); an unnamed one outside draw() has nothing to remember by and returns its input
(speed 0, trail []), and a card says that too. env(), trigger() and count('kick') are about events and work
anywhere. A sketch with its own speed or count keeps it; OAVp5.chop.speed(…) is the same function under another name.
Seeing the values. scope(value, 'label') returns value, so it can wrap an expression:
circle(x, y, scope(lag(micLevel, 0.03, 0.4), 'lag') * 300). Each label is one line in a small oscilloscope across the
page from the 🎹: the last 4 seconds and the value now; scope(micLevel, 'mic') beside it shows what the lag did. It lives
on the page, not on the canvas: never in a recording or a 📷, never in the way of a click; hints=off or scope=off hides
it. To draw it in the work: stroke(255); drawTrail(trail(micLevel, 3), 0, height - 100, width, 100) (or
drawTrail(micLevel, …), which keeps its own 2 seconds): 0 at the bottom of the box, 1 at the top (lo, hi after h),
the sketch's stroke() (white under noStroke()).
The math is @openav/chop, loaded from next to this file the first time one of these is called;
until it arrives (a few frames) each returns its input as it is. What each parameter means follows Derivative's
TouchDesigner documentation; the code is OAV's own. Raw against lagged, side by side, with the scope: p5/examples/lag/. env, spring, speed, count and slope driving a disc that a bass drum opens, with startDrums() as the only input: p5/examples/chop/.
Recording: a video with its sound#
function keyPressed() {
if (key === 'r') recordingIsOn ? stopRecording() : startRecording(); // startRecording(10): ten seconds, then it stops
}
function recordingSaved(info) { // info.name, info.seconds, info.bytes, info.format ('mp4' or 'webm')
}| call / read / define | |
|---|---|
startRecording(), startRecording(seconds), startRecording({ seconds, fps, mic, name, canvas, bitrate }) | record the canvas and the sketch's sound; with seconds it stops by itself; a take is 5 minutes at most. canvas is the canvas to record (a p5 canvas or createGraphics(); the default is the biggest p5 canvas on the page), bitrate is bits a second (the default follows the canvas size and frame rate: 4 to 16 Mb/s, up to 24 Mb/s above 30 frames a second) |
stopRecording() | stop; the file downloads |
recordingIsOn | whether a take is running |
recordingSaved(info) | the file is saved: name, seconds, bytes, format, width, height, audio, sources, warnings, blob, url |
In the 🎹 panel, ⏺ starts and stops a take with no code; while it runs, the 🎹 button shows a red dot and the time (a click on the dot stops it). 📷 saves the canvas as a PNG.
- What is in the video. The p5 canvas at its own pixel size (a retina screen doubles it), 30 frames a second, and every
sound this file makes:
startSound(), the drum machine and the pads, the microphone while it is on, and p5.sound 1.x, mixed into one track while the speakers play as before. The 🎹, the drum grid, the cards and OPC's panel are not on the canvas, so they are not in the video. - The file. MP4 (H.264 + AAC), which Instagram, YouTube and every editor take, wherever the browser can write it: Chrome
126 and later, Edge, Safari. Elsewhere (Firefox, an older Chrome) WebM, and a card says Instagram will not take it. It is
named
oav-20261015-183005.mp4; on a computer it lands in Downloads, on a phone a Share button on the card saves it to the photo library or sends it to Instagram. - Checked after every take. A card says what was recorded (format, length, size, with or without sound) and, when something is wrong, what and why: nothing reached the recording although a sound was on (no click on the page yet, nothing played), WebM, an almost empty file, a black video of a canvas that was not black (Safari recording a WebGL canvas, WebKit 229611), an image from another site on the canvas (no CORS), no recorder in this browser.
- The microphone is in the take while it is on: wear headphones, or the speakers are recorded twice.
record=nomicleaves it out. - p5.sound. p5.sound 1.x (the one for p5 1.x) is recorded. The p5.sound made for p5 2.x connects each sound straight to
the speakers, with no output a recording can tap: it is not in the take, and a card says so. Switch the sketch to p5 1.11, or
use
startSound()andstartDrums(). Sound the sketch makes with its own Tone.js or Web Audio code is not recorded either. - Stills. 📷 (or
OAVp5.record.shot()) saves the canvas alone asoav-<date>-<time>.png, without the 🎹 or the cards; from code, p5's ownsaveCanvas('name', 'png')does the same. - Options.
record=nomic(no microphone) orrecord=off(no ⏺ or 📷;startRecording()does nothing),recfps=60,reclimit=600(seconds),title=my-piece(in the file name); for one take,startRecording({ seconds, fps, mic: false, name, canvas, bitrate }). - The whole of it, ready to open:
p5/examples/record/.
Three inputs#
| input | how |
|---|---|
| a real controller | Web MIDI in Chrome, Edge and Firefox; the browser asks once |
| the on-screen controller | 🎹 opens <oav-controller>, which follows the controller you plug in |
| a phone | 📱 in that panel shows a QR code; the phone's remote page MIDI tab plays the sketch |
All three arrive as the same MIDI bytes, so the sketch cannot tell them apart (midi.last.source can). Safari and
iPad have no Web MIDI; the on-screen controller and the phone still work there. A sketch that uses Web MIDI itself
(navigator.requestMIDIAccess, WebMidi.js) also hears the screen and the phone, as one more input called
OAV Virtual MIDI.
Other programs are a fourth input (0.7). What a program plays into OAV Virtual MIDI's output — Strudel's
.midi('OAV Virtual MIDI') — reaches the sketch at the moment it was stamped with (midi.last.source is virtual-out);
an external MIDI clock moves drumPhase, drumBar and a drum machine; and link=<room> brings another tab's or another
computer's programs into this sketch. All of it: With Strudel.
When something goes wrong#
Nobody watches OpenProcessing's console while playing, so problems are said on the page: a small card beside the 🎹, never over the middle of the canvas, in the browser's language, once per page, with a × to close it. Each card says what happened, why, and what to press, and the console gets the same sentence.
- The microphone: refused (the lock left of the address → allow → click the canvas), refused by the computer's settings,
by an embed without
allow="microphone", missing, held by another app (Zoom, OBS), anhttp:page, or the browser still asking. - MIDI: no Web MIDI (Safari, iPad: use the 🎹 or a phone), or the permission refused. Only for a sketch that plays from MIDI; a sketch that uses none of this never sees a card.
- Your own code: an error inside
notePressed(),kickDetected(),drumHit()and the other callbacks shows the message with the file and line, and the sketch keeps running. A sketch that declares its ownnoteorknob1(let,var,function) keeps it, and the card says what to read instead (midi.last.note,knob(1)). - Sound and drums: waiting for the first click, the piano still loading (the electric piano meanwhile), an instrument that failed (the fallback plays), Tone.js unreachable, the drum machine playing silently until a click.
- Recording: started (what it hears, how to stop), saved (format, length, size, sound), a take with no sound in it, WebM (not for Instagram), a black take, an almost empty file, a canvas with another site's image, no recorder in this browser.
?oav_hints=off (or window.OAV_P5 = { hints: 'off' }) keeps them in the console only; OAVp5.hints lists what was said.
🩺 Check-up. 🩺 in the 🎹 panel (or OAVp5.check()) shows the version, the page, the browser, the microphone, MIDI,
sound, drums, recording (MP4 or WebM here, the last take), the wiring, the sketch's own names and the hints so far, as one card to screenshot, with a copy button and a
link to the full device check.
In Hydra#
Hydra has a loader of its own; the same file works there, and Hydra's arrow functions read the variables each frame:
await loadScript('https://openaudiovisual.com/p5/oav-p5.js')
osc(10, 0.1, () => knob1 * 2).out() // knob 1 sets the colour offsetosc(10, 0.1, () => 1 + micLevel * 3).out() // the microphone, asked for when this line first runsnote, velocity, knob1 … knob8, fader1 …, pad1 …, drumStep and the 🎹 on-screen controller are there the same way.
Hydra is not p5, so there are no notePressed() callbacks to define; subscribe instead:
midi.on('notePressed', (note, velocity) => { … }) or OAVp5.drums.on('hit', (lane, velocity) => { … }) (each returns a
function that unsubscribes). Checked against
hydra-synth 1.4.0, the library the editor runs (p5/test/hydra-smoke.cjs).
Notes#
- Why
prepend. p5's friendly-error checker reads the last<script>on the page as your sketch. Put first, this file is never mistaken for your code. - Why one asynchronous line is enough. p5 starts global mode on the window's
loadevent, and a script inserted while the page is loading holdsloadback until it has run, soknob1already exists insetup(). - When the line does not load (offline, a typo in the URL), code that reads
knob1stops with knob1 is not defined. - Embedding a sketch elsewhere. OpenProcessing's own pages allow MIDI and the microphone in the sketch frame. The
embed code it gives you does not: add
allow="midi"to that<iframe>if the embedded sketch should hear a hardware controller, andallow="microphone"if it should hear the microphone. - Options, MIDI-level access and tests:
p5/README.md. Examples:p5/examples/particles/,p5/examples/opc/,p5/examples/mic/,p5/examples/keys/,p5/examples/drums/,p5/examples/rhythm/,p5/examples/rhythm-visual/,p5/examples/rhythm-lab/,p5/examples/record/,p5/examples/lag/,p5/examples/chop/,p5/examples/with-strudel/andp5/examples/note-names/.
With Strudel#
Strudel is a live-coding environment for music in the browser, a JavaScript version of
TidalCycles. OAV is a separate program. The two run side by side the way two programs on a
stage do: Strudel plays, and OAV brings what Strudel does not have — knobs on screen and on a phone, a microphone that
is analysed, Web MIDI where the browser has none — and draws what Strudel plays in a p5.js sketch, on the same page or
in another tab or on another computer. They meet through MIDI (OAV's virtual port, "OAV Virtual MIDI") and through
the page's own variables (knob1, micLevel, drumPhase). OAV never loads or calls any of Strudel's code.
Everything on this page was run on strudel.cc as a user runs it (its editor, Ctrl+Enter, its documented functions):
p5/test/strudel-smoke.cjs does it again.
Load OAV in Strudel#
Strudel → settings → prebake, add one line at the end, save:
await import('https://openaudiovisual.com/p5/oav-p5.js')- Single quotes. Strudel reads text in double quotes (and backticks) as mini-notation: a URL in double quotes is a
[mini] parse error. - Why the prebake. It runs before your code, also after a reload (about 2.5 s after the editor appears), so OAV is
on the page before anything opens MIDI. If Strudel's
midin()ran before OAV loaded,midin('OAV Virtual MIDI')gets nothing and says nothing: reload. 🩺 then shows "no program has asked OAV Virtual MIDI for MIDI". - The line can also be the first line of your code. Options go on the line before it:
window.OAV_P5 = { link: 'my-room' }. - Strudel's share button puts the prebake into the link (with "share with patterns" ticked): whoever opens it gets OAV too.
On strudel.cc OAV changes its manners. It does not ask for MIDI when it loads (hardware=click: Strudel's midin()
asks). The 🎹 panel is a box in the corner, a third of the window high, so the code stays in view. One card says which
names Strudel already has — note, velocity, beat are Strudel's functions, OAV leaves them alone — and what to
read instead: OAVp5.last.note, OAVp5.last.value, OAVp5.mic.on('beat', fn). knob1, micLevel, drumPhase
and the rest are free names there.
Recipe 1: knobs, a phone and the microphone as Strudel's parameters#
Strudel's signal() turns a function into a parameter; OAV's names are plain values a function can return.
s("bd*4").gain(signal(() => knob1))
s("hh*8").lpf(signal(() => 300 + micLevel * 8000))knob1moves with knob 1 of the 🎹 on screen, of a MIDI controller, or of a phone (🎹 → 📱, scan the code).knob(2),fader1,pad1are there too. Measured: knob 1 at 0, the kick is silent; at 127, it is heard (RMS 0.58).- Strudel reads the value when it schedules, 0.2–0.3 s before the sound: a knob is heard that much later.
micLevel,bass,mid,trebleare OAV's analysis of the microphone (Strudel has none); reading one asks for the microphone. Wear headphones: the microphone hears the speakers, andgain(signal(() => micLevel))feeds back.- Strudel's own MIDI input works on OAV's port too, also in Safari (no Web MIDI there; OAV's port is virtual):
const cc = await midin('OAV Virtual MIDI')
s("bd*4").gain(cc(74))Knob 1 of the on-screen MiniLab 3 is CC 74. midikeys('OAV Virtual MIDI') makes the 🎹 keys and the phone Strudel's
keyboard.
Recipe 2: Strudel plays, a p5 sketch draws#
On the same page. Send to OAV's port:
note("c3 [e3 g3] <a3 b3> c4").midi('OAV Virtual MIDI')Strudel hands each note over 0.21–0.32 s early with the moment it should sound; OAV waits for that moment and then
calls notePressed(note, velocity) (in Strudel, with no sketch: OAVp5.on('notePressed', fn)), moves knob1 for a CC,
plays a pad. Measured on strudel.cc: each note is heard within 5 ms of its timestamp (10 notes, at most 4.5 ms late; p5/test/strudel-smoke.cjs), and drumPhase follows Strudel's clock to 0.1 ms. What arrives on the port is never handed
back to OAV's input, so midikeys('OAV Virtual MIDI') played into .midi('OAV Virtual MIDI') is one echo, not a loop.
Strudel's clock. MIDI counts 24 clocks a beat, so a cycle of one 4-beat bar sends 96:
setcps(128 / 60 / 4)
$: s("bd*4, [~ sd]*2, hh*8")
$: midicmd("clock*96, <start stop>/8").midi('OAV Virtual MIDI')drumPhase, beatPhase, drumBar, drumStep and drumBpm follow it. A drum machine started with startDrums()
plays at Strudel's tempo, on Strudel's downbeat, and starts and stops with it (the card says "following an external
clock · 128 BPM"). Measured at 128 bpm: drumPhase within 1.2 ms of Strudel's bar (median 0.04 ms), every kick within
6 ms of Strudel's beat (three runs); Strudel's start, which arrives three times with one timestamp, counts once. Without a clock
nothing changes; clock=off ignores one.
In another tab, or on another computer. Give both pages the same room. In Strudel's prebake:
window.OAV_P5 = { link: 'my-room' }
await import('https://openaudiovisual.com/p5/oav-p5.js')and in the p5 sketch (OpenProcessing: before the one line; or ?sketch_oav_link=my-room on the page's address):
window.OAV_P5 = { link: 'my-room' };
document.head.prepend(Object.assign(document.createElement('script'), { src: 'https://openaudiovisual.com/p5/oav-p5.js' }));Everything Strudel sends to OAV's port — notes, CCs, the clock — reaches every other OAV page in the room, through the
same relay the phones use. 🎹 grows a 🔗: green when another page is there; the panel says the room, the pages and the
trip. The receiving page plays each note when Strudel plays it, not when it arrives: it lines the two clocks up from the
fastest trips it has seen and the round trips both pages measure (packages/relay/link.js explains how), and Strudel's
quarter-second head start covers the trip. Measured with two tabs on one Mac over wss://remote.cheyuwu.com (one way
≈ 61 ms): notes within −3 … +7 ms of Strudel's timestamps (two runs). Across two computers add the difference between the
two halves of a round trip (usually a few ms). Keep the default relay (wss://): Chrome does not let strudel.cc reach a
relay on your own machine or network (ws://127.0.0.1… is blocked by its local-network checks). On a network whose jitter is longer than Strudel's head start, linkdelay= (ms)
buffers more. A page never sends on what it received, and a program that plays a linked note back out is caught as an
echo. The room is the phones' room too: one QR code plays every page in it.
The sketch side, ready to run: p5/examples/with-strudel/ (?room=… on its address).
Recipe 3: Strudel, Hydra and OAV on one page#
With OAV in the prebake:
await initHydra()
osc(10, 0.1, () => knob1 * 2).rotate(() => drumPhase * 6.28).color(1, () => micLevel, 0.6).out()
setcps(120 / 60 / 4)
$: s("bd*4, [~ sd]*2, hh*8").gain(signal(() => 0.3 + knob1))
$: midicmd("clock*96").midi('OAV Virtual MIDI')Hydra turns once a bar on Strudel's clock (through drumPhase), knob 1 drives Hydra and Strudel at once, and the
microphone colours the picture.
Options for Strudel#
Set before the line (window.OAV_P5 = { … }), or ?oav_key=value on a p5 page's address.
| key | values | default |
|---|---|---|
link | a room name (letters, digits, -, _): pages in it hear each other's programs | none |
linkdelay | milliseconds of extra buffer on a linked page's notes | 0 |
clock | follow (an external MIDI clock moves drumPhase and the drums) · off | follow |
strudel | auto (strudel.cc, or Strudel's functions already on the page) · on · off | auto |
compact | on (the panel as a box in the corner) · off | on in Strudel |
hardware | auto · click · off | click in Strudel |
Credits and licences#
Strudel is © its contributors (begun by Felix Roos and Alex McLean), free software under the GNU Affero General Public License v3: strudel.cc, source at codeberg.org/uzu/strudel. TidalCycles, whose way of writing patterns Strudel carries to the browser, is Alex McLean's. Music you make with Strudel is yours (Strudel's FAQ).
OAV is MIT-licensed and a separate program. It contains no Strudel code, does not load @strudel/* at run time and calls
none of Strudel's functions: it only looks at the page's address and at which names the page already has, to choose its
manners. The two meet the way two programs on a stage meet, over MIDI. OAV's own rhythm strings (startDrums("bd*4, hh*8"), @openav/pattern) are an independent implementation of the TidalCycles / Strudel way of writing rhythm, from their
public documentation. Please do not ask Strudel's community for help with OAV: OAV's issues are on
GitHub.
Phones, relay & surfaces#
Phones and iPads can be controllers: their motion sensors, a piano, and a TouchOSC-style control panel that grows from the world's params. Three packages do it, each usable alone:
| package | role |
|---|---|
@openav/relay | a zero-dependency WebSocket relay with rooms, and its browser client |
@openav/surface | the control-surface widgets, the layout JSON, autoSurface(params) |
@openav/remote | the phone app (tabs 感測 sensors · 琴鍵 keys · 控制台 control · MIDI) and the show-side adapter |
To the mapper, a phone is one more input: it publishes signals, and routes connect them to params.
Set it up#
- Run
node serve.json the show machine. It serves the pages and hosts the relay at/relay; nothing else is needed. - Open a show that declares
modules: { remote: true }, for examplehttp://localhost:8080/examples/08-remote-surface/. - The join card at the top left of the stage shows the URL a phone should open,
with the show machine's LAN address filled in (the page asks
serve.jsfor it at/__info) and the room:http://<lan-ip>:8080/packages/remote/?room=default. - Open that URL on a phone on the same network. The card counts connected phones.
The card also draws a QR code of that URL: point a phone's camera at it.
On openaudiovisual.com (and any static host)#
There is no serve.js there, so no relay of its own. A show that declares
modules: { remote: true } uses the public relay (wss://remote.cheyuwu.com/relay)
instead, and the card says so (public relay). Open
example 08 on the computer, scan the QR code
with the phone, and the phone's faders move the show.
- The room is random (
show-plus 10 characters), made once and kept in this browser'slocalStorage, so a reload keeps the same room and two visitors never share one. A room name declared in the show (room: 'main') is ignored here for that reason;?room=class-7in the page URL always wins, which is how a classroom puts everyone in one show. ?relay=wss://your.host/relayuses a relay of your own (the phone link carries it along);?relay=offturns the remote off for that page.- The choice is
pickRelay()in@openav/remote/relay-pick: this machine or its LAN (localhost,192.168.x.x,*.local…) keeps the same-origin/relayand works exactly as before; any other host gets the public relay. - The public relay has no accounts and no authentication. The room name is the only secret, so treat the QR code like one: whoever scans it can drive that show. Don't put it on a slide you share.
The phone app#
/packages/remote/ is a single page with a status header (connection, latency,
room, the phone's id) and tabs:
- 感測 (sensors): a tilt ball, acceleration and rotation readouts, a multi-touch pad, knocks (a sharp jolt of the phone), and light from the front camera. iOS needs a tap on 開始 (start) before it gives motion data.
- 琴鍵 (keys): a piano that is one row of about three octaves in landscape, and two stacked rows in portrait (the upper row continues the range). Vertical position on a key sets velocity; hold-to-sustain and octave buttons included.
- 控制台 (control): a control surface. Its layout comes from, in order: the
?surface=<layout.json url>query, the?meta=<json url>query (itsparamsgo throughautoSurface), whatever the show publishes over the relay, and finally a small generic panel so the page is never blank. - MIDI: a virtual MIDI controller, the same one the show displays (MIDI controllers & embedding).
| query | meaning |
|---|---|
room=<name> | the room to join (1–32 characters of A–Z a–z 0–9 _ -; anything else means default) |
tab=sense|keys|control|midi | the tab to open first |
surface=<url> | a layout JSON for the control tab |
meta=<url> | a JSON file with a params array; the control tab becomes autoSurface(params) |
relay=ws://… | a relay somewhere else than the page's own server (the QR code on the join card carries it when the show uses the public relay) |
Each phone keeps a short random id in localStorage; it is the <id> in
phone/<id>/….
iOS and HTTPS#
iOS Safari only gives motion sensors and the camera to pages served over
https://. Touch, keys and the control surface work over plain http:// on the
LAN. For tilt and knocks on an iPhone, reach serve.js through an HTTPS tunnel
and open the tunnel's address on the phone; the relay follows the page's origin
(wss:// on HTTPS pages). Android Chrome behaves the same for the camera.
The show side#
modules: { remote: true } in createShow() (or mountRemoteHost() from
@openav/remote/host) does five things, so the world stays ignorant of phones:
- joins the relay as the room's runner (the show);
- files every phone signal into
signals, and mirrorsphone/<id>/…tophone/any/…, which always holds the value from whichever phone sent it last; - builds the control surface:
world.surfaceif the world ships a layout, otherwiseautoSurface(world.params); and publishes it to every phone, including phones that join later; - adds the
surface/… → paramroutes to the mapper (skipping pairs that are already there); - sends param values back to the phones about 10 times a second, so faders follow the timeline and other controllers.
modules.remote option | default | meaning |
|---|---|---|
room | ?room= in the page URL, else 'default' | the room name |
auto | {} | options for autoSurface (pairs, meters, style, perPage, smooth, title) |
surface | world.surface | an explicit layout (wins over the world's) |
feedbackHz | 10 | how often param values are echoed |
relay | chosen by pickRelay() | 'wss://host/relay' for a relay of your own, false for no remote. Without it: same-origin /relay on this machine or LAN, the public relay on a static host. ?relay= in the page URL overrides it |
url | the same | alias of relay (and the option mountRemoteHost() takes: it appends role, room and id) |
Routes for sensors are written at author time with the phone/any/… alias,
because nobody knows a stranger's device id in advance (example 08):
await createShow({
world: bloomWorld,
routes: [
{ source: 'phone/any/tilt/x', target: 'drift', inMin: -1, inMax: 1, outMin: 0, outMax: 2, smooth: 0.12 },
{ source: 'phone/any/knock', target: 'burst' },
],
modules: { keys: { base: 48 }, remote: { auto: { pairs: [['cx', 'cy']], meters: ['density'] } } },
});show.remote is the host: { relay, layout, routes, bindings, room, publish(), frame(dt, state), connect(), dispose() }.
Surface widgets#
A surface turns fingers into named, normalized signals. Every widget publishes
under surface/<page>/<id>; when the widget's range is not 0..1, the unscaled
value also goes out as …/raw.
| type | publishes | notes |
|---|---|---|
fader | <id> | orient: v, h or auto (follows the cell's shape) · double-tap resets to def |
knob | <id> | grab the rim to turn, the middle to drag |
encoder | <id> (phase, wraps) and <id>/delta (turns) | endless |
button | <id> 1 / 0 | momentary; a pulse param fires on the rising edge |
toggle | <id> 1 / 0 | |
xy | <id>/x, <id>/y, <id>/down | spring: true returns to the centre |
bank | <id>/1 … <id>/N | count, labels; one finger paints across channels |
radio | <id> = index / (n − 1), raw = index | options: [...] |
pads | <id>/hit pulse { pad, row, col, vel }, <id>/<n> velocity while held | rows, cols (default 4 × 4), labels; n counts from 0; velocity = where you hit (top soft, bottom hard) |
keyboard | midi/note/on, midi/note/off, midi/cc/64 | the same names a hardware keyboard uses |
number | <id> | − / + buttons (hold repeats) and a field |
text | <id>, a string pulse | Enter or ↵ sends |
label, meter | nothing | display only; fed by surface.feedback(name, value) |
Common fields: id, type, label, min, max, def, step, unit,
color (cyan amber magenta lime violet coral white), and target (a param key,
or an array for xy and bank) for hand-written layouts.
The phone keyboard's notes are { note, vel, velocity, ch: 1, device: 'surface' }.
Layout JSON#
A layout is data, like a route: shareable as a file, writable by an agent, publishable by a world.
{ "version": 1, "title": "Bloom",
"pages": [{
"id": "main", "title": "Main", "grid": { "cols": 8, "rows": 4 },
"widgets": [
{ "id": "hue", "type": "knob", "label": "Hue", "x": 0, "y": 0, "w": 2, "h": 2, "min": 0, "max": 360, "def": 205, "color": "cyan", "target": "hue" },
{ "id": "pos", "type": "xy", "x": 2, "y": 0, "w": 4, "h": 4, "target": ["cx", "cy"] },
{ "id": "go", "type": "button", "label": "Burst", "x": 6, "y": 0, "w": 2, "h": 1, "target": "burst" }
],
"portrait": { "grid": { "cols": 4, "rows": 8 }, "place": { "pos": { "x": 0, "y": 2, "w": 4, "h": 4 } } }
}] }Phones get turned, so a page decides its arrangement like this:
- if the page has a
portraitorlandscapevariant for the current orientation, use its grid (widgets missing from itsplacekeep their base spot or fill free cells); - otherwise, if the base grid already has the right shape, use it;
- otherwise auto-reflow: the same widgets, repacked row by row into half (portrait) or double (landscape) the columns.
Rows are a count, not pixels, so the grid always fills the screen and nothing
ever scrolls. Widgets without x/y are packed into free cells with a default
size per type. validateLayout(layout) returns a list of problems (empty =
fine); new Surface() throws with that list on a bad layout.
autoSurface#
autoSurface(params, options) turns a world's params into a layout, the routes
that drive the params, and the bindings for feedback:
import { autoSurface, feedbackFor } from '@openav/surface';
const { layout, routes, bindings } = autoSurface(world.params, { pairs: [['cx', 'cy']], meters: ['density'] });
routes.forEach((r) => mapper.addRoute(r)); // surface/main/hue → hue, …
feedbackFor(state, bindings); // [{ name, value }] to echo back to the phoneHow each param becomes a widget:
| param | widget |
|---|---|
pulse: true | button |
step: 1 and a range of 1 (two values) | toggle |
step: 1 and three to seven values | radio (labels from options, else the numbers) |
a pair listed in pairs | one xy pad driving both |
| anything else | a fader when there are six continuous params or fewer, knobs when there are more |
surface: { type, color, label } on the param | whatever it says |
Options: pairs, meters (params to also show read-only), style ('auto',
'fader', 'knob'), perPage (default 8; a page that scrolls is a page that
fails), smooth (default 0.04 s on continuous routes, to hide 30 Hz network
steps), title. Each param group gets its own page.
Hand-written layouts#
Give a widget a target and routesFromLayout(layout, params) writes its route
(the same shape autoSurface produces): xy maps x to the first target and y to
the second, bank maps channel i to the i-th target. A world can carry its
layout as world.surface; the show side then uses it instead of generating one.
Theme and phone helpers#
Every control reads CSS custom properties on :root (THEME_VARS:
--oav-bg, --oav-surface, --oav-accent, --oav-radius, --oav-touch-min
(44 px) …). Override them to re-skin the whole surface. The rules: dark,
≥ 44 px touch targets, the live number always visible, pressed = border and glow
in the widget's color, navigator.vibrate where it exists.
@openav/surface also exports phone helpers: lockViewport() (no scrolling or
zooming), keepAwake() (Wake Lock), toggleFullscreen(), canFullscreen(),
haptic(ms).
The relay#
Clients connect to ws://<host>/relay?role=controller|runner|monitor&room=<name>&id=<device>.
A room is a name; peers in different rooms never hear each other.
| from | to | what |
|---|---|---|
| controller (a phone) | runner + monitor | signal and batch |
| runner (the show) | monitor | signal and batch |
| runner | controllers + monitors | feedback (meters, fader echo), config (the surface layout; replayed to phones that join later) |
| monitor | nobody |
Messages are JSON: {"type":"signal","name":"phone/ab12/tilt/x","value":0.3,"t":…,"pulse"?:true},
{"type":"batch","items":[…]}, ping/pong, status (peer counts),
feedback, config. Clients ignore types they do not know. When the runner
leaves, the room forgets its config. Messages over 256 KB are ignored, frames
over 1 MB close the connection, and a phone that stops answering pings (screen
locked, Wi-Fi gone) is dropped within about 40 s.
import { RelayClient, bindSignals } from '@openav/relay';
const show = new RelayClient({ role: 'runner', room: 'main' }).connect();
bindSignals(show, signals); // phone signals → Signals, + phone/any/… alias
show.feedback('surface/main/level', 0.4); // runner → phones
show.config('surface', { layout }); // runner → phones, kept for late joiners
const phone = new RelayClient({ role: 'controller', room: 'main' }).connect();
phone.set('phone/me/tilt/x', 0.3); // continuous: batched at 30 Hz, re-sent every second
phone.send('midi/note/on', { note: 60 }, true); // pulse: immediate, never coalescedContinuous values are re-sent once a second so a phone that joins late, or a show page reloaded mid-performance, is never stuck on stale defaults. Pulses are never batched or repeated: a note-on delivered twice is a bug. The client reconnects by itself (0.5 s, doubling up to 5 s).
fileSignal(signals, name, value, { pulse }) is the receiving half of the wire
format: it declares a name the first time it is heard (signalMeta() knows the
ranges of phone/…, surface/… and midi/… names), fills in whichever velocity
spelling a note message lacks (vel 0..1 or velocity 1..127), then pulses or
sets.
To run the relay without serve.js: node packages/relay/server.js [port]
(default 7458, health at /health), or attach it to your own Node server with
attachRelay(server, { path: '/relay' }) from @openav/relay/server.
The show page on a phone#
When the show itself runs on a phone, it can read the phone's own motion
sensors without a relay. localSensors(signals) publishes the same names under
phone/local/…, mirrored to phone/any/…, so routes written for "the latest
phone" work either way. Call .start() from a tap (iOS permission rules):
import { localSensors } from '@openav/remote';
const own = localSensors(show.signals);
button.onclick = async () => { await own.start(); if (own.denied) alert('motion access denied'); };Security#
The relay has no authentication. Anyone on the same network who knows the room name can join as a controller and move your params. For a show, use a closed network or a room name nobody will guess. On the public relay the room is random and lives in the QR code; it is a convenience for trying a show, not a lock.
Signals reference#
Signals are the single currency of the input layer: named, normalized, source-blind. This page lists every signal published by the built-in input packages.
Conventions:
- ranges are 0..1 unless noted
pulsesignals fire events; their value is the event payload- y axes are flipped where it makes performance sense (1 = raised), never raw pixels
<n>is a number,<ch>a MIDI channel 1..16,<id>a device id,<slug>a device name made URL-safe
@openav/midi#
Every hardware message publishes the legacy names and the per-channel names.
| signal | kind | range | meaning |
|---|---|---|---|
midi/note/on | pulse | {note, vel, velocity, ch, device} | key down: vel 0..1, velocity 1..127, ch 1..16, device = port slug |
midi/note/off | pulse | {note, ch, device} | key up |
midi/cc/<n> | continuous | 0..1 | controller n (any channel) |
midi/bend | continuous | -1..1 | pitch bend (any channel) |
midi/ch/<ch>/cc/<n> | continuous | 0..1 | controller n on one channel |
midi/ch/<ch>/note/<n> | continuous | 0..1 | velocity while note n is held, 0 on release |
midi/ch/<ch>/bend | continuous | -1..1 | pitch bend on one channel |
midi/ch/<ch>/pressure | continuous | 0..1 | channel aftertouch |
midi/ch/<ch>/poly/<n> | continuous | 0..1 | polyphonic aftertouch on note n |
With two or more MIDI inputs connected, each device also publishes under its
slug (from the port name: Arturia MiniLab 3 → arturia-minilab-3, duplicates
get -2, -3):
| signal | kind | range | meaning |
|---|---|---|---|
midi/<slug>/note/on | pulse | {note, vel, ch} | key down on that device |
midi/<slug>/note/off | pulse | {note, ch} | key up on that device |
midi/<slug>/cc/<n> | continuous | 0..1 | controller n on that device |
midi/<slug>/bend | continuous | -1..1 | pitch bend on that device |
On-screen controllers (modules.midi.controllers) publish one name per
control, midi/<device>/<control> (for example midi/minilab3/knob1), plus
/hit pulses for pads and /raw values. They are listed in
MIDI controllers & embedding.
@openav/keys#
The on-screen piano, QWERTY playing and the simulated performer publish the legacy note names with a smaller payload:
| signal | kind | range | meaning |
|---|---|---|---|
midi/note/on | pulse | {note, vel, ch: 0} | key down (vel 0..1) |
midi/note/off | pulse | {note, ch: 0} | key up |
@openav/chord#
| signal | kind | range | meaning |
|---|---|---|---|
chord/consonance | continuous | -1..1 | harmonic consonance of last gesture |
chord/count | continuous | 0..10 | notes in last gesture |
chord/root | continuous | 0..127 | lowest MIDI note of last gesture |
chord/event | pulse | full analysis | {notes, count, root, vel, pcs, consonance, isConsonant, isDissonant, isTriad, thirdsFraction, dissonanceLevel, chordType} |
chordType: single · major · minor · sus2 · sus4 · dim · aug · maj7 ·
min7 · dom7 · halfdim7 · maj9 · min9 · dom9 · six · min6 ·
cluster (very dissonant pile) · chord (anything else). Inversions resolve to
their root-position name via pitch-class rotation.
dissonanceLevel: 0 none · 1 mild (storm warning — foreshadow) · 2 severe
(true cluster — commit to the decay language). Born from a real audience note at
the IRCAM × C-LAB performance in Taipei: "dissonance detection wasn't strict enough."
@openav/audio#
| signal | kind | range | meaning |
|---|---|---|---|
audio/rms | continuous | 0..1 | loudness (smoothed) |
audio/peak | continuous | 0..1 | instantaneous peak |
audio/band/low | continuous | 0..1 | 20–250 Hz energy |
audio/band/mid | continuous | 0..1 | 250 Hz–2 kHz energy |
audio/band/high | continuous | 0..1 | 2–8 kHz energy |
audio/centroid | continuous | 0..1 | spectral brightness |
audio/onset | pulse | {rms} | transient over adaptive floor (100 ms refractory) |
audio/kick | pulse | {level} | 20–120 Hz transient (90 ms refractory) |
audio/snare | pulse | {level} | 150–800 Hz transient (90 ms refractory) |
audio/hat | pulse | {level} | 6–14 kHz transient (60 ms refractory) |
audio/kick/env /snare/env /hat/env | continuous | 0..1 | decay envelope per drum (map straight onto params) |
@openav/pose#
Mirrored by default (webcam-as-mirror). y flipped: 1 = raised.
| signal | kind | range | meaning |
|---|---|---|---|
pose/present | continuous | 0/1 | someone visible |
pose/hand/left/x /y | continuous | 0..1 | left wrist (viewer's left) |
pose/hand/right/x /y | continuous | 0..1 | right wrist |
pose/hand/left/v right/v | continuous | 0..1 | wrist speed |
pose/hands/spread | continuous | 0..1 | wrist-to-wrist distance |
pose/height | continuous | 0..1 | nose height (crouch ↔ stand) |
pose/lean | continuous | -1..1 | shoulder-line tilt |
@openav/pose — HandTracker (21-landmark fine control)#
Per-hand pinch distances = four precise continuous controllers across two hands. Normalized by palm size, so distance to the camera doesn't change your pinch.
| signal | kind | range | meaning |
|---|---|---|---|
hand/left/present right/… | continuous | 0/1 | hand visible |
hand/left/x /y | continuous | 0..1 | palm position (y: 1 = raised) |
hand/left/pinch/index | continuous | 0..1 | thumb-tip ↔ index-tip (0 = touching) |
hand/left/pinch/middle | continuous | 0..1 | thumb-tip ↔ middle-tip |
hand/left/spread | continuous | 0..1 | index-tip ↔ pinky-tip spread |
(hand/right/* mirrors the set. Both trackers expose skeleton(ctx, w, h) overlays.)
@openav/leap#
Leap Motion / Ultraleap through the machine's bridge. Positions are the palm or
fingertip inside the interaction box, normalized and clamped (x right, y up,
z toward the performer). <side> is left or right, <finger> one of
thumb index middle ring pinky.
| signal | kind | range | meaning |
|---|---|---|---|
leap/status | continuous | 0..3 | 0 no bridge · 1 bridge, no tracking service · 2 no device · 3 tracking |
leap/hands | continuous | 0..2 | hands in view |
leap/hand/<side>/present | continuous | 0/1 | hand in view |
leap/hand/<side>/x /y /z | continuous | 0..1 | palm position |
leap/hand/<side>/pinch /grab | continuous | 0..1 | pinch and fist strength (0 when the hand is lost) |
leap/hand/<side>/roll /pitch /yaw | continuous | -π..π | hand rotation in radians |
leap/hand/<side>/speed | continuous | 0..1 | palm speed, 1 = 1 m/s |
leap/hand/<side>/<finger>/x /y /z | continuous | 0..1 | fingertip position |
leap/both/distance | continuous | 0..1 | palm to palm, 1 = 40 cm |
leap/hand/<side>/pinch-start /pinch-end | pulse | {side, x, y, z, strength} | pinch above 0.86 starts, below 0.5 ends |
leap/hand/<side>/grab-start /grab-end | pulse | {side, x, y, z, strength} | fist above 0.8 starts, below 0.5 ends |
leap/both/pinch-start /pinch-end | pulse | {x, y, z, distance} | both pinch above 0.8 enters, either below 0.5 exits |
@openav/drums (simulator — analyzer-shaped)#
The drum machine publishes the SAME shapes the audio analyzer publishes, so a
world mapped to a kick cannot tell mic from machine (that's what a simulator
is for). Sequencer hits also travel as midi/note/on (ch 10, GM notes).
| signal | kind | range | meaning |
|---|---|---|---|
drum/kick /snare /hat /clap | pulse | {level} | a sequencer hit |
drum/kick/env … | continuous | 0..1 | decay envelope per lane (map straight onto params) |
midi/note/on | pulse | {note, vel, ch: 10} | kick 36 · snare 38 · clap 39 · closed hat 42 |
@openav/sound (output events)#
The in-page instruments listen to midi/note/on|off and the sustain pedal
midi/cc/64, and announce one event of their own.
| signal | kind | range | meaning |
|---|---|---|---|
sound/instrument | pulse | {id, name} | the instrument changed (picker, code or a cue) |
@openav/record (take playback)#
A TakePlayer publishes a recorded take's events under their original names
(midi/note/on, midi/ch/<ch>/note/<n>, midi/cc/<n>…), exactly as the
keyboard did, plus two signals of its own.
| signal | kind | range | meaning |
|---|---|---|---|
take/playing | continuous | 0 / 1 | 1 while a take plays; 0 when paused, stopped, or yielded to live playing |
take/position | continuous | 0..1 | how far into the take |
@openav/remote · @openav/surface#
Phone sensors (/packages/remote/ → 感測). <id> is the device id; phone/any/… carries the value from whichever phone sent it last, so a
World's routes can be written before any phone exists.
| signal | kind | range | meaning |
|---|---|---|---|
phone/<id>/tilt/x /y | continuous | -1..1 | ±45° from the calibrated rest pose, in SCREEN axes (x right, y toward you) |
phone/<id>/accel/x|y|z | continuous | m/s² | including gravity |
phone/<id>/rot/alpha|beta|gamma | continuous | deg/s | rotation rate |
phone/<id>/orient/alpha|beta|gamma | continuous | deg | raw deviceorientation |
phone/<id>/knock | pulse | {strength, delta, t} | acceleration jolt over a threshold (130 ms cooldown) |
phone/<id>/light | continuous | 0..1 | front-camera mean luma |
phone/<id>/touch/x /y /down | continuous | 0..1 | finger 0; touch/<n>/… for n ≥ 1, touch/count |
phone/local/… | — | — | the same sensor names when the show page ITSELF runs on a phone (localSensors(signals), no relay; also mirrored to phone/any/…) |
Surface widgets (normalized 0..1; …/raw carries the unscaled value when the range is not 0..1):
| signal | kind | meaning |
|---|---|---|
surface/<page>/<id> | continuous | fader · knob · number · toggle · button (0/1) · radio (idx/(n-1)) · encoder phase |
surface/<page>/<id>/x /y /down | continuous | xy pad |
surface/<page>/<id>/<1..N> | continuous | bank channels |
surface/<page>/<id>/hit | pulse {pad,row,col,vel} | pads; …/<n> (n from 0) holds the velocity while down |
surface/<page>/<id>/delta | continuous | encoder step in turns |
surface/<page>/<id> (text) | pulse | the string typed into a text widget |
midi/note/on /off, midi/cc/64 | pulse / continuous | the 琴鍵 page and the keyboard widget — same names and shape as @openav/midi: {note, vel, velocity, ch: 1, device: 'surface'} |
Receiving end: every relay message is filed by fileSignal(signals, name, value, {pulse}) (@openav/relay; bindSignals
uses it, so do hosts with their own socket such as the lab's lab.js). It declares the name on first sight (signalMeta),
and fills in whichever velocity spelling a midi/…/note/on|off sender left out (vel 0..1 ⇄ velocity 1..127).
Naming your own#
Path-style, source-first: breath/pressure, phone/{id}/gyro/x, weather/wind.
Declare ranges with signals.define(name, {min, max}) so meters and norm()
work; then just signals.set(name, v). Anything that changes can perform.
Package reference#
Twenty-seven packages live in packages/<name>/. Each is plain ES modules with no
dependencies; in a page you import them as @openav/<name> through an import
map (copy the one in examples/01-hello-particles/index.html). Nothing is on
npm yet.
| layer | packages |
|---|---|
| L1 input | midi · audio · chord · pose · leap · keys · drums |
| L2 mapping | mapping · chop |
| L3 world | stage · world-webtoe |
| L4 output | sound · osc · out · record · fab (and MIDI out in midi) |
| spines | timeline · score · pattern · console · monitor |
| phones | relay · surface · remote |
| glue | core · show · mcp |
Subpath entries (@openav/remote/host, @openav/relay/server,
@openav/surface/layout, @openav/surface/auto) need their own import-map line
in a browser, for example
"@openav/remote/host": "../../packages/remote/host.js?v=b59edc6".
@openav/core#
Glue · the shared language: Signals, Params, Loop, Bus.
| export | signature |
|---|---|
Signals | new Signals() · define(name, { kind, min, max, unit, description, source }) · set(name, value) · pulse(name, payload = 1) · get(name) · norm(name) → 0..1 · on(name, cb(value, name)) → unsubscribe · onAny(cb(name, value, meta)) → unsubscribe · list() → [{ name, value, at, kind, min, max, … }] |
Params | new Params(schema = []) · add(entries) · get(key) · clamp(key, v) · override(key, v) · clearOverride(key) · clearAllOverrides() · isOverridden(key) · firePulse(key) · onPulse(key, cb) → unsubscribe · resolve(base) → state · schema, overrides |
Loop | new Loop(onFrame(dt, now), { maxDt = 0.1 }) · start() · stop() · fps |
Bus | new Bus() · on(event, cb) → unsubscribe · off(event, cb) · emit(event, payload) |
import { Signals, Params, Loop } from '@openav/core';
const signals = new Signals();
signals.define('breath/pressure', { min: 0, max: 1 });
signals.on('breath/pressure', (v) => console.log(v));
signals.set('breath/pressure', 0.4);@openav/show#
Glue · createShow(options): one call that assembles a whole show around a
world and returns every part. Options, modules and the return value are in
Show control.
import { createShow } from '@openav/show';
const show = await createShow({ world, modules: { keys: { base: 48 }, sound: true } });@openav/midi#
L1 input and L4 output · Web MIDI in and out, many devices at once, and on-screen controllers that mirror real hardware.
| export | signature |
|---|---|
Midi | new Midi({ signals, filterOut = 'IAC', requestAccess, echoWindow = 40, prefer = [] }) · enable(preferredOut = '') → Promise<boolean> · devices() → [{ slug, name, listening }] · setListening(slug, on) · onDevices(fn) · onOutput(fn({ text, reason, next })) · listen(fn(bytes, port)) · selectOutput(name) → boolean (and remembered as your choice) · outputFor(nameOrRegExp) · send(bytes) · noteOn(note, vel = 100, ch = 1) · noteOff(note, ch = 1) · cc(cc, val, ch = 1) · panic() · dispose() · hooks onCC, onNote, onMessage, onSend, onDeviceChange · out, outputs, preferredOut, echoDropped |
| output hot-plug, echo | pickOutput(outputs, { preferred, prefer }) · reselectOutput({ current, outputs, preferred, prefer }) → { next, changed, reason, text } · EchoGuard({ window }) (sent(bytes), isEcho(bytes)): the pure logic behind Midi, see Show control |
| score to MIDI | new ScoreMidi({ midi, channel = 15, segment = { note: 36 }, cue = { note: 84 }, length, scrub, enabled }) · segment({ index, cause }) · cue({ name, n }): segment changes and cues as notes and CCs, see Score & Director |
| parsing | parseMessage(bytes) → event · encodeMessage(event) → bytes · genericSignals(event, { device }) · publish(signals, list) · describe(event) · relativeDelta(value, mode), relativeValue(delta, mode), RELATIVE_MODES · bendToUnit, unitToBend, BEND_CENTER |
| controllers | MidiController, MidiControllers, controllerRoutes(profile, bindings), PROFILES, profileById(id), ControllerView, mountMidiPanel(el, controllers, opts), linkControllers, profile helpers (validateProfile, normalizeProfile, matchProfile, …) |
| Web MIDI shim | createVirtualMIDIAccess({ name, onOutput(bytes, timestamp), onClear }), virtualRequestMIDIAccess, splitMidi(bytes): its output is heard with each send's timestamp |
MidiClock (clock.js) | new MidiClock({ ppq = 24, beatsPerBar = 4 }) · feed(bytes, t) → 'clock'·'start'·'continue'·'stop'·'spp'·'dupe' · bpm · phaseAt(t) → { pos, bar, beat, bpm, running } · anchor() · state(t): follow an external MIDI clock (With Strudel) |
| embedding | createController(profile, { profiles, signals, channel, follow, midi, requestAccess, filterOut, telemetry }) → ControllerHost (on(event, fn), set, press, release, values, mount(el), learn(), dispose()) · loadProfile(idOrUrl) · the <oav-controller> element in packages/midi/element.js · embed protocol helpers in embed.js |
import { Midi } from '@openav/midi';
const midi = new Midi({ signals });
await midi.enable();
midi.noteOn(60, 100, 1);Signals: Signals reference. Controllers, profiles and the shim: MIDI controllers & embedding.
@openav/audio#
L1 input · microphone (or any audio node) analysis.
| export | signature |
|---|---|
AudioAnalyzer | new AudioAnalyzer({ signals, fftSize = 2048, smooth = 0.7 }) · enableMic() · enableElement(mediaEl) · input() → an AnalyserNode to connect into · update() per frame → { rms, peak, low, mid, high, centroid, onset, kick, snare, hat } |
import { AudioAnalyzer } from '@openav/audio';
const audio = new AudioAnalyzer({ signals });
await audio.enableMic(); // from a click
// every frame: audio.update();@openav/chord#
L1 input (an analyzer) · groups struck notes into gestures and publishes consonance, triads and clusters.
| export | signature |
|---|---|
ChordDetector | new ChordDetector({ window = 80, onChord, signals }) · noteOn(note, vel = 0.8) · noteOff(note) · analyze(notes, vels) → analysis (pure) |
import { ChordDetector } from '@openav/chord';
const chord = new ChordDetector({ signals });
signals.on('midi/note/on', ({ note, vel }) => chord.noteOn(note, vel));
signals.on('midi/note/off', ({ note }) => chord.noteOff(note));
chord.analyze([60, 64, 67]).chordType; // 'major'@openav/pose#
L1 input · body and hand tracking from the camera with MediaPipe, on the device.
| export | signature |
|---|---|
PoseTracker | new PoseTracker({ signals, mirror = true }) · enable({ videoEl }) (from a click) · stop() · skeleton(ctx, w, h, { color, lineWidth }) · landmarks, running |
HandTracker | new HandTracker({ signals, mirror = true }) · enable({ videoEl }) · stop() · skeleton(ctx, w, h, { colorLeft, colorRight, lineWidth }) · hands, running |
import { HandTracker } from '@openav/pose';
const hands = new HandTracker({ signals });
button.onclick = () => hands.enable();@openav/leap#
L1 input · Leap Motion / Ultraleap hands through the machine's bridge
(node packages/leap/bridge/leap-bridge.mjs, ws://127.0.0.1:6437). The same
bridge speaks the old LeapJS protocol, so 2014–2021 leap.js sketches run unchanged.
| export | signature |
|---|---|
LeapInput | new LeapInput({ signals, url, reconnect = true, fingers = true, box, thresholds }) · connect() · disconnect() · simulate({ target }) → stop() · skeleton(ctx, w, h, { view, colorLeft, colorRight, lineWidth }) · mountPanel(el) · onFrame(fn) · onStatus(fn) · ingest(msg) · hands, frame, status, device |
| frames | V6Converter (raw → LeapJS v6) · handsOf(msg) · normalizePoint(p, box) · handAngles(direction, normal) · mockHand(o) · mockFrame(t) |
| gestures | HandGestures · Hysteresis(on, off) · THRESHOLDS (pinch 0.86 / 0.5, both hands 0.8 / 0.5, grab 0.8 / 0.5) |
import { LeapInput } from '@openav/leap';
const leap = new LeapInput({ signals }).connect(); // ws://127.0.0.1:6437/v6.json
leap.onFrame(() => leap.skeleton(ctx, w, h)); // joints in mm: leap.hands
// no sensor: leap.simulate() — mouse = right palm, press = pinch@openav/keys#
L1 input · the on-screen piano, QWERTY playing and a simulated performer, all
publishing midi/note/on|off.
| export | signature |
|---|---|
mountKeys | mountKeys(container, { signals, chord, base = 48, octaves = 2, sim = true, capture = true }) → { piano, sim, update(dt), dispose(), captureBox } |
KeysPiano | new KeysPiano(container, { base, octaves, semitones, fill, keyWidth, velocity, minBase, maxBase, onNote, onOctave }) · press(note, vel01) · release(note) · releaseAll() · setBase(n) · handleKeyDown(e), handleKeyUp(e) · captureEnabled |
SimPlayer | new SimPlayer({ press, release, base = 60, scale = [0, 2, 4, 7, 9], density = 1 }) · toggle(on) · update(dt) |
PIANO_KEYMAP | { a: 0, w: 1, s: 2, … ';': 16 } |
import { mountKeys } from '@openav/keys';
const keys = mountKeys(document.querySelector('#keys'), { signals, base: 48 });
// every frame: keys.update(dt);@openav/drums#
L1 input and L4 output · a synthesized drum kit, a 16-step sequencer, and its
grid UI; publishes drum/* in the same shapes as the microphone's drum
detection.
| export | signature |
|---|---|
mountDrums | mountDrums(container, { signals, engine, autoEnableEngine = true, onStep, onHit, onTakeover, bpm = 112, pattern, clock = 'frame', lang }) → { seq, engine, root, clock, play(on), setPattern(p, { text }), setBpm(bpm), update(dt), deliver(), phase(), now(), info(), dispose() }; clock: 'lookahead' schedules the hits ahead on the kit's audio clock (oav-p5 0.6 uses it) and plays a compiled rhythm line as written; lang: 'zh' names the lanes in Chinese; rhythm: <@openav/pattern> (or useRhythm(mod)) makes the line over the grid editable and the cells rewrite it, with history / undo(), Copy and Open in Strudel (0.7) |
DrumSequencer | new DrumSequencer({ onHit(lane, vel), onStep(i, n), bpm = 112, swing = 0.12, humanize = 0.35, pattern = 'four on floor' }) · setPattern(nameOrGrid) · toggleCell(lane, i) · toggle(on) · update(dt) · grid, bpm (the frame clock) |
DrumScheduler, DrumTransport | the lookahead clock: a grid or a compiled line → timed events, positions in bars turned into seconds by a tempo map (no drift); the runtime with a Web Worker heartbeat, the audio clock, and steps / hits told when heard; follow({ pos, at, bpm }) plays on an external clock (a small error pulled in by tempo, a large one a jump onto its grid), followInfo(), unfollow() |
drumEngine | drumEngine({ samples }) → a sound engine (see Sound); noteOn(note, vel, time) plays at time on its own AudioContext and returns { time, cancel() } |
PATTERNS, GM | pattern presets; General MIDI drum notes { kick: 36, snare: 38, clap: 39, tom: 45, hat: 42, openhat: 46 } |
import { mountDrums } from '@openav/drums';
const drums = mountDrums(document.querySelector('#drums'), { signals });
// every frame: drums.update(dt);@openav/mapping#
L2 mapping · routes from signals to params, with curves, smoothing, learn and saved profiles.
| export | signature |
|---|---|
Mapper | new Mapper({ signals, params, profile = 'default', onChange, onLearn }) · addRoute(route) · removeRoute(id) · routesFor(target) · learn(target) · update(dt) · toJSON() · fromJSON(routes) · save() · load() · dispose() · routes, learnTarget |
LearnWizard | new LearnWizard({ mapper, keys, onChange }) · start() · skip() · back() · stop() · sync() · active, current, index, total, done, added: "map the knobs in order", see Score & Director |
import { Mapper } from '@openav/mapping';
const mapper = new Mapper({ signals, params });
mapper.addRoute({ source: 'midi/cc/74', target: 'bloom', curve: 'exp', smooth: 0.1 });
// every frame: mapper.update(dt);Full reference: Mapping.
@openav/chop#
L2 mapping · values over time, the way TouchDesigner's CHOPs shape them: Lag, Trigger (envelope), Speed, Spring, Trail, Slope, Count. Pure logic, no clock of its own: every step is told the seconds that went by and is solved exactly over them, so 30 fps and 144 fps draw the same curve. What the parameters mean follows Derivative's TouchDesigner documentation; the code is OAV's own. In a p5 sketch, oav-p5 gives these one call each: p5 one line.
| export | signature |
|---|---|
approach | approach(current, target, seconds, dt) → one step of a one-pole lag; seconds = time to 63% of a change (the smooth of @openav/mapping, which steps with it) |
Lag | new Lag({ up = 0.1, down = up }) · step(input, dt, up?, down?) → output · reset(v?): up while rising, down while falling (Lag CHOP's Lag 1 / Lag 2), in TouchDesigner's seconds (about 90% of a change) |
envelope | envelope(times, t, attack = 0.01, decay = 0.3, peak = 1) → the Trigger CHOP's level at t for events at times (seconds, oldest first): a straight attack (0 → peak), then 0 after exactly decay; a retrigger starts where it is · envelopeStage(elapsed, attack, decay, from, peak) |
Speed | new Speed() · step(rate, dt) → position: rate per second |
Spring | new Spring({ stiffness = 100, damping = 6 }) · step(target, dt) → position (velocity alongside): a mass on a spring pulled to the input, solved exactly · springStep(x, v, target, k, c, dt) → [x, v] |
Trail | new Trail({ seconds = 2, rate = 60 }) · step(value, dt) → the last seconds, 60 values a second plus the newest, oldest first, fixed length |
Slope | new Slope() · step(value, dt) → change per second |
Count | new Count({ threshold = 0.5 }) · step(on, limit = 0) → times it turned on, looped to 0 … limit − 1 with a limit · wrapCount(total, limit) |
TD_LAG_RATIO, DEFAULTS | Math.LN10: two kinds of seconds. Lag and lag() take TouchDesigner's Lag CHOP seconds (about the time to follow 90% of a change), so lag(x, 0.1) moves like a Lag CHOP set to 0.1; underneath, Lag divides by TD_LAG_RATIO (2.3) to get its time constant. approach(…, seconds) takes that time constant itself (63% of a change, what @openav/mapping's smooth always meant); the defaults above |
import { Lag, envelope } from '@openav/chop';
const vu = new Lag({ up: 0.03, down: 0.4 }); // opens at once, closes slowly
// every frame: r = 50 + vu.step(level, dt) * 300; pump = envelope(kickTimes, now, 0.01, 0.3);@openav/stage#
L3 world · the world shell: lifecycle and param aggregation, no renderer.
| export | signature |
|---|---|
Stage | new Stage({ container, params, signals, io = {} }) · register(world) · activate(name) (async) · frame(dt, baseState) → state · active, activeName, worlds, io |
createCanvas | createCanvas(container, { alpha = false }) → { canvas, ctx, fit() → { w, h }, dispose() } |
import { Stage, createCanvas } from '@openav/stage';
const stage = new Stage({ container, params, signals });
stage.register(world);
await stage.activate(world.name);
// every frame: const state = stage.frame(dt, timeline.state());@openav/world-webtoe#
L3 world · performs a WebToe node patch
as a world; params reach the patch's ext('name', fallback) bindings.
| export | signature |
|---|---|
webtoeWorld | webtoeWorld({ name = 'webtoe', app = 'https://webtoe.openaudiovisual.com/', project, params = [], extra }) → a world |
See Writing a world.
@openav/sound#
L4 output · instruments in the page behind a small engine contract: a Salamander
grand piano, mallets, strings, organ, choir, harp and synth presets, chosen from a
picker; engine params become sound/* params.
| export | signature |
|---|---|
Sound | new Sound({ signals, params, engine, instrument, remember, picker }) · enable() (from a click) · update(state) per frame · setInstrument(id) · instrument · status · onChange(cb) · dispose() · enabled |
toneEngine | toneEngine({ instrument = 'synth/pad', baseUrl, instruments, cdn, drumChannel = 10 }) → engine with params cutoff, space, volume plus the instrument's own |
registerInstrument | registerInstrument({ id, name, category, params, defaults, credit, create(Tone, ctx) }, { replace }); also getInstrument, listInstruments, instrumentGroups, onInstrumentsChange |
registerSynthPreset | registerSynthPreset({ id, name, voice, oscillator, envelope, filter, effects, … }) → synth/<id> |
modalInstrument | modalInstrument({ id, name, partials, strike, damp }) → a mallet-style instrument from data |
mountSoundPicker | mountSoundPicker(el, sound, { lang, className }) → the picker the console uses |
Engine contract: { params, enable(), noteOn(note, vel01), noteOff(note), set(key, value), dispose() }.
The instrument list and how to add one: Sound.
import { Sound, toneEngine } from '@openav/sound';
const sound = new Sound({ signals, params, engine: toneEngine({ instrument: 'piano' }) });
button.onclick = () => sound.enable();
// every frame: sound.update(state);@openav/osc#
L4 output · OSC from the browser through a small Node bridge to UDP.
| export | signature |
|---|---|
OscOut | new OscOut(url = 'http://127.0.0.1:7456') · enable() → Promise<boolean> · disable() · send(addr, args) · flush() per frame · rate() → messages sent since the last call · hooks onStatus(ok, info), onMsg(addr, args) |
| bridge | node packages/osc/bridges/osc-bridge.js [httpPort=7456] [targetHost=127.0.0.1] [targetPort=3456] |
See Show control.
@openav/out#
L4 output · page parameters to physical hardware through one local bridge
(node packages/out/bridge/out-bridge.mjs --config out.json, ws://127.0.0.1:6450/out): WLED light strips (DDP),
serial motor boards, OSC. A watchdog sends every channel its configured safe value when the page goes quiet or the last client leaves.
| export | signature |
|---|---|
OutClient | new OutClient({ url, id, schedule }) · connect() · simulate() · close() · set(ch, v) · batch({ ch: v }) · onStatus(fn) · status, last, bridge — one value per channel per frame, reconnects with backoff, heartbeat |
| bridge | node packages/out/bridge/out-bridge.mjs [--port 6450] [--config out.json] [--no-watchdog] · sinks ddp serial osc udp-json log |
| lab | meta.params[i].out: "light/1/r" binds a param (0..1 by min–max, outRaw for the raw value); ?out=sim · ?out=0 · ?out=ws://… |
See the package README for the config file and the watchdog rules.
@openav/record#
L4 output (video branch) · the performance video: the work and the performer's
camera composited into one canvas of an exact size (vertical 1080×1920, 4:5,
2.7K, 4K, landscape, square) and recorded with the work's own sound by one
MediaRecorder, so picture and sound are in sync by construction; plus the
sound as its own file, an event log, and MIDI takes (record what you play, play
it back into the show, .mid in and out).
| export | signature |
|---|---|
Compositor | new Compositor({ size = 'vertical-1080p', layout = 'stack', layoutOptions, getWorkCanvas, background, mirrorCam = true, maxFps = 60, onDraw(ctx, rects, comp) }) · start() · stop() · draw() · setSize(size) · setLayout(layout, options) · setCamera(stream | video | null) · drawOnCamera(fn(ctx, w, h)) · canvas, rects, video |
Recorder | new Recorder({ canvas, fps = 60, audioTracks = [], audioOnly = false, videoBitsPerSecond, mimeType }) · start({ timeslice = 1000, ondata(blob, seq), keep }) · stop() → Promise<{ mimeType, ext, bytes, durationMs, width, height, blob, … }> · t0 |
AudioTap | new AudioTap({ context, sources, captureDestination }) · add(node | stream | track, { bus }) · captureDestination() · addMicrophone() · level() · tracks (work + mic), workTracks (work only) |
EventLog | new EventLog({ now, filter, throttleMs }) · start(at) · stop() · add(name, value) · attach(signals) · toJSON() · toTake() |
TakeRecorder · TakePlayer | new TakeRecorder({ signals, filter = /^midi\//, overdub }) · start() · stop() → take · new TakePlayer({ signals, take, loop = true, speed = 1, yieldToLive, resumeAfter, clock }) · play() · pause() · stop() · seek(ms) · update(dt) |
| pure helpers | layoutRects(layout, out, src, opts) · PRESETS · presetById · resolveSize · recommendedFps(w, h) · recommendedBitrate(w, h, fps) · pickMime(isTypeSupported, { audioOnly }) · extFor(mime) · normalizeTake · validateTake · trimSilence · quantizeTake · toMidiFile(take) → Uint8Array · fromMidiFile(bytes) → take |
| cameras | openCamera({ deviceId, width = 1920, height = 1080, fps = 30 }) → MediaStream · listCameras() · listMicrophones() |
Layouts: stack (work on top at full width, camera below), pip (camera inset
in a corner), side (work left, camera right), work. 60 fps up to about
1080×1920, 30 fps for 2.7K and 4K; webcams stop at 1080p, so a 4K frame
enlarges the camera.
import { Compositor, AudioTap, Recorder, openCamera } from '@openav/record';
const comp = new Compositor({ size: 'vertical-1080p', layout: 'stack', getWorkCanvas: () => canvas });
comp.start();
await comp.setCamera(await openCamera());
const tap = new AudioTap({ context, sources: [workOutput] });
const rec = new Recorder({ canvas: comp.canvas, fps: 60, audioTracks: tap.tracks }).start();
// later: const { blob, mimeType } = await rec.stop();Package README with the MIDI takes: packages/record. Example: 10-record.
@openav/fab#
L4 output (physical branch) · can it be made? A manufacturability report for any mesh (watertight, volume, overhang, thinnest wall, grams of PETG / PLA, one Chinese warning per problem) and STL / OBJ / GLB files; STEP and DXF when the shape is a smlibwasm Part or Shape. smlibwasm and manifold-3d are not bundled: smlib() / manifold() import() them from a URL on first use.
| export | signature |
|---|---|
Fab | new Fab({ urls: { smlib, manifold }, scale, importer, download, context }) · check(x, opts) → report · export(x, fmt, opts) → Promise<{ bytes, mime, ext, report }> (stl obj glb step dxf) · save(x, fmt, { name, params, note }) through saver(bytes, info) or a browser download · smlib() · manifold() · meshOf(x) |
| pure functions | checkMesh(mesh, { angle = 45, minWall = 1.2, bed = [320, 320, 325], wallSamples = 2000 }) · weldMesh(mesh) · meshOf(x, { scale }) (TriMesh, smlibwasm Part, three.js Mesh/Object3D, Y up becomes Z up) · toStlBytes · toObjText · toGlbBytes |
@openav/timeline#
Spine · automation, scenes and transport; pure logic.
| export | signature |
|---|---|
Timeline | new Timeline({ params, automation = {}, scenes = [], total = 600, score = null, start = null, loop = false, onCue = null }) · play() (releases a hold), pause(), toggle(), seek(t), jumpScene(d), next() (releases a hold, else a scene on), prev(), release(), reset() (to start, default 0) · advance(dt) · state(t), valueAt(key, t) · sceneIndexAt(t), currentScene(t), sceneEnd(i) · onSceneChange(cb(i, scene, { cause })), onSeek(cb(t, kind)) · layer(key, value, t) · keyframes [t, value, curve?] ('linear' · 'ease' · 'in' · 'out' · 'step') · loop (true, 'scene', a scene id, { from, to }), onLoop(cb) · cues(), onCue(cb(cue, { cause })), fireCue(cue) · record(key, source), stopRecording(), onRecord(cb), mergeTake(key, take, opts) · t, playing, holding, rate, total, start, score |
A keyframe's third item is the curve of the segment that ends at it; a keyframe without one is linear, exactly as before. A scene's cues fire when playback passes them, never on a seek. record() writes a value source (a knob override) into a lane while the timeline plays. CURVES is exported too.
See Show control.
@openav/score#
Spine · the score and the director: a show written as segment lengths, and the modules that perform the segments. Pure logic, no DOM.
| export | signature |
|---|---|
Score | new Score({ cuts, cut }) · table() · total · segmentAt(T), indexAt(T) · cueTime('seg.name') · cuesBetween(T0, T1) · cues() · scenes() · validate() · cuts, cut, segments, startT, scale, baseTime(T) |
ScoreError | thrown by an invalid score; problems lists every reason |
Director | new Director({ score, modules, api, onStatus, onCue, maxStep }) · update(T, dt, { holding, playing }) · param(key, value, T) · seek(T) · reset() · status() · register(module) |
import { Score, Director } from '@openav/score';
const score = new Score({ cuts, cut: 'full' });
const director = new Director({ score, modules, api });
// every frame, after timeline.advance(dt):
director.update(timeline.t, dt, { holding: timeline.holding, playing: timeline.playing });Full reference: Score & Director.
@openav/pattern#
Spine · rhythm in one line: mini-notation, the way TidalCycles and Strudel write rhythm, read into the hits of each bar. Pure logic. An independent implementation from their public documentation (the writing comes from TidalCycles and Strudel).
| export | signature |
|---|---|
compile | compile(src, { words = WORDS }) → { ok, error, src, voices, layers, period, eventsInBar(bar, { seed = 0 }) }, never throws · eventsInBar (bars from 1) → [{ word, voice, n, pos, dur, vel, layer, token: { start, end } }] sorted by pos (0..1 in the bar) · error → { code, index, length, message, hint }, code one of empty unclosed unexpected bad-number bad-euclid unknown-word unsupported |
parse | parse(src) → { ok, tree } or { ok: false, error }: nodes step rest seq layer alt fast slow euclid maybe, each with its character range |
WORDS | bd kick · sd snare · hh hat · oh openhat · cp clap · rim/rs · lt mt ht toms · cr crash · rd ride · cb cowbell, and the kit's own names |
euclid | euclid(pulses, steps, rotate = 0) → booleans (Toussaint 2005) |
fromGrid | fromGrid(grid, { steps = 16, words, order }) → a line that plays exactly the grid, written the way a person would: bd*4, [~ sd]*2, bd(3,8,2), else beat by beat bd [~ ~ ~ bd] bd*2 ~ (0.7) |
toGrid | toGrid(srcOrCompiled, { bar = 1, steps = 16, seed = 0, bars = 16 }) → { ok, grid, words, voices, exact, lost: { offGrid, stacked, varies, random, samples } }: bar bar on the steps and what they cannot hold; seed decides each ? (as in eventsInBar), bars is how many bars it looks through to tell whether the others differ (lost.varies) (0.7) |
fromStrudel | fromStrudel(code) → { strudel, ok, src, bpm, ignored, unsupported }: s("…") / sound("…") copied from strudel.cc, .fast / .slow folded in, setcpm / setcps as bpm (0.7) |
toStrudel, strudelUrl | strudelUrl(src, { bpm, comment }) → https://strudel.cc/#… (the line as s("…") with setcpm(bpm/4), base64 in the #); open it in a new tab (0.7) |
Writing: spaces (steps share the bar) · ~ - rest · [ ] group · , layers · *n faster · /n slower, across bars · < > one per bar · (k,n,r) Euclidean · !n and ! repeat · @n and _ longer · ? ?0.3 drop by chance (seeded) · bd:2 sample number. . | { } are refused with a hint (rests are ~ or -).
import { compile } from '@openav/pattern';
const p = compile('bd*4, [~ sd]*2, hh*8');
p.eventsInBar(1); // kick 0 .25 .5 .75 · snare .25 .75 · hat every 1/8@openav/console#
Spine · the director's desk: transport, layer panels, params with learn, signal meters, performance mode.
| export | signature |
|---|---|
mountConsole | mountConsole(el, app, { layers, signals, learnOrder }) → { render(state), perf, director, wizard, dispose() }; app = { timeline, params, mapper, signals, stage, midi?, sound?, audio?, hands?, pose?, loop?, osc?, score?, director? } |
See Show control.
@openav/monitor#
Spine · the backstage page and the WebSocket relay that feeds it.
| export | signature |
|---|---|
MonitorFeed | new MonitorFeed({ url = 'ws://<page host>:7457', hz = 15 }) · connect() · frame(snapshot) · connected, onStatus |
snapshotOf | snapshotOf({ timeline, params, signals, stage, loop }, state) → a JSON snapshot |
| server | node packages/monitor/server.js [port=7457]: backstage page at /, /health |
See Show control.
@openav/relay#
Phones · room-scoped WebSocket relay (server) and its browser client.
| export | signature |
|---|---|
RelayClient | new RelayClient({ role, room, id, url, batchHz = 30, resendMs = 1000, onStatus, onSignal, onFeedback, onConfig }) · connect() · close() · set(name, value) · send(name, value, pulse, extra) · feedback(name, value) · config(key, data) · status, latency, peers |
| signals helpers | bindSignals(client, signals, { alias = 'any' }) · fileSignal(signals, name, value, { pulse }) · signalMeta(name, { pulse }) · aliasOf(name) · normalizeValue(name, value) · deviceId() |
@openav/relay/server | attachRelay(httpServer, { path = '/relay', log, heartbeatMs = 20000 }) → { hub, close() }; standalone node packages/relay/server.js [port=7458] |
MidiLink (link.js) | new MidiLink({ url, room, delay = 0, onMessages(list, from), onStatus }) · connect() · send(bytes, whenMs) · info() → { state, peers, rtt, trip, diff, lead, late, sent, received } · close(); LinkTiming: MIDI between pages in one room, played when it sounds at the sender (With Strudel) |
@openav/surface#
Phones · TouchOSC-style widgets, layout JSON, theme, and autoSurface(params).
| export | signature |
|---|---|
Surface | new Surface(el, { layout, sink(name, value, info), haptics = true, tabs = true, orientation, onPage }) · setLayout(layout) · setPage(id) · feedback(name, value) · widget(page, id) · dispose() |
signalSink | signalSink(signals) → a sink that writes into a local Signals |
| auto | autoSurface(params, { perPage, style, pairs, meters, smooth, title }) → { layout, routes, bindings } · feedbackFor(state, bindings) · routesFromLayout(layout, params) · widgetTypeFor(param) |
| layout | validateLayout, normalizeLayout, resolvePage, autoArrange, signalNames, DEFAULT_SIZE, WIDGET_TYPES, planKeyboard |
| platform & theme | lockViewport, keepAwake, toggleFullscreen, canFullscreen, haptic, injectTheme, THEME_VARS, COLOR_NAMES, WIDGET_CLASSES |
import { Surface, autoSurface, signalSink } from '@openav/surface';
const { layout, routes } = autoSurface(world.params);
new Surface(document.querySelector('#panel'), { layout, sink: signalSink(signals) });
routes.forEach((r) => mapper.addRoute(r));@openav/remote#
Phones · the phone/iPad app and the show-side adapter.
| export | signature |
|---|---|
mountRemote | mountRemote(el, { room, relayUrl, surfaceUrl, metaUrl }) → the phone app (/packages/remote/index.html calls it) |
PhoneSensors | new PhoneSensors({ set, send }, { onKnock, onSample, notify }) · start() (from a tap) · stop() · calibrate() · setCamera(on) · denied |
localSensors | localSensors(signals, { prefix = 'phone/local/', alias = 'any' }) → PhoneSensors |
attachTouchPad | attachTouchPad(el, io, { onMove, onEnd }) |
@openav/remote/host | mountRemoteHost({ signals, params, mapper, world, room, auto, surface, feedbackHz = 10, url, mode, controllers }) → host · mountJoinCard(parent, host) (URL, QR code, status) |
@openav/remote/relay-pick | pickRelay({ hostname, search, declared, storage }) → { mode: 'local'|'public'|'custom'|'off', url, room, shared } · isLocalHost(h) · PUBLIC_RELAY · phoneUrl({ base, room, relay }) |
@openav/remote/qr | qrSvg(text, { ecc = 'M', margin = 2, scale }) → an <svg> string · qrMatrix(text, { ecc }) → { version, size, mask, rows } (byte mode, versions 1–10) |
@openav/mcp#
Glue · a zero-dependency MCP server over stdio for coding agents:
node packages/mcp/server.js. Tools and arguments are in
For AI agents.
For AI agents#
Worlds are visual code, and coding agents are good at code. A person describes a performance ("a jellyfish world that blooms on consonant chords"); an agent writes the world inside a chassis that already handles MIDI, mapping, timeline, console and backstage. This chapter is what an agent needs to do that well.
The two rules#
- Continuous control goes through params; discrete events may use signals.
Never read
signals.get(…)inupdate()for something that varies continuously. Declare a param and let a route connect the signal to it. - The shell never knows the work. Never edit
packages/*to make one example look right. A framework change must make every work better.
The reasoning is in Core concepts.
Start with AGENTS.md#
AGENTS.md at the repository root is the briefing: what the
framework is, the two rules, how to create a new work, how to verify changes,
and the conventions (vanilla ESM + JSDoc, no build step, no runtime
dependencies, comments that explain the performance reasoning (why a knob feels
right) rather than syntax, path-style signal names declared with signals.define).
The MCP server#
The repository ships a Model Context Protocol server: packages/mcp/server.js,
zero dependencies, JSON-RPC 2.0 over stdio, one JSON message per line. Claude
Code picks it up from .mcp.json when you open the repository. Other clients:
{
"mcpServers": {
"openav": {
"command": "node",
"args": ["/path/to/open-audiovisual/packages/mcp/server.js"]
}
}
}It answers initialize (server openav 0.1.0, echoing the client's protocol
version, 2024-11-05 if none is given), tools/list and tools/call. Results
come back as one text item; objects are JSON-encoded. A failing call returns a
JSON-RPC error with code −32000 and a message.
| tool | arguments | returns |
|---|---|---|
list_examples | none | [{ dir, title, summary }]: each example's folder, the <title> of its index.html, and the first comment line of its main.js |
read_doc | doc (required): architecture, writing-a-world, signals, show-control, roadmap, agents, readme | the Markdown text. agents is AGENTS.md, readme the README; the others are docs/<name>.md |
scaffold_world | slug (required, kebab-case), donor (default 01-hello-particles) | { created, next_steps }. Copies every file of examples/<donor>/ into examples/<next number>-<slug>/ |
run_checks | none | { pass, fail, ok } from node --test tests/*.test.js (60 s timeout), or { ok: false, output } with the failing lines |
list_controllers | none | { count, controllers: [{ id, short, name, maker, kind, controls, unverified, url }] }: every shipped MIDI profile, its control mix ({ knob: 8, pad: 8, keys: 25 }), how many controls are still flagged unverified, and its page https://openaudiovisual.com/controllers/<id>/ |
read_profile | id (required): a profile id or its short | { id, url, unverified, profile, mapping }: the profile JSON as shipped, and a readable MIDI map (control, type, CC 74, ch 1, signal midi/<short>/<id>, verified) |
validate_profile | profile (required): an object or a JSON string | { ok, problems, conflicts }: the problems validateProfile finds, plus clashes with shipped profiles (same id, same short, overlapping match.ports) |
scaffold_profile | maker, model, controls (all required): [{ type, count, from? }] | { profile, problems, unverified, next_steps }: a skeleton that passes validateProfile, every number a placeholder with "verified": false, and the steps to source and register it. Nothing is written to disk |
Good donors for scaffold_world: 01-hello-particles (minimal 2D canvas),
02-chord-garden (chord-driven), 03-pose-field (camera, hands),
05-prebiotic-flake (p5 with sound). Start with read_doc agents, then
read_doc writing-a-world.
llms.txt and llms-full.txt#
- /llms.txt is the short index in the llmstxt.org format: what this is, the core concepts, how to start, every doc and example.
- /llms-full.txt is the README, AGENTS.md and the docs in one file, for agents that prefer to read everything at once.
- This handbook is plain HTML at
/docs/(and/zh/docs/in Traditional Chinese); every word is there without JavaScript. Each section has a stable anchor, for example/docs/#mapping-curves.
The create-world skill#
.claude/skills/create-world/SKILL.md is a Claude Code skill that turns a
description into a runnable example: extract the inputs, the 3–6 performable
params, the events and the acts; copy a donor; write the world; wire the
assembly; verify in the browser; hand back the URL, the param list, the scene
list and the signals it reacts to.
Poking a running show#
Every show made with createShow() sets window.openav to the whole show, so
an agent driving a browser can inspect and steer it:
openav.signals.list() // every signal, its value and range
openav.params.resolve(openav.timeline.state()) // the current param values
openav.signals.pulse('midi/note/on', { note: 60, vel: 0.8, ch: 1 }); // play a note
openav.timeline.seek(40); openav.timeline.play(); // jump to a scene and play
openav.mapper.routes // the wiringA prompt to paste#
Clone https://github.com/frank890417/open-audiovisual and read AGENTS.md first.
Run `node serve.js` and open http://localhost:8080/examples/01-hello-particles/ to see the smallest complete show.
Create examples/27-<name>/ by copying 01-hello-particles (or call the openav MCP tool scaffold_world), then write a World in its main.js for this piece:
<describe the performance, e.g. "a jellyfish world that blooms on consonant chords">
Rules from AGENTS.md: continuous control goes through params, and the mapper routes signals to them. Never edit packages/* to make one example look right.
Done means: npm test passes, and the new example loads with zero console errors.Before handing back#
npm testpasses (orrun_checkssaysok).- The example loads with zero console errors. The refused
ws://…:7457connection (no backstage monitor running) is expected. - It reacts to the on-screen piano or QWERTY keys: no hardware required.
- Space plays the timeline and something evolves.
- Report the URL, the params (what to map to knobs), the scenes, and the signals the world listens to.
Lab integration (the "lab" field)#
cheyuwu-lab runs sketches in plain <script> pages, so it builds a
classic-script bundle of each OAV package it wants. Instead of a hard-coded list, the lab's generator
(tools/lab-dev/build-modules.mjs) scans packages/*/package.json and builds every package that declares a
"lab" field (an object, or an array when one package yields several modules). No declaration, no module.
| key | meaning |
|---|---|
module | output name, runtime/modules/<module>.js (lowercase, digits, -) |
kind | "build" (default, bundle the code) or "declare" (only announce a module the lab implements itself) |
files | source files to bundle, relative to packages/; relative imports between them are resolved, JSON imports are inlined |
exports | names exposed on window.OpenAV |
wire | optional file (relative to the package dir) run inside the module scope; lab is window.lab. It plugs the package into lab.signals / lab.params |
autowire | one sentence on what the wiring does (goes into the lab's oav-manifest.json) |
remoteTab | optional {id, label, icon, entry}: adds a tab to the phone /remote app |
since | version or month |
Rules: relative imports only, no runtime dependencies, no build step in OAV itself. The generator is
idempotent and has a --check mode (exit 3 = OAV has something new to regenerate).
Single source of truth: controllers and inputs live here#
Decided 2026-10-03: every controller and input module the lab uses is written in OAV and reaches the lab only by reference:
- generated:
runtime/modules/<module>.jsfrom a"lab"declaration (header// GENERATED by tools/lab-dev/build-modules.mjs); - served as-is: the lab's dev server serves
/remotefrompackages/remote/index.htmland/oav/packages/*from this repo.
That covers the message format (relay/signals.js: signalMeta, fileSignal, velocity normalization), the virtual
Web MIDI (midi/virtual-access.js), controller profiles and panels (packages/midi), the phone page (感測 · 琴鍵 ·
控制台 · MIDI: packages/remote, packages/surface, packages/keys), phone sensors on the run page itself
(localSensors, lab module motion), audio, sound, mapping and OSC (the OSC bridge is packages/osc/bridges/osc-bridge.js).
To change one, change it here, test it here, push main; the lab regenerates (its nightly tools/oav-sync/sync.sh, or
node tools/lab-dev/build-modules.mjs by hand) — never by editing the lab's copy.
The lab is not allowed to grow a second copy. Its gate (node tools/lab-dev/modules-gate.mjs, also a test and the
first step of npm run deploy) fails when a runtime/modules/*.js lacks the GENERATED header (a short, reasoned
allow-list excepted — empty today) or when runtime/modules/ has a sub-folder (hand-copied packages).
What stays in the lab is the thin host layer — code that must exist before a sketch loads or that only knows the
lab's own pages: runtime/lab.js (the lab object, its relay socket, and the few lines that swap
navigator.requestMIDIAccess for a lazy loader of the generated midi module), /remote's room picker
(runtime/remote-room.js: which lab works are open) and its recording panel (runtime/rec-remote.js: the lab's own
capture/record API), and the dev server's relay and routes. A new kind of controller or input never starts there.
Troubleshooting#
Real problems, found in rehearsals and in the code, with what to do about them.
The console shows a ws://…:7457 error#
Every show made with createShow() tries to stream to the backstage monitor at
ws://<the page's host>:7457. When no monitor is running, the browser logs the
refused connection. This is expected and harmless: the page retries with a
growing delay (about 5 s, rising to 30 s) instead of spamming. To make it go away, run
the monitor:
npm run monitor # node packages/monitor/server.jsPages served over https (openaudiovisual.com, an https tunnel) don't try at all: the
browser blocks ws:// there, and a published site has no relay beside it. A show that
runs a monitor behind TLS passes the address itself, new MonitorFeed({ url: 'wss://…' }).
Chrome asks about the local network, or about apps on this device#
Chrome's Local Network Access check puts a permission prompt in front of a public https page that tries to reach your own machine or network; a page served from your machine is not covered (Chrome blog). What it gates, by Chrome version (checked 2026-10-15):
- Chrome 142:
fetchrequests (Chrome blog). - Chrome 145: two permissions, one for the local network and one for this device itself, called loopback (Chrome Platform Status).
- Chrome 147: WebSocket and WebTransport connections as well (Chrome Platform Status).
- Chrome 154:
new WebSocket(url, { targetAddressSpace })lets a page say which kind of address it expects (Chrome Platform Status).
When a show runs on openaudiovisual.com or another https site, three things of ours reach for your machine: the OSC bridge (OscOut posts to http://127.0.0.1:7456), the backstage monitor (ws://127.0.0.1:7457) and the Leap bridge. The first time, Chrome asks. If you chose Block, the connection fails, and the console shows net::ERR_BLOCKED_BY_LOCAL_NETWORK_ACCESS_CHECKS (seen on Chrome 154, Leap package notes).
What to do:
- Press Allow. If you blocked it, open the page's site settings (the icon left of the address) and allow it again, then reload.
- Or open the page from your own machine:
node serve.js, thenhttp://localhost:8080/…. That page is not a public site, so Chrome does not ask. This is the safest way to teach or rehearse the OAV → OSC → TouchDesigner path. - In an
<iframe>, a cross-origin frame needsallow="loopback-network"; a same-origin frame inherits it; asandboxed frame cannot get it (verified on Chrome 154, Leap package notes). - The device check has a button that tries the bridge and the monitor and says which one answered. It connects only when you press it.
The phone join card says "relay offline"#
On your own machine the phone relay lives inside serve.js (/relay). So the
card says "relay offline" when the page was opened from a file, or from a server that
is not node serve.js: run the show with node serve.js and open it from there.
On a static host (openaudiovisual.com, GitHub Pages) the show uses the public
relay instead and the card reads relay ok · … · public relay with a QR code.
If it says can't reach the relay (wss://remote.cheyuwu.com/relay) — retrying:
- the network blocks WebSockets (some school and office Wi-Fi does): try a phone
hotspot, or run
node serve.json a laptop on the same network as the phones; - the public relay is busy or down: it limits each address to 24 connections and
30 new connections a minute, which a whole classroom behind one router can hit.
Run your own (
node packages/relay/server.js) and open the show with?relay=wss://your.host/relay; ?relay=offis in the URL, which turns the remote off (the card is not drawn).
The phone opens the link in the QR code, which carries the room and the relay.
If the phone says it is connected but nothing moves, check that both show the same
#room and that the computer's page was not reloaded into a new room (the room is
kept per browser, so another browser or a private window gets a different one).
No MIDI devices appear#
Run the device check first: it tells you whether this browser has Web MIDI at all.
- Use Chrome or Edge. Safari has no Web MIDI (WebKit bug 107250, still open), and Firefox has it only after you install its site-permission add-on (Firefox 108). More in Which browser.
- Allow MIDI when the browser asks. Since Chrome 124 it asks for any MIDI use (Chrome blog). The permission is per site: allowing it on
localhost:8080does not allow it onlocalhost:3000. - The device was plugged in after the page loaded: it should appear by itself. If it does not, reload.
- Check the L1 · Input panel: a device with its box unticked is muted.
- Another program may hold the port exclusively (some DAWs and drivers on Windows do). Close it.
A DAW on the IAC bus is not heard#
By default the Midi engine ignores every input whose name contains IAC
(filterOut: 'IAC'). A show that sends MIDI out through the macOS IAC bus would
otherwise hear its own output and loop. createShow() uses that default. To
receive from a DAW over IAC, turn the built-in engine off and make your own:
import { Midi } from '@openav/midi';
const show = await createShow({ world, modules: { midi: false } });
const midi = new Midi({ signals: show.signals, filterOut: null }); // or a narrower regex
await midi.enable();Your engine publishes into the same signals, so routes and worlds work
unchanged. (The console's device list and MIDI meter only follow the engine
createShow() made.) Do not send MIDI out to the same IAC bus you listen to.
A second guard needs no setting: a message that arrives on any input within 40 ms
of an identical one the engine just sent is taken for the echo and dropped
(new Midi({ echoWindow }), 0 turns it off, midi.echoDropped counts them).
The DAW went silent after a cable was pulled#
It should not any more. When the output port is unplugged Midi moves to the next
best live port, prints one line in the MIDI log and one in the browser console
(MIDI out: switched to "…" ("…" was unplugged)), and the L1 · Input panel's
MIDI out → row follows. When the port you chose is plugged in again the output
goes back to it. If the line says no output left, nothing is plugged in: messages
are dropped until a port appears. Choose your port with midi.selectOutput(name)
(or enable(name)) so it is the one that returns, and add
new Midi({ prefer: ['IAC'] }) to say which port stands in first.
With two controllers, a route reacts to both#
With two or more MIDI inputs, each device publishes under its own name
(midi/<slug>/cc/74) and under the shared legacy name (midi/cc/74). A route
on midi/cc/74 hears every device; change its source to the per-device name.
The slugs are shown in the L1 · Input panel.
QWERTY keys do not play#
Tick keyboard above the piano first. Capture is off by default so that typing
never fights the console's hotkeys. Then A W S E D F T G Y H U J K O L P ;
play, Z/X change octave.
While keyboard is ticked the letters belong to the piano, so the console's R (reset), T (prompter) and F (fullscreen) do nothing: T and F are piano keys, and an accidental R must not rewind a show you are playing. Space, the arrows and Esc still work, and Esc leaves capture.
Learn bound the wrong signal#
Learn binds the first signal that moves by 5% of its range, or the first pulse of any kind. The simulated performer and the drum machine send pulses all the time. Untick simulate performance, stop the drum machine, then click learn again. Remove the wrong route with ✕ in L2 · Mapping.
The timeline no longer moves a param#
A touched slider or a mapped signal overrides the param, and overrides are
sticky: the score yields to the human. Clear it with ✕ next to the param (or
show.params.clearOverride(key); clearAllOverrides() for all). A smoothed
route keeps writing after its signal arrived, even when muted; remove the route
to give the param back to the timeline. See
Overrides and the timeline.
I changed a route in code and nothing changed#
The mapper saved yesterday's routes in localStorage, and a saved
source → target pair wins over the declared one (new pairs are still added).
Clear the profile, or pass profile: false while you develop:
localStorage.removeItem('openav.map.<world name>');A route to sound/cutoff only moves between 0 and 1#
outMin/outMax default to the target's range when the route is added, and in
createShow() declared routes are added before the sound engine registers its
sound/* params. Give the range explicitly:
{ source: 'midi/cc/74', target: 'sound/cutoff', outMin: 100, outMax: 8000 }.
The camera or microphone does not start#
- The page must be a secure context:
http://localhostorhttps://. A LAN address over plainhttp://(http://192.168.1.20:8080) is blocked. - The 🎤 mic, 🖐 hands and 🕺 body buttons must be clicked; nothing starts on its own. A button that reads ✗ retry failed: the reason is in the browser's devtools console.
- On macOS, the browser itself needs camera and microphone access in System Settings → Privacy & Security.
- Hands and body load MediaPipe and its model from jsDelivr and Google's storage the first time. Offline, they cannot start.
There is no sound#
Browsers start audio only after a click. Press 🔊 enable sound in the L4 · Output — sound panel. The Tone.js engine is loaded from jsDelivr at that moment, so the first time needs a network connection. The piano loads about 2 MB of samples the first time you pick it: the picker shows the progress, and the previous instrument keeps playing until the piano is ready. The drum machine's kit starts on the first hit after you tick drum machine.
A p5.js sketch with the one line misbehaves#
oav-p5 says what went wrong on the page: a card beside the 🎹, in the browser's language, with what happened, why and what to press (When something goes wrong). 🎹 → 🩺 puts the version, the microphone, MIDI, sound, the drums and the wiring on one card: screenshot it when you ask for help. The common ones:
- knob1 is not defined: the line did not load (offline, or a typo in the URL). It goes first in the sketch,
above
setup(). - Silence: browsers play audio only after the page has been clicked, and a key on a MIDI keyboard does not count. Click the canvas once; until then the 🔊 on the 🎹 button is yellow.
- The drums are late on a Bluetooth speaker (0.15–0.25 s):
?oav_drumlatency=200sets the delay by hand, in milliseconds. - The video is WebM: Firefox and older Chrome cannot write MP4, and Instagram does not take WebM. Record in Chrome 126 or later, Edge or Safari.
- The microphone stays at 0 in a sketch embedded in another page: its
<iframe>needsallow="microphone"(andallow="midi"for a hardware controller).
Strudel does not hear OAV Virtual MIDI#
OAV was loaded after Strudel's midin() ran, so midin('OAV Virtual MIDI') gets nothing and says nothing. Put
await import('https://openaudiovisual.com/p5/oav-p5.js') in Strudel's settings → prebake (single quotes), save and
reload: the prebake runs before your code. Until then 🩺 says "no program has asked OAV Virtual MIDI for MIDI".
More in With Strudel.
The phone's tilt and knocks do nothing on an iPhone#
iOS only gives motion sensors to https:// pages, and only after a tap on
開始 (start). Over plain http:// on the LAN, touch, keys and the control
surface still work. Reach serve.js through an HTTPS tunnel for sensors. If the
permission was denied once, iOS keeps saying no until you change it in
Settings → Safari → Motion & Orientation Access, then reload.
After a deploy, the site still runs old code#
GitHub Pages lets browsers cache JavaScript for hours while HTML expires in
minutes, so a visitor can get new HTML with old modules. Before deploying, run
node tools/stamp-version.mjs: it appends ?v=<commit> to module URLs in the
examples, packages and homepage. Locally this never happens: serve.js sends
Cache-Control: no-store.
The show slows down when the window is in the background#
Browsers pause requestAnimationFrame in hidden tabs. The Loop then keeps
running from a Web Worker at about 15 frames per second, so the timeline, mapped
signals and the backstage feed continue, but rendering is slower. Keep the
projection window visible, on its own screen, in fullscreen (F).
An example from a CDN works online but not offline#
p5 (examples 05, 06), Tone.js (sound), MediaPipe (hands, body) and the WebToe app
(example 04) come from the network. For an offline venue, download them and
point the script tags, the import map or the module options (toneEngine({ cdn }),
webtoeWorld({ app })) at local copies.
The port is already in use#
node serve.js 8081 serves on another port, and the OSC bridge takes one too:
node packages/osc/bridges/osc-bridge.js 7470 (then new OscOut('http://127.0.0.1:7470')).
The backstage monitor is the exception: a createShow() page always streams to
port 7457 on its own host, so free that port. Only a hand-assembled show can
point elsewhere, with new MonitorFeed({ url: 'ws://host:7460' }) and
node packages/monitor/server.js 7460.
Roadmap#
Guiding rule: the framework only grows features that a real show demanded. (This is how the core was born — every module here drove an actual performance before it was generalized.)
v0.x — the chassis (now)#
- four layers: input (midi/audio/chord/pose) → mapping → world → output
- timeline with scenes, negative standby time, cue hook
- director's console + performance mode
- backstage monitor (zero-dep WS relay, LAN devices)
- OSC bridge; MIDI out with observable throat + panic
- tests for all pure logic (chord, timeline, mapping, params)
- mapping profile import/export UI (JSON file, drag-and-drop,
?map=<url>, remembered per world) - example: audio-reactive world driven by a live instrument (mic) —
06-cylinder-earth - drum machine with analyzer-shaped signals (
@openav/drums) - on-screen MIDI controllers that mirror real hardware (
@openav/midi, example 09) - a documentation site: openaudiovisual.com/docs
- oav-p5: one line for any p5.js sketch, with controllers, a phone, the microphone, sound, a drum machine you can write as one line of rhythm, bars and cues, and video recording; versioned on its own (changelog)
- one line of rhythm, TidalCycles/Strudel-style mini-notation (
@openav/pattern) - values over time as TouchDesigner's CHOPs shape them: lag, envelope, speed, spring, trail (
@openav/chop) - next to Strudel: what a program plays into OAV Virtual MIDI, an external MIDI clock, and
link=<room>across tabs and computers (With Strudel)
v1.x — the show grows#
- Audience-device input — classic controller shipped (
@openav/relay,@openav/surface,@openav/remote): a zero-dep room server,phone/{id}/*sensors, a TouchOSC-style widget set, layout JSON,autoSurface(params), feedback to the phone. Next:- QR code on the join card (
@openav/remote/qr, a ~150-line zero-dep encoder) — and on static hosts the show uses the public relay with a random room, so the examples on openaudiovisual.com take phones too - experimental surfaces: gesture/physics widgets, shared multi-user surfaces, audience-mode (many phones → aggregate signals like
crowd/tilt/mean) - mic as a phone sensor; per-device profiles; layout editor / import-export UI
- QR code on the join card (
- Score & Director — shipped (
@openav/score): cuts as segment lengths, derived versions, cues,hold, segment modules that fail alone, the prompter's acts, score → MIDI markers. Next:- MIDI clock in:
MidiClock(@openav/midi) follows start, stop and song position, and oav-p5's drum machine anddrumPhasefollow it (0.7) - the score on an external clock, and clock and transport out, so a DAW can be the master clock of the score, or follow it
- a
clockstable in the cut, only if a show needs engines on two different time lines (see Score & Director) - a score editor on the desk: drag a segment's length, see every cue move
- MIDI clock in:
- Signal recording & replay — MIDI takes shipped (
@openav/record:TakeRecorder,TakePlayer,.midin and out;EventLogalready records any signal beside a video). Next: replay the full signal stream (hands, body, audio analysis) into any world. Rehearse without the performer; archive the gesture, not just the video. - npm publish of
@openav/*, and docs pages that are themselves performable with the framework. - WebGPU world adapter as WebGPU compute matures for particle/agent worlds.
v2.x — the stage widens#
- NDI gateway: headless-browser → NDI bridge so browser output enters professional video pipelines (research: WebRTC→NDI converters already exist).
- Distributed performance: multi-browser signal sync over WebRTC data channels — several machines, one signal space, geographically split ensembles.
- Agent performers: LLM-driven agents subscribing to signals and writing params — improvising alongside humans (lineage: The Last Input's autonomous city agents).
Non-goals#
- a node-based visual editor (cables.gl does this well; we are a library)
- a synthesis toolkit of our own (Tone.js is the neighbor:
@openav/soundwraps it behind a small engine contract, andaudio.input()accepts its nodes) - being a platform: your show is your repo, not an account on our server