Photosensitivity warning

Loom generates flashing, strobing, and rapidly shifting imagery (especially with feedback and fast oscillators) that may trigger seizures in people with photosensitive epilepsy. Viewer discretion is advised.

Loom

a pattern language for drawing
edit the pattern, then ⌘⏎ to run
0
staticvoid.studio

no matches.

mini-notation (inside "…")

"a b c d"

sequence, splits the cycle evenly

"a [b c]"

subdivide a step

"<a b c>"

one per cycle (alternate)

"a*3" "a/2"

speed up / slow down a step

"a!3" "a@3 b"

replicate · weight (a takes ¾)

"a(3,8)" "a(3,8,2)"

euclid (pulses, steps, rotation)

"a , b"

stack in parallel · ~ = rest

"a | b | c"

random choice each cycle

"{a b c}%4"

polymeter, n steps/cycle

sources

shape("circle*8")

emit glyphs (alias s)

n("0 1 2")

numbered dots · run(8) = 0…7

polygon(["a","b","c"])

one shape drawn as edges through N points — a ball id, or an explicit [x,y] pair (0..1). polyline leaves it open. Sampled per onset, so .fast() to track live points. Edges are lines → .color/.weight/.cap apply.

bg("#101820")

set the canvas background, stack it in

shapes: dot circle ring arc square box tri pent hex star plus line cross

3D (tumble with rotateX/Y): cube sphere torus hoop octa

torus/hoop tube thickness = .outline(o) (fraction of size; hoop = thin hula-hoop default, torus = fat donut), face-on at rest — shape("hoop").rotateX(0.19) tilts it mid-swing

meshes (flat-tinted, load on first use): bong knot amongus balloons chain

3D shapes are matte by default (.shade(0) = flat/unlit); .fill(0).stroke().weight(w) = wireframe (edges on cube/octa, silhouette on sphere/torus)

.shade(0..1)

3D-ify a shape: 2D shapes puff into glossy plastic toys (a ring → a shiny tube), 3D shapes get matte faceted lighting

controls (chain onto a source)

.color("#ff5d73" | "red" | 0..1) .color(palette("#a","#b","#c").at(x))

interpolate a palette by x (number · pattern · osc)

.size(0.06)

radius, fraction of min(w,h)

.x(0..1) .y(0..1)

centre point (default screen centre)

.radius(0..0.5) .angle(turns)

polar offset from the centre, combine with x/y to orbit a point. angle defaults to onset phase; .angle(saw.range(0,5)) winds a spiral

.grid(cols, rows)

lay events into a grid by onset

.rotate(turns) .spin(turns/s)

Z spin · 1 = full turn

.rotateX(turns) .rotateY(turns)

3D tilt (foreshorten)

persp(n)

the 3D-tilt camera, global (sits in a stack): n = distance in glyph radii, default 2.6 (close/dramatic — the near edge swells, tumbling wobbles). persp(0) = orthographic: a tilted circle is an exact centred ellipse, pinned while it tumbles. patternable like bg()

.fill(1|0) .stroke(1|0) .vertex(1|0)

draw modes, independent & patternable. .fill(0).vertex() = dots only

.weight(0..0.1)

stroke / line / vertex-dot size (absolute)

.outline(0..0.3)

stroke size relative to the shape — scales with .size()

.cap("round"|"butt"|"square")

line ends

.join("miter"|"round"|"bevel")

polygon corners (default miter / sharp)

.open(0..1)

arc/ring gap (use shape arc)

.alpha(0..1) .opacity(0..1) .pan(0..1) .jitter(0..0.1) .attack(s) .decay(s)

fade-in / fade-out (s alias .life)

.attack(s, "outBack") .decay(s, "inOutSine")

shape the fade with a Penner curve (2nd arg)

.hold(cond)

sustain: while cond is truthy the envelope parks at full presence; the decay runs from release. cond is live (re-read every frame): .hold(gate(1)) while a MIDI key is down, .hold(mouseDown) while pressed, .hold(1) until clear

.blend("source-over"|"screen"|"lighter"|"multiply")

transforms

