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.
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.
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.
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();
}
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.
// 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;
}
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.
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);
}
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.
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);
}
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.
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;
}
}
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.
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);
}
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.
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);
}
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.
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);
}
}
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.