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 gaussnoise: white · smooth · fractal · red · bell
mouseX mouseY mouseDownlive 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
⌘⇧Kclear the canvas
⌘⇧Eperform 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.