norns-web API docs
back to demo
Getting Started
norns-web provides three ES modules that mirror the monome norns Lua APIs for use in the browser:
- midi — virtual MIDI bus
- softcut — 6-voice sample player/recorder (Web Audio)
- screen — 128×64 pixel display (Canvas 2D)
- clock — coroutine-based clock system for tempo-synced scheduling
Basic script structure
import midi from "./lib/midi.js";
import softcut from "./lib/softcut.js";
import screen from "./lib/screen.js";
// Initialize screen (pass a <canvas> element)
screen.init(document.getElementById("norns-screen"));
// Initialize audio (must be called from a user gesture)
const ctx = new AudioContext({ sampleRate: 48000 });
await softcut.init(ctx);
// Your script's redraw loop
function redraw() {
screen.clear();
screen.level(15);
screen.move(10, 10);
screen.text("hello norns-web");
screen.update();
requestAnimationFrame(redraw);
}
redraw();
Scripts directory
On hardware norns, scripts live in ~/dust/code/. In norns-web, place scripts in a scripts/ directory and register them in scripts/index.json:
norns-web/
├── lib/
│ ├── midi.js
│ ├── softcut.js
│ ├── screen.js
│ └── script-loader.js
├── scripts/
│ ├── index.json ← manifest of available scripts
│ ├── my-script.js ← your scripts go here
│ └── another-script.js
├── index.html
└── docs.html
Manifest format (scripts/index.json)
[
{
"name": "my-script",
"description": "A brief description shown in the launcher",
"file": "my-script.js"
}
]
Scripts listed in the manifest appear in the dropdown on the main page. You can also drag and drop any .js file onto the drop zone to run it without adding it to the manifest.
Each script is an ES module that imports from ../lib/. See the script template below.
midi
import midi from "./lib/midi.js"
Software-only virtual MIDI bus. Up to 16 ports. Ports can be wired together for internal routing.
Module functions
| Function | Description |
midi.connect(n) |
Create or retrieve virtual port n (1–16). Returns a port object. |
midi.cleanup() |
Clear all handlers and destroy all ports. |
midi.to_msg(data) |
Parse raw MIDI byte array → message object {type, note, vel, ch, cc, val}. |
midi.to_data(msg) |
Serialize message object → raw MIDI byte array. |
Global callbacks
| Property | Description |
midi.add | Called when a port is created. Receives {id, name}. |
midi.remove | Called when a port is destroyed. Receives {id, name}. |
Port object
Routing
| Method | Description |
port.wire(target) | Route this port's output to target port's .event callback. |
port.unwire(target) | Remove the routing. |
port.event = fn | Set callback to receive incoming MIDI bytes: fn(data). |
Send methods
All channel (ch) params are 1-based (1–16), defaulting to 1.
| Method | Description |
port.send(data) | Send raw byte array. |
port.note_on(note, vel, ch) | Send note on. vel defaults to 127. |
port.note_off(note, vel, ch) | Send note off. vel defaults to 0. |
port.cc(cc, val, ch) | Send control change. |
port.pitchbend(val, ch) | Send pitch bend. val: -8192 to +8191. |
port.channel_pressure(val, ch) | Send channel pressure (aftertouch). |
port.key_pressure(note, val, ch) | Send polyphonic key pressure. |
port.program_change(val, ch) | Send program change. |
port.clock() | Send MIDI clock tick. |
port.start() | Send start. |
port.stop() | Send stop. |
port.continue() | Send continue. |
Example
const a = midi.connect(1);
const b = midi.connect(2);
a.wire(b);
b.event = (data) => {
const msg = midi.to_msg(data);
console.log(msg); // {type:"note_on", note:60, vel:100, ch:1}
};
a.note_on(60, 100, 1);
softcut
import softcut from "./lib/softcut.js"
6-voice sample player/recorder with 2 mono buffers (~350 seconds each at 48 kHz). Backed by an AudioWorklet.
Initialization
| Function | Description |
await softcut.init(audioCtx?) |
Initialize the engine. Optionally pass an existing AudioContext. Must be called from a user gesture. Safe to call multiple times. |
softcut.reset() |
Reset all voices and clear buffers to defaults. |
Voice control
All voice params are 1-based (1–6).
| Function | Description |
softcut.enable(voice, state) | Enable/disable voice (1/0). |
softcut.buffer(voice, buf) | Assign voice to buffer (1 or 2). |
softcut.play(voice, state) | Start/stop playback (1/0). |
softcut.rate(voice, rate) | Playback rate. 1.0 = normal, 0.5 = half speed, -1.0 = reverse. |
softcut.level(voice, amp) | Output level (0.0–1.0). |
softcut.pan(voice, pos) | Stereo pan (-1.0 left, 0.0 center, 1.0 right). |
softcut.position(voice, pos) | Set playhead position in seconds. |
softcut.loop(voice, state) | Enable/disable looping (1/0). |
softcut.loop_start(voice, pos) | Loop start point in seconds. |
softcut.loop_end(voice, pos) | Loop end point in seconds. |
softcut.fade_time(voice, t) | Crossfade time at loop boundaries in seconds. |
softcut.level_slew_time(voice, t) | Level change slew time in seconds. |
Recording
| Function | Description |
softcut.rec(voice, state) | Enable/disable recording (1/0). |
softcut.rec_level(voice, amp) | Input recording level (0.0–1.0). |
softcut.pre_level(voice, amp) | Pre-existing buffer level for overdub (0.0–1.0). Formula: out = rec_level * input + pre_level * existing. |
Buffer operations
| Function | Description |
softcut.buffer_clear() | Clear both buffers. |
softcut.buffer_clear_channel(ch) | Clear buffer ch (1 or 2). |
softcut.buffer_clear_region(start, dur) | Clear a time region in both buffers. |
await softcut.buffer_read_mono(file, start_src, start_dst, dur, ch_src, ch_dst) | Load audio file (URL, Blob, or ArrayBuffer) into a buffer. Resamples if needed. |
await softcut.buffer_write_mono(filename, start, dur, ch) | Export buffer region as WAV file download. |
Phase polling
| Function | Description |
softcut.phase_quant(voice, quantum) | Set phase report interval in seconds. |
softcut.poll_start_phase() | Start phase reporting. |
softcut.poll_stop_phase() | Stop phase reporting. |
softcut.event_phase(fn) | Set phase callback: fn(voice, phase) where phase is in seconds. |
Example
await softcut.init();
// Play a loop
softcut.enable(1, 1);
softcut.buffer(1, 1);
softcut.level(1, 0.8);
softcut.rate(1, 1.0);
softcut.loop(1, 1);
softcut.loop_start(1, 0);
softcut.loop_end(1, 2.0);
softcut.position(1, 0);
softcut.play(1, 1);
// Record with overdub
softcut.enable(2, 1);
softcut.buffer(2, 2);
softcut.rec(2, 1);
softcut.rec_level(2, 1.0);
softcut.pre_level(2, 0.5); // keep 50% of previous
softcut.play(2, 1);
screen
import screen from "./lib/screen.js"
128×64 pixel display with 16 brightness levels (0–15), matching the norns OLED. Double-buffered: draw calls accumulate on an offscreen canvas, then screen.update() copies to the visible canvas.
Initialization
| Function | Description |
screen.init(canvas) | Bind to a <canvas> element. Sets internal resolution to 128×64. |
screen.clear() | Fill offscreen buffer with black. Resets cursor to (0, 0). |
screen.update() | Copy offscreen buffer to visible canvas. |
Drawing state
| Function | Description |
screen.level(l) | Set brightness 0–15 (0 = black, 15 = white). Sets both fill and stroke color. |
screen.aa(state) | Anti-aliasing on (1) / off (0). |
screen.line_width(w) | Set stroke line width. |
screen.line_cap(style) | "butt", "round", or "square". |
screen.line_join(style) | "miter", "round", or "bevel". |
Path operations
| Function | Description |
screen.move(x, y) | Move cursor to absolute position. |
screen.move_rel(x, y) | Move cursor by relative offset. |
screen.line(x, y) | Draw line to absolute position. |
screen.line_rel(x, y) | Draw line by relative offset. |
screen.close() | Close the current path. |
screen.stroke() | Stroke the current path. |
screen.fill() | Fill the current path. |
Shapes
| Function | Description |
screen.rect(x, y, w, h) | Add rectangle to path (stroke/fill separately). |
screen.rect_fill(x, y, w, h) | Draw filled rectangle immediately. |
screen.circle(x, y, r) | Add circle to path. |
screen.circle_fill(x, y, r) | Draw filled circle immediately. |
screen.arc(x, y, r, a1, a2) | Add arc to path (angles in radians). |
screen.curve(x1, y1, x2, y2, x3, y3) | Cubic bezier from current position through control points to end. |
screen.curve_rel(dx1, dy1, dx2, dy2, dx3, dy3) | Relative cubic bezier. |
screen.pixel(x, y) | Draw a single pixel. |
Text
| Function | Description |
screen.font_face(index) | Set font by index (1–24 mapped, others fall back to monospace). |
screen.font_size(size) | Set font size in pixels. |
screen.text(str) | Draw left-aligned text at cursor. Advances cursor. |
screen.text_right(str) | Draw right-aligned text at cursor. |
screen.text_center(str) | Draw centered text at cursor. |
screen.text_extents(str) | Returns {w, h} of the string. |
screen.text_rotate(x, y, str, deg) | Draw rotated text at position (degrees). |
screen.text_center_rotate(x, y, str, deg) | Draw centered rotated text. |
Transforms & state
| Function | Description |
screen.save() | Save drawing state (level, font, transforms). |
screen.restore() | Restore previously saved state. |
screen.translate(x, y) | Translate coordinate origin. |
screen.rotate(r) | Rotate (radians). |
Pixel access
| Function | Description |
screen.peek(x, y, w, h) | Read pixel data. Returns ImageData. |
screen.poke(x, y, w, h, imageData) | Write pixel data. |
Canvas setup
The canvas internal resolution is always 128×64. Scale it up with CSS for visibility:
<canvas id="norns-screen"></canvas>
<style>
#norns-screen {
width: 512px; /* 4x scale */
height: 256px;
image-rendering: pixelated;
background: #000;
}
</style>
<script type="module">
import screen from "./lib/screen.js";
screen.init(document.getElementById("norns-screen"));
</script>
Example
function redraw() {
screen.clear();
// Gradient bar
for (let i = 0; i <= 15; i++) {
screen.level(i);
screen.rect_fill(i * 8, 0, 7, 10);
}
// Circle
screen.level(15);
screen.circle(64, 32, 12);
screen.stroke();
// Text
screen.level(10);
screen.font_size(8);
screen.move(2, 54);
screen.text("norns-web");
screen.update();
requestAnimationFrame(redraw);
}
redraw();
clock
import clock from "./lib/clock.js"
Coroutine-based clock system matching the norns Lua API. Provides tempo-synced scheduling via clock.run(), clock.sync(), and clock.sleep(). Local-only implementation (no network Link peers).
Coroutines
The norns clock.run() uses Lua coroutines. In norns-web, we use async functions + AbortController:
| Function | Description |
clock.run(fn, ...args) |
Start an async clock function. Returns a numeric coroutine ID. The function can await clock.sync() and await clock.sleep(). |
clock.cancel(id) |
Cancel a running coroutine by ID. |
await clock.sleep(seconds) |
Sleep for seconds. Must be awaited inside clock.run(). Uses high-accuracy timing (setTimeout + spin loop). |
await clock.sync(beat, offset?) |
Sync to the next beat grid position. beat=1 syncs to every beat, beat=1/4 to every sixteenth note. Optional offset shifts the grid. |
clock.cleanup() |
Cancel all running coroutines and clear transport callbacks. |
Tempo & beat queries
| Function | Description |
clock.get_tempo() | Returns current BPM. |
clock.get_beats() | Returns current beat position (float). |
clock.get_beat_sec() | Returns duration of one beat in seconds (60 / tempo). |
Clock source
| Function | Description |
clock.set_source(source) | Set clock source: "internal", "midi", or "link". Link behaves as internal in this implementation. |
clock.get_source() | Returns current source string. |
Internal clock
| Function | Description |
clock.internal.set_tempo(bpm) | Set tempo (clamped 1–300, default 120). |
clock.internal.start() | Start the transport. |
clock.internal.stop() | Stop the transport. |
Link (local-only)
These match the norns Link API signatures but operate locally (no network peers).
| Function | Description |
clock.link.set_tempo(bpm) | Set tempo. |
clock.link.set_quantum(q) | Set quantum (default 4). |
clock.link.get_quantum() | Returns quantum. |
clock.link.start() | Start transport. |
clock.link.stop() | Stop transport. |
clock.link.get_number_of_peers() | Always returns 0 (no network). |
clock.link.set_start_stop_sync(enabled) | Stored but no-op without network. |
clock.link.get_start_stop_sync() | Returns current setting. |
Transport callbacks
| Property | Description |
clock.transport.start | User-settable callback, called when transport starts. |
clock.transport.stop | User-settable callback, called when transport stops. |
clock.tempo_change_handler | User-settable callback fn(tempo), called when tempo changes. |
MIDI clock output
| Property | Description |
clock.midi_out_port | Set to a MIDI port object to send 24 PPQ clock ticks + start/stop messages. Set to null to disable. |
Example
import clock from "../lib/clock.js";
// Set tempo and start transport
clock.internal.set_tempo(128);
clock.internal.start();
// Run a coroutine that fires every beat
const id = clock.run(async () => {
while (true) {
await clock.sync(1); // every beat
console.log("beat!", clock.get_beats());
}
});
// Run another at sixteenth notes
clock.run(async () => {
while (true) {
await clock.sync(1/4);
// trigger a note, advance a sequencer step, etc.
}
});
// Change tempo later
clock.internal.set_tempo(140);
// Stop a specific coroutine
clock.cancel(id);
// Or stop everything
clock.cleanup();
Script template
Save this as scripts/my-script.js:
import midi from "../lib/midi.js";
import softcut from "../lib/softcut.js";
import screen from "../lib/screen.js";
import clock from "../lib/clock.js";
// -- state --
let counter = 0;
// -- init --
export async function init(canvas, audioCtx) {
screen.init(canvas);
await softcut.init(audioCtx);
// set up MIDI
const port = midi.connect(1);
port.event = (data) => {
const msg = midi.to_msg(data);
console.log("midi:", msg);
};
// set up clock
clock.internal.set_tempo(120);
clock.internal.start();
clock.run(async () => {
while (true) {
await clock.sync(1);
// do something every beat
}
});
// set up softcut voices, load buffers, etc.
// ...
// start redraw loop
redraw();
}
// -- redraw (called at ~60fps) --
function redraw() {
screen.clear();
screen.level(15);
screen.move(10, 10);
screen.text("my script");
screen.level(8);
screen.move(10, 24);
screen.text(`frame: ${counter++}`);
screen.update();
requestAnimationFrame(redraw);
}
// -- cleanup --
export function cleanup() {
midi.cleanup();
clock.cleanup();
softcut.reset();
}
Load it from HTML:
<script type="module">
import { init } from "./scripts/my-script.js";
document.getElementById("start").addEventListener("click", async () => {
const ctx = new AudioContext({ sampleRate: 48000 });
await init(document.getElementById("norns-screen"), ctx);
});
</script>