Curation engine · processing.waves

Signal Loom Guide

One machine, two runtimes. The instruction is Processing — each copyable block is verbatim from SignalLoom.pde — whole functions and classes, plus two labelled fragments; the complete runnable sketch is the download. The working part is simulated online with p5 — every preview is a live p5.waves sketch running the identical sampler settings (the Processing sketch runs ~10% slower by design via TIME_SCALE), so you can watch the decision before you compile it.

From One Wave To A Loom

Signal Loom is not a list of effects. Pools become tension, samplers move the shuttle, and the pattern card turns phase into woven output — the same five stations in Processing and in the browser.

Live assembly preview · warp + weft + pattern card + readout (p5)
start: pool -> tension -> shuttle
end product: one loom, woven output
start One source, five stations

SOURCE, TENSION, SHUTTLE, PATTERN, OUTPUT. Every layer answers one question: what job does this wave behaviour perform in the loom?

build Processing is the instruction

The .pde sketch is the deliverable. setup() sizes the canvas, rebuildSystem() builds the samplers, draw() stacks the modules in order.

end product p5 is the rehearsal

The browser runs the identical wave logic via p5.waves so the loom is legible before Processing 4 ever compiles it.

Processing · instruction SignalLoom.pde — paste into Processing 4 + waves library
import waves.*;

final int PAPER = color(247, 242, 232);
final int INK = color(9, 9, 8);
final int SIGNAL = color(255, 61, 46);
final int DEEP = color(6, 71, 255);
final int LIME = color(199, 255, 45);
final int VIOLET = color(142, 81, 255);
final int AQUA = color(0, 189, 214);
final float TIME_SCALE = 0.9;

String[] curated = {
  "classic sine",
  "mountain peaks",
  "wobble sine",
  "triangle sine",
  "meta sine"
};

void setup() {
  size(1200, 760);
  smooth(4);
  displayFont = createFont("SansSerif.bold", 64);
  monoFont = createFont("Monospaced", 12);
  textFont(displayFont);
  rebuildSystem();
}

void draw() {
  float t = millis() / 1000.0 * TIME_SCALE;
  background(PAPER);
  drawBackplane(t);
  drawLoomFrame(t);
  drawWarpThreads(t);
  drawWeftPasses(t);
  drawPatternCard(t);
  drawGlyphField(t);
  drawTitle(t);
  drawMachineReadout(t);
  drawTicker(t);
  drawGrain();
}

Curate The Tension

This is the editorial backbone. Four named pools — gentle, harsh, all, and a hand-picked curated array — each become one tension voice with its own colour.

start Name the voices

gentle stays readable, harsh ruptures with wild mode, all stays broad, the array is taste.

build Bind colour to character

Each pool gets a sampler with its own seed and frequency. activeIndex(t) rotates which voice drives the loom.

end product Rotation without chaos

The active pool changes over time, but every layer stays in its family so the loom keeps one controlled voice.

p5 · simulation four curated families as live tension rows
preview: pool -> one tension voice
result: variation with authorship
Processing · instruction the pool table + per-pool sampler
// inside rebuildSystem() — each pool is a named wave family / one tension voice
pools = new Pool[] {
  new Pool("gentle", "gentle", DEEP),
  new Pool("harsh", "harsh", SIGNAL),
  new Pool("all", "all", AQUA),
  new Pool("curated", curated, VIOLET)
};

threadSamplers = new Waves.WaveSampler[pools.length];
looms = new LoomGrid[pools.length];
for (int i = 0; i < pools.length; i++) {
  threadSamplers[i] = Waves.createSampler(new WaveOpts()
    .group(pools[i].group)
    .shift(true)
    .shiftInterval(3.8f + i * 0.45f)
    .shiftDuration(1.1f)
    .range(-1, 1)
    .frequency(0.5f + i * 0.08f)
    .mode(i == 1 ? "wild" : "stable")
    .unpredictability(i == 1 ? 0.33f : 0f)
    .seed(500 + i * 61));
  looms[i] = new LoomGrid(18, 13, pools[i].group, 800 + i * 101, i);
}

int activeIndex(float t) {
  return floor(t / 5.2) % pools.length;
}

Hang The Warp

The warp is the vertical structure of the loom. One thread per pool, each bent by Waves.wave() plus a slow sampler drift. The active pool is thick and opaque; the rest hang back.

start Sample a vertical line

Walk y down the loom bounds and ask Waves.wave() for a horizontal offset at each step.

build Add a second motion

The pool's own sampler adds a slow drift on top of the weave so threads breathe instead of just oscillating.

end product Tensioned structure

MULTIPLY blend lets the threads overlap like real yarn; the active pool reads as the one under load.