.fast(n) .slow(n) .rev() .every(n, p => …) .iter(n) .palindrome() .jux(p => p.rev())

split a transformed copy left/right

.off(t, p => …)

echo, shifted by t cycles

.burst(n, spread?)

each event becomes n simultaneous copies with fanned phases — shapes arranged on shapes: shape("square").burst(8).radius(0.3) = 8 squares around a circle per event, blooming together ("square*8" walks the ring one at a time). fans everything phase-driven: ring angle, .grid() cells, palette hue, osc .spread(). spread < 1 = an arc; n patternable

.degrade() .sometimes(p=>…) .often(…) .rarely(…) pick(sel, [a, b, c])

branch by a 0..1 selector (number/pattern/signal) — .size(pick(mouseX, [.02,.06,.1])) · pick("<0 1>", [shape("dot"), shape("ring")]) swaps per cycle

iff(cond, then, else)

if cond>0.5 then/else — iff(mouseDown, shape("star*5"), shape("dot*5"))

.when(cond, p=>…)

apply to events where cond is truthy at onset (signal-driven sometimes) — .when(mouseDown, p=>p.size(.12))

.gate(cond)

keep only events where cond is truthy at onset — stops a stale input from drawing: .gate(ballSeen("a")) (in frame), .gate(mouseDown) (while pressed)

stack(…) cat(…) seq(…) fastcat(…) $("name", pat)

named, separately-editable layers — each $ line is a voice, auto-stacked (name optional). Bare patches still work. Chips under the editor: click a name to mute, the dot to solo — live

slider(value, min, max, default)

a number with a draggable slider inline in the code — drag/scroll to retune live (rewrites the source + re-runs). Use anywhere a number works. Double-click resets to default (4th arg) or mid-range. The URL captures the value

group(pat)

render a layer to its own buffer, then run a shader FX chain on it

physics(pat, {gravity, bounce, drag, vel, spin, windx})

spawn each onset as a 2D rigid body — falls, bounces off the edges, collides with the others. Opts patternable. rapier2d lazy-loads on first use

physics(pat, {gravity:0, attract, swirl, ax, ay, turbulence, turbScale})

force-fields: pull toward / orbit a (moving) point, or drift through a curl-noise flow — emergent swarming

shader fx (chain onto a group)

.pixelate(n) .blur(n)

mosaic · gaussian glow

.feedback(fade, zoom, rot) .trails(fade)

trails / tunnel, warps the previous frame

.feedback(fade, zoom, rot, f => f.hue(0.015).blur(1.5))

the last arg embeds effects inside the loop: they run on the history every frame and compound as it recirculates — hue keeps rotating, blur keeps diffusing (an effect chained after feedback touches the output once). small values; params patternable

.kaleido(n) .mirror()

radial wedges · left/right symmetry

.tile(x, y) .dots(n)

grid repeat · halftone dots (grow with brightness)

.rgbshift(amt, ang) .posterize(n)

channel split · colour quantize

.dither(n) .scanlines(amt)

bayer dither · CRT lines

.slice(n, amt, "grid")

cut into bands & offset — oscillate amt for glitch (modes: h · v · grid)

.lens(amt) .opacity(a)

barrel/pincushion · fade the whole group (modulate a to pulse)

.scale(s) .move(x, y) .turn(t)

compose the whole layer: zoom · translate · rotate

.aspect(16/9) .aspect("9:16")

crop to an aspect ratio (letterbox; bg shows in the bars)

.displace(amount, scale)

warp / melt the layer

.hue(t) .brightness(b) .contrast(c) .saturate(s)

colour grade

.pixelate(osc(0.2).range(3,26))

FX params take numbers, patterns, or oscs (global time), so the effect itself moves

editing/removing an FX line and re-running applies to glyphs already on screen — no wipe, no waiting for them to fade (glyph content still cross-fades)

echo(group(...), n)

