field notes
An instrument,
disguised as a website
How fourteen glowing organisms, one AnalyserNode, and a fragment shader become a playable sound garden. Everything below is the real code that runs the field — no samples, no stock, no generated images.
01Concept & creative direction
Most websites narrate. This one listens. The brief was the single non-scroll-narrative site of the fifteen: one continuous world where the visitor is a performer, not a reader. The fiction is a nocturnal garden — bioluminescent organisms in an indigo field, each tuned to a note of a pentatonic scale across two octaves from A2.
Three rules shaped every decision. Nothing shrill: sine and triangle voices, lowpassed everywhere, soft 65 ms attacks, six-second tails. Sound is visible: every note becomes light — a bloom, a spore burst, and a ripple in the ground whose wavelength tightens with pitch. The garden is alive without you: organisms breathe, dust drifts, and after twenty untouched seconds it starts playing itself.
02Palette & type
#11131F#0A0C16#6EE7FF#A78BFA#FF9EDBPitch maps onto the accent gradient: low notes glow aqua, the middle register turns violet, the top blooms pink. The same ramp lives in three places — the organism textures, the ripple shader, and the custom cursor — so color always means frequency.
Unbounded · variable 200–900 · display
Schibsted Grotesk · body
03The voice — a synthesis graph with no samples
Each organism triggers a freshly built voice: a sine fundamental, a triangle detuned +7 cents for slow beating, a quiet sub-octave for warmth, and a late-swelling octave partial — the “bioluminescent glow” you hear arrive a third of a second after the attack. All of it breathes through one lowpass whose cutoff blooms open and settles.
sine ─┐ ┌─ dry ────────────┐
tri+7¢┤ │ ▼
sub ┼─▶ lowpass ─▶ gain env ─▶ pan ─▶ voice bus master ─▶ comp ─▶ ANALYSER ─▶ out
2f ┘ (bloom) (65ms/6s) │ ▲ │
└─▶ ping-pong FDN ─┘ ▼
delays A⇄B, lowpassed FFT texture
→ ground shader
// src/audio.js — the heart of every note
const oscA = ctx.createOscillator(); oscA.type = 'sine'; oscA.frequency.value = f
const oscB = ctx.createOscillator(); oscB.type = 'triangle'; oscB.frequency.value = f
oscB.detune.value = 7 // slow beating against the sine
const sub = ctx.createOscillator(); sub.frequency.value = f / 2
const shim = ctx.createOscillator(); shim.frequency.value = f * 2
gShim.gain.setValueAtTime(0, t)
gShim.gain.linearRampToValueAtTime(0.085, t + 0.35) // the late glow
gShim.gain.setTargetAtTime(0, t + 0.9, 1.2)
// filter bloom: opens fast, settles warm
filter.frequency.setValueAtTime(f * 1.4, t)
filter.frequency.linearRampToValueAtTime(Math.min(f * 5.5, 5200), t + 0.09)
filter.frequency.setTargetAtTime(f * 2.2, t + 0.09, 0.9)
// amplitude: soft 65ms attack, ~6s exponential tail
amp.gain.setValueAtTime(0, t)
amp.gain.linearRampToValueAtTime(vel * 0.6, t + 0.065)
amp.gain.setTargetAtTime(0, t + 0.12, 1.5)
The reverb is a ping-pong feedback network rather than a convolver — two cross-fed delay lines (311 ms and 427 ms, deliberately non-integer ratios) with a lowpass inside the loop, so every echo returns softer and darker. Warmth by construction: the tail physically cannot build up brightness.
// src/audio.js — ping-pong feedback loop: A -> B -> A, lowpassed both ways
input.connect(pre) // 28ms predelay
pre.connect(dA)
dA.connect(lpA); lpA.connect(fbA); fbA.connect(dB) // A -> B
dB.connect(lpB); lpB.connect(fbB); fbB.connect(dA) // B -> A (the loop)
dA.connect(panA); dB.connect(panB) // taps pan hard left / right
panA.connect(wet); panB.connect(wet)
04FFT → shader — how the ground hears
The master chain ends in an AnalyserNode (fftSize 256).
Every frame, its 128 frequency bins are copied into a 128×1
DataTexture and its time-domain buffer is folded into a
smoothed RMS level. The ground plane is a single quad — all the detail is fragment work.
// src/audio.js — per-frame tap
pump() {
this.analyser.getByteFrequencyData(this.fft) // 128 bins -> texture
this.analyser.getByteTimeDomainData(this.wave) // -> RMS level
let sum = 0
for (let i = 0; i < this.wave.length; i++) {
const v = (this.wave[i] - 128) / 128
sum += v * v
}
const rms = Math.sqrt(sum / this.wave.length)
this.level += (rms - this.level) * 0.25 // smoothed
return { fft: this.fft, level: this.level }
}
// src/field.js — upload
this.fftTex = new THREE.DataTexture(this.fftData, 128, 1,
THREE.RedFormat, THREE.UnsignedByteType)
if (fft) this.fftData.set(fft)
this.fftTex.needsUpdate = true
In the shader, distance from the field’s heart indexes the spectrum — bass wraps the organisms, treble halos the horizon — and the RMS level feeds the mycelium filaments, so the whole floor brightens when the garden sings.
Ripples that actually interfere
Every note pushes a ripple into a 16-slot uniform ring buffer: position, birth time, and pitch. Each ripple is a travelling gaussian packet of signed sine — and the packets are summed per color channel. That sign is the whole trick: where crests align the sum spikes and the field flares white-hot; where phases oppose, the waves cancel back to dark indigo. Two-source interference, the physics-textbook figure, drawn by your chord.
// src/shaders/ground.frag.glsl — the signature loop
vec3 waveField = vec3(0.0);
float waveSum = 0.0;
for (int i = 0; i < MAX_RIPPLES; i++) {
vec4 r = uRipples[i]; // x, z, birth, pitch01
float age = uTime - r.z;
if (r.z < 0.0 || age < 0.0 || age > 7.0) continue;
float d = distance(P, r.xy);
float x = d - age * 3.1; // wavefront radius
float packet = exp(-x * x / (0.5 + age * 0.55));
float k = 3.0 + r.w * 5.5; // higher pitch -> tighter wavelength
float w = sin(x * k) * packet * exp(-age * 0.6) * uRippleAmp[i];
waveField += pitchColor(r.w) * w; // SIGNED sum -> real interference
waveSum += w;
}
col += max(waveField, vec3(0.0)) * 1.15;
col += vec3(1.0) * pow(max(waveSum, 0.0), 3.0) * 0.32; // aligned crests flare
Live 2D model of the same math — click to drop ripples, drop two close together and watch the bands:
05The garden that plays itself
After twenty untouched seconds an idle composer wakes. It is a weighted random walk over scale degrees — tuned until it stopped sounding random and started sounding shy:
| event | weight | why |
|---|---|---|
| step ±1 degree | 48% | melody is mostly stepwise |
| skip ±2 degrees | 26% | gentle contour changes |
| repeat the note | 8% | insistence reads as intent |
| leap ±4 degrees | 10% | rare surprise, never twice |
| drift toward home | 8% | keeps phrases centered low |
| dyad a third above | 12% | harmony as seasoning |
| long rest ×2.4 | 9% | music needs breath |
| low anchor note | every 8–13 events | grounds the phrase on the root |
The loop button records something different from audio: timing. It captures
{ at, index, velocity } tuples and replays them through the
same trigger path as your finger — so a loop re-blooms the organisms, re-ripples the
ground, and even retunes itself if you switch the scale mid-loop. Loops are patterns of
gesture, not recordings of sound.
06Asset pipeline
This site ships zero AI-generated images — the brief demanded it, and the aesthetic is better for it. Every organism texture is drawn at runtime on a 256px canvas: five to eight gradient petals around a hot core, seeded by a deterministic PRNG (mulberry32) so organism №7 always grows the same petals. Rings, beams, spores, and dust are canvas gradients too. The only binary assets are three self-hosted woff2 files (Unbounded variable + Schibsted Grotesk) and the OG image — which is simply a screenshot of the live field mid-bloom, triggered through the same QA hooks used to test the audio.
07Iteration diary
Pass 1 — structure
Built the full skeleton: engine, field, composer, veil, HUD, drawer, guide. A 31-check scripted probe confirmed the audio graph headless (the harness can’t hear): AudioContext unlocks on the veil gesture, notes drive the analyser to RMS 0.26, the drone sustains, loops replay audibly, the idle composer sings. Fixed a hard horizon seam by fading the ground plane’s alpha into the fog so the field dissolves into the night sky, pulled clipped edge organisms back into frame, and made this page’s demo re-seed itself.
Pass 2 — craft
Read every capture like a critic. The ground’s twinkle cells were lighting up as hard rectangles — replaced with round gaussian motes. Rendered the OG image from a live mid-bloom frame, parked the custom cursor out of captures, fixed the keyboard hint’s “0” rendering as “O” in Unbounded, and tightened the organism spread against the camera drift. Report clean at all three widths, both routes.
Pass 3 — complexify & refine
Added the detail that makes the garden feel inhabited: gesture threads — when it dreams or replays your loop, a faint glowing arc travels from the previous organism to the next, drawing the melody’s constellation. Human touches don’t get threads; your hand already draws its own line. Made the hint yield to the dream caption, re-verified the full probe + harness suite, and re-rendered the OG frame.