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:

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

FunctionDescription
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

PropertyDescription
midi.addCalled when a port is created. Receives {id, name}.
midi.removeCalled when a port is destroyed. Receives {id, name}.

Port object

Routing

MethodDescription
port.wire(target)Route this port's output to target port's .event callback.
port.unwire(target)Remove the routing.
port.event = fnSet callback to receive incoming MIDI bytes: fn(data).

Send methods

All channel (ch) params are 1-based (1–16), defaulting to 1.

MethodDescription
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

FunctionDescription
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).

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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:

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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).

FunctionDescription
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

PropertyDescription
clock.transport.startUser-settable callback, called when transport starts.
clock.transport.stopUser-settable callback, called when transport stops.
clock.tempo_change_handlerUser-settable callback fn(tempo), called when tempo changes.

MIDI clock output

PropertyDescription
clock.midi_out_portSet 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>