opt a group into accumulating edits: each re-run forks a frozen generation (keeping that edit's FX) while older ones decay out, so alternating effects stack into a fading palimpsest. n caps how many linger

signals & maths (0..1 unless ranged)

sine cosine saw isaw tri square rand perlin fbm brown gauss

noise: white · smooth · fractal · red · bell

mouseX mouseY mouseDown

live pointer (mouse/touch). Frozen at onset on a control → .x(mouseX).y(mouseY) trails the pointer; live as an FX/physics param → {ax:mouseX, ay:mouseY} a cursor attractor. mouseX.sample(8) steps/holds it 8×/cycle. mouseDown fires on a canvas press (or ⌥/⌘-click), not editor clicks

cc(num, ch) gate(ch) vel(ch) note(ch) bend(ch)

live MIDI (Web MIDI, plug-and-play). cc 0..1 · gate 1 while a key's held · vel/note the last key's velocity/pitch · bend -1..1. ch = channel 1–16, omit for omni — .x(cc(16)).y(cc(17)) · .size(vel().range(.02,.12)). Each juggling ball = its own channel: cc(7,1) vs cc(7,2)

onNote(ch, "circle")

EVENT source — emits exactly one glyph per note-on (vs gate, which samples a held note as a stream). Style each with the signals at its onset: onNote(1,"circle").y(note(1).range(.9,.1)).size(vel(1).range(.01,.1)). ch 0 = any. A chord = one glyph per note

pc(ch)

pitch class (note within the octave) → same colour per note-name across octaves: .color(palette("rainbow").at(pc(1))). For live colour from a note, put the signal in an osc's range (frozen at onset, waveform stays live): .color(palette("rainbow").at(osc(.5).range(pc(1), pc(1).add(.08))))

dev("name").note(1)

scope the MIDI signals to one device (case-insensitive substring of its port name) — opt-in; bare signals stay omni across all devices. So two controllers on the same channel stay apart: dev("launchkey").note(1) vs dev("push").cc(74). Resolves at query time, so plugging in later still works

choose("a","b") irand(8)

random pick / integer per onset

osc(rate, "sine").range(lo, hi)

live LFO, keeps moving over a glyph's life (vs signals, frozen at spawn). shapes: sine saw tri square rand perlin fbm

osc(…).spread(n)

per-glyph phase offset = n × onset phase, spreads the wave around a ring/grid instead of every glyph in lockstep

osc(…).drift(r)

starting phase advances over time = r × spawn seconds, so the pattern keeps evolving instead of repeating

osc(2).rate(osc(0.1).range(1,4))

any osc param (rate · phase · range) can itself be an osc → cross-modulation (FM · PM · AM)

.x(osc(0.2, "perlin").range(0, 1))

← a wandering dot

env(attack, decay, easeIn?, easeOut?)

per-glyph attack/decay envelope as a signal (osc-family, so it animates over the glyph's life): 0→1 over attacks, then 1→0 over decays, each segment optionally eased. route into any param: .size(env(0.2, 1, "outBack").range(0.05, 0.25)) pops in then settles. (for opacity use .decay(s, curve) — alpha is also × the lifetime envelope)

.range(lo, hi)

remap a 0..1 signal, lo/hi may be numbers, patterns, or oscs

.quantize(n) .segment(n)

snap the value to n steps · snap the time to n steps/cycle (alias .seg)

.ease("outExpo")

shape a 0..1 signal/osc through a Penner curve before .range() — in/out/inOut × quad·cubic·quart·expo·sine·back·elastic·bounce. saw.ease("outExpo").range(0,1)

osc(…).spring(stiffness, damping)

chase the osc's (usually .quantized) output with a damped spring — per-glyph momentum / overshoot / settle. .x(osc(0.2,"saw").quantize(8).spring(150,11)) settles between steps

spring(target, k?, d?)

free-function form; the target may also be a live signal or mini-notation: .x(spring(note(1))) glides to each new note, spring(mouseX) chases the pointer, spring("<0.2 0.8>") lurches between steps. pair with .hold(gate(1)) for a mono voice

.id(key)

ONE addressable object instead of a glyph stream: the first onset spawns it, every later onset with the same key re-targets it in place — sprung fields glide, plain fields snap, the lifetime refreshes. mono voice: onNote(1,"circle").id("v1").x(spring(note(1))).hold(gate(1)). keys are patternable (.id("a b c") = 3 round-robin voices)

.add(x) .sub(x) .mul(x) .div(x)

x may be a number or a pattern

.x( saw.add(sine.range(0, 0.1)) )

← wobble a ramp by a sine

juggling (WebSocket ball feed)

ballX(id) ballY(id)

a ball's position, 0..1 of the camera frame. id = "a"/"b"/"c". .x(ballX("a")).y(ballY("a")) trails it — frozen at a glyph's onset / live as an FX param, like any signal

ballSeen(id) moving(id)

ballSeen = detected this frame · moving = seen and in motion (the host flags a settled ball stationary). Gate at-rest / stale noise: .gate(moving("a"))

thrown(id) caught(id) tapped(id)

throw / catch / tap as a decaying 0..1 pulse (a flash) — .size(caught("a").range(.03,.2)) flares on a catch. tapped peaks with impact

held(id) shaken(id)

held = 1 while the ball is held still (latched by hold/active gestures — a mode you can park a visual on); shaken = a deliberate shake as a decaying pulse, amplitude scales with intensity

flight(id) gyro(id)

flight = last catch's airtime (held) · gyro = on-ball IMU spin 0..1

each ball is its own input — id joins position + events. The spatial layer (where the ball is); the musical layer (mapped notes/CC) arrives over MIDI. off by default — enable with ?feed in the URL, or the antenna button in the toolbar (its pane: host, selfie flip, camera overlay).

hands & body (MediaPipe webcam)

fingerX(f) fingerY(f)

fingertip position 0..1 (f: 0=thumb 1=index 2=middle 3=ring 4=pinky) — frozen at onset = a trail, live as an FX/physics param, like any signal

fingerUp(f) fingersUp()

fingerUp = 1 while that finger is extended — draw whichever fingers are out: .gate(fingerUp(1)). fingersUp() counts them, 0..5

pinch() palmX() palmY() handSeen()

pinch = thumb↔index closeness 0..1 (1 = touching — a free continuous knob); palm centre; 1 while a hand is tracked

handNear() fingerZ(f)

depth, approximately: handNear = how close the hand is from its apparent size (0 far → 1 close — push your hand at the camera); fingerZ = a tip's relative depth in the hand (→1 pointing at the camera)

poseX(j) poseY(j) poseSeen()

body joints by name ("nose" "lwrist" "rwrist" "lhip" "rankle" …) — .x(poseX("nose")).y(poseY("nose")) is a person-trail

cam(opacity?)

show the webcam behind the glyphs — the patch-declared backdrop, like bg() but a live camera (sits in a stack; opacity patternable: cam(0.35), cam(pinch()) fades in as you pinch). works alone, no tracking signals needed

all take an optional hand arg: 0 = first seen · "left"/"right" · 1/2. lazy: the model + webcam load only when a patch uses a signal or cam() (first run prompts for the camera). selfie-mirrored by default (loom.cam.flipX)

palettes (named ramps)

palette("sunset").at(saw.range(0,1))

named ramps, interpolated in OKLCH — click a swatch to drop it in your patch:

keys (⌘ = Ctrl on Windows/Linux)

⌘⏎

run the patch

⌘/

toggle line comment · ⌘Z undo

⌘⇧K

clear the canvas

⌘⇧E

perform mode — dim/ghost the editor & make it click-through; sliders stay live

⌘.

hide all chrome (Esc brings it back)

lineage

Loom takes its pattern language from Strudel / TidalCycles, and its live-coded visual-feedback spirit from Hydra.

The thread of driving visuals from Tidal patterns runs back through TidalP5 (2017 — an earlier take on the same idea, Tidal → p5.js) to yèct's s2hs2 (TOPLAP, 2015 — Tidal → Processing over OSC), which sparked it.

juggling feedoff
no balls

a separate local app broadcasts ball positions + throw/catch/tap over a WebSocket — drive visuals with ballX, caught, gyro… (see the guide's signals).

link audiooff
no tracks

per-track DSP from Ableton Link Audio (Live 12.4+) — drive visuals with level, low/mid/high, band, hit (see the guide).

link tempooff
clock: internal

Ableton Link tempo drives Loom's clock — cps + cycle lock to Live's transport while connected.