p5 · simulation four warp threads, active one under tension
preview: wave + drift -> thread
result: tensioned warp
Processing · instruction drawWarpThreads(t)
void drawWarpThreads(float t) {
  int active = activeIndex(t);
  float[] b = loomBounds();
  blendMode(MULTIPLY);
  noFill();
  for (int i = 0; i < pools.length; i++) {
    Pool pool = pools[i];
    Waves.WaveSampler sampler = threadSamplers[i];
    float baseX = map(i, 0, pools.length - 1, b[0] + b[2] * 0.16, b[0] + b[2] * 0.84);
    float amp = b[2] * (i == active ? 0.125 : 0.07);
    stroke(pool.col, i == active ? 218 : 138);
    strokeWeight(i == active ? 13 : 7);
    beginShape();
    for (float y = b[1]; y <= b[1] + b[3]; y += 10) {
      float local = y * 0.013 + i * 0.41;
      float weave = Waves.wave(local, new WaveOpts()
        .group(pool.group)
        .shift(true)
        .shiftInterval(3.2f + i * 0.35f)
        .shiftDuration(1.1f)
        .seed(1200 + i * 77)
        .t(t * 0.74f)
        .amplitude(amp)
        .frequency(0.72f)
        .mode(i == 1 ? "wild" : "stable")
        .unpredictability(i == 1 ? 0.3f : 0f));
      float drift = sampler.sample(y * 0.01, t) * 34;
      vertex(baseX + weave + drift, y);
    }
    endShape();
  }
  blendMode(BLEND);
}

Throw The Shuttle

The weft is the horizontal pass. The pattern card decides which cells are lit; motionSampler lifts each row and pulseSampler sets the weight of every lit segment.

start Read the card per row

Each row asks the loom grid which columns are on, then only draws lit cells (plus a faint ground every third).

build Two samplers, two jobs

motionSampler tilts the row, pulseSampler maps to stroke weight so the shuttle has rhythm.

end product Woven passes

Lit segments in the pool colour, ground in faint ink — the card becomes a physical-looking weave.

p5 · simulation thresholded weft over the loom grid
preview: card -> lit segments
result: rhythmic weave
Processing · instruction drawWeftPasses(t)
void drawWeftPasses(float t) {
  int active = activeIndex(t);
  Pool pool = pools[active];
  int[] grid = looms[active].sample(t);
  float[] b = loomBounds();
  int rows = looms[active].rows;
  int cols = looms[active].cols;
  float rowGap = b[3] / rows;

  blendMode(MULTIPLY);
  strokeCap(SQUARE);
  for (int row = 0; row < rows; row++) {
    float y = b[1] + rowGap * (row + 0.5);
    float rowPower = motionSampler.sample(row * 0.13, t) * 15;
    for (int col = 0; col < cols; col++) {
      boolean on = grid[row * cols + col] == 1;
      if (!on && (row + col) % 3 != 0) continue;
      float x0 = b[0] + (col / (float)cols) * b[2];
      float x1 = b[0] + ((col + 0.82) / (float)cols) * b[2];
      float pulse = pulseSampler.sample((row + col) * 0.08, t);
      stroke(on ? pool.col : INK, on ? 200 : 34);
      strokeWeight(on ? map(pulse, 0, 1, 2, 7) : 1.2);
      line(x0, y + rowPower, x1, y - rowPower * 0.35);
    }
  }
  strokeCap(ROUND);
  blendMode(BLEND);
}

Punch The Card

The pattern card is the loom's proof panel. Two fixed samplers — a smooth solid sine for rows and a ramp up sine for columns — are summed and thresholded with rv + cv > 0.

start Two fixed waves

Row and column samplers use named waves, not the active pool, so the card stays a stable reference surface.

build Sum and threshold

A nested loop reads rowS(row*5 + t) and colS(col*2.5 - t); sliding the sample positions animates it.

end product Binary punch card

Lit cells in the pool colour, off cells in a checker — a rational readout beside the expressive loom.

p5 · simulation binary field rendered as a punch card
preview: rowS + colS > 0
result: punch-card grid
Processing · instruction class LoomGrid
class LoomGrid {
  int cols;
  int rows;
  int offset;
  Waves.WaveSampler rowS;
  Waves.WaveSampler colS;

  LoomGrid(int cols, int rows, Object group, int seed, int offset) {
    this.cols = cols;
    this.rows = rows;
    this.offset = offset;
    rowS = Waves.createSampler(new WaveOpts()
      .wave("smooth solid sine")
      .range(-1, 1)
      .seed(seed));
    colS = Waves.createSampler(new WaveOpts()
      .wave("ramp up sine")
      .range(-1, 1)
      .seed(seed + 37));
  }

  int[] sample(float t) {
    int[] cells = new int[cols * rows];
    for (int row = 0; row < rows; row++) {
      float rv = rowS.sample(row * 5 + t);
      for (int col = 0; col < cols; col++) {
        float cv = colS.sample(col * 2.5 - t);
        cells[row * cols + col] = rv + cv > 0 ? 1 : 0;
      }
    }
    return cells;
  }
}

Make The Title Breathe

The word stays typographic first, live second. Each letter is lifted by motionSampler at its own sample position; letters 7 and 8 (the L–O of LOOM) take an accent colour from colorSampler.

start Keep the word intact

Draw letter by letter with a cursor so spacing and the baseline of SIGNAL LOOM stay correct.

build Sample each glyph

One sampler, different sample index per letter, so the title moves as a family rather than randomly.

end product Alive, not unstable

The lift is scaled to the font size so the word drifts just enough to read as live, never broken.

p5 · simulation one word, offset per letter
preview: letter -> sampled lift
result: typography that breathes
Processing · instruction drawTitle(t)
void drawTitle(float t) {
  float m = max(18, min(width, height) * 0.05);
  float x = m;
  float[] panel = panelBounds();
  float maxW = width < 900 ? width - m * 2 : max(360, panel[0] - x - 24);
  float size = fitSize("SIGNAL LOOM", maxW, width < 760 ? 54 : 92, 28);
  float y = width < 760 ? height * 0.14 : height * 0.105;

  textFont(displayFont);
  textAlign(LEFT, TOP);
  textSize(size);
  float cursor = x;
  String label = "SIGNAL LOOM";
  for (int i = 0; i < label.length(); i++) {
    char ch = label.charAt(i);
    if (ch == ' ') {
      cursor += size * 0.28;
      continue;
    }
    float lift = motionSampler.sample(i * 0.23, t) * size * 0.055;
    fill(i == 7 || i == 8 ? accent(colorSampler.sample(i * 0.18, t)) : INK);
    text(ch, cursor, y + lift);
    cursor += textWidth(ch) * 0.98;
  }

  textFont(monoFont);
  textSize(width < 760 ? 13 : 16);
  fill(INK);
  text("a live loom for wave signals: source, tension, shuttle, pattern, output", x, y + size * 1.08);
}

Expose The Tension

The readout turns the loom into an instrument. The bar fill is the sampler's mix() during a shift; the labels expose the current and target wave name of the active pool.

start Pump the sampler

Call sampler.sample() once first so shifting(), mix(), and the wave names are current this frame.

build State becomes UI

Bar fill = mix(), left label = tension + pool key, right label = waveName -> targetName.

end product Readable instrument

The loom explains itself without becoming a dashboard — the logic is present, but still graphic.

p5 · simulation mix bar + wave-name labels
preview: mix() -> bar fill
result: state as composition
Processing · instruction drawMachineReadout(t)
void drawMachineReadout(float t) {
  int active = activeIndex(t);
  Pool pool = pools[active];
  Waves.WaveSampler sampler = threadSamplers[active];
  sampler.sample(0.42, t);
  float[] p = panelBounds();
  float[] b = loomBounds();
  float x = width < 900 ? b[0] : p[0];
  float y = width < 900 ? b[1] + b[3] + 48 : p[1] + p[3] + 42;
  float w = width < 900 ? min(b[2], 520) : p[2];
  float h = 34;
  float mix = sampler.shifting() ? sampler.mix() : pulseSampler.sample(0.5, t);

  noStroke();
  fill(INK);
  rect(x, y, w, h);
  fill(pool.col);
  rect(x, y, w * constrain(mix, 0, 1), h);
  fill(mix > 0.55 ? INK : PAPER);
  textFont(monoFont);
  textAlign(LEFT, CENTER);
  textSize(11);
  text(("tension " + pool.key).toUpperCase(), x + 12, y + h * 0.5);
  fill(PAPER);
  textAlign(RIGHT, CENTER);
  text((shortName(sampler.waveName()) + " -> " + shortName(sampler.targetName())).toUpperCase(), x + w - 12, y + h * 0.5);
}

Let Vocabulary Drift

Loom words become marginalia. pulseSampler gates whether a word appears, motionSampler drifts it — context behind the hero statement, never filler on top.

start A small vocabulary

Words that belong to the machine: warp, weft, tension, shuttle, pattern, phase, morph, output.

build Pulse gates, motion drifts

Below 0.38 the word is invisible this frame; above it, motionSampler is read twice — once for x, once for y (offset t + 2).

end product Context without clutter

Ghost labels that flicker with the same pulse driving the weave — atmosphere from the same engine.

p5 · simulation pulse-gated drifting vocabulary
preview: pulse -> visibility
result: ambient language field
Processing · instruction buildGlyphs() + drawGlyphField(t)
void buildGlyphs() {
  randomSeed(16);
  String[] words = { "warp", "weft", "tension", "shuttle", "pattern", "phase", "morph", "output" };
  int count = constrain(floor((width * height) / 21000.0), 28, 82);
  glyphs = new Glyph[count];
  for (int i = 0; i < count; i++) {
    glyphs[i] = new Glyph(random(width), random(height), i, words[(int)random(words.length)], random(10, 15));
  }
}

void drawGlyphField(float t) {
  textFont(monoFont);
  textAlign(CENTER, CENTER);
  for (Glyph g : glyphs) {
    float p = pulseSampler.sample(g.seed * 0.19, t);
    if (p < 0.38) continue;
    float dx = motionSampler.sample(g.seed * 0.11, t) * 22;
    float dy = motionSampler.sample(g.seed * 0.17, t + 2) * 17;
    fill(INK, map(p, 0.38, 1, 44, 145));
    textSize(g.size);
    text(g.word, g.x + dx, g.y + dy);
  }
}

The Full Loom

Every module above, running together. This frame is the p5.waves loom; the Processing blocks above are its sibling — same structure, each using the strengths of its runtime.