A cluster of FM bells at closely-spaced frequencies — they beat against each other, drifting in and out of consonance. Short envelopes, fast trigger, long delay. Less Eno, more chime cabinet.
lattice-previewimport type { ElementaryPatch } from '@/lib/engine/types'
const lattice: ElementaryPatch = {
id: 'lattice',
title: 'Lattice',
author: 'stuart',
year: 2026,
tags: ['fm', 'cluster', 'glassy'],
description:
'A cluster of FM bells at closely-spaced frequencies — they beat against each other, drifting in and out of consonance. Short envelopes, fast trigger, long delay. Less Eno, more chime cabinet.',
engine: 'elementary',
audio: ({ el, rng, schedule, render }) => {
// Center freqs in glass-bell range. Each cluster picks 5 voices closely
// spaced (10–25 Hz apart) for beating textures.
const centers = [440, 523.25, 587.33, 659.25, 783.99]
const interval = 6 // re-cluster every 6s
schedule(interval, (tick) => {
const center = rng.pick(centers, tick, 0)
const spread = rng.range(8, 24, tick, 1) // Hz between voices
const fmRatio = rng.pick([2, 3, 5, 7], tick, 2)
const fmDepth = rng.range(20, 90, tick, 3)
const trig = rng.pick([0.45, 0.6, 0.75, 0.9], tick, 4) // seconds between bells
// Five FM bell voices at center ± n*spread
const voices = [-2, -1, 0, 1, 2].map((offset, i) => {
const f = center + offset * spread
const modFreq = f * fmRatio
const mod = el.mul(
el.cycle(el.const({ key: `modOsc-${i}`, value: modFreq })),
el.const({ key: `modAmt-${i}`, value: fmDepth }),
)
return el.cycle(el.add(el.const({ key: `car-${i}`, value: f }), mod))
})
// Trigger: a metro per voice, slightly offset by index so the cluster
// doesn't all hit on the same downbeat
const enveloped = voices.map((osc, i) => {
const gate = el.metro({
name: `bell-${i}`,
interval: trig * 1000 + i * 47, // ms, prime-ish offset for variety
})
const env = el.adsr(0.002, 0.35, 0, 0, gate)
return el.mul(osc, el.mul(env, 0.18))
})
const cluster = el.add(...enveloped)
// Long stereo-ish delay (mono delay here, but long tail)
const delayed = el.delay(
{ key: 'echo', size: 96000 },
el.const({ key: 'dlen', value: 22000 }),
el.const({ key: 'dfb', value: 0.55 }),
cluster,
)
const wet = el.add(cluster, el.mul(delayed, 0.45))
// Slight high-shelf via highpass to keep it from getting muddy
const filtered = el.highpass(
el.const({ key: 'hpf', value: 220 }),
0.7,
wet,
)
const out = el.mul(el.tanh(el.mul(filtered, 0.45)), 0.18)
render(out, out)
})
},
visual: ({ ctx, rng, t, width, height, mouseX, mouseY, mouseActive, getRms, getWaveform }) => {
// Constellation — a ring of orbiting nodes connected by hairlines.
// Cursor pulls each node gently toward it (gravity), and the central
// core drifts toward the cursor over a few seconds when mouse is active.
ctx.fillStyle = 'rgba(12, 12, 12, 0.10)'
ctx.fillRect(0, 0, width, height)
const cx = width / 2
const cy = height / 2
const baseR = Math.min(width, height) * 0.32
const nodeCount = 16
const ticks = Math.floor(t / 0.4)
// Mouse position in canvas coordinates (only when active)
const mx = mouseActive ? mouseX * width : cx
const my = mouseActive ? mouseY * height : cy
const pullStrength = mouseActive ? 0.18 : 0
const pts: { x: number; y: number; bright: number }[] = []
for (let i = 0; i < nodeCount; i++) {
const angle = (i / nodeCount) * Math.PI * 2 + t * 0.08
const wobble = rng.range(-25, 25, ticks, i + 7)
const radius = baseR + wobble
let x = cx + Math.cos(angle) * radius
let y = cy + Math.sin(angle) * radius
// gravity toward mouse
x += (mx - x) * pullStrength
y += (my - y) * pullStrength
const bright = 0.4 + 0.6 * Math.sin(t * 1.5 + i * 0.6)
pts.push({ x, y, bright })
}
ctx.strokeStyle = 'rgba(232, 230, 225, 0.18)'
ctx.lineWidth = 1
ctx.beginPath()
for (let i = 0; i < pts.length; i++) {
const a = pts[i]
const b = pts[(i + 1) % pts.length]
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
for (const p of pts) {
ctx.fillStyle = `rgba(232, 230, 225, ${0.3 + p.bright * 0.6})`
ctx.beginPath()
ctx.arc(p.x, p.y, 4 + p.bright * 5, 0, Math.PI * 2)
ctx.fill()
}
// Central pulsing core — RMS-reactive when audio plays, else just LFO
const audioRms = getRms()
const corePulse = audioRms > 0
? Math.min(1, audioRms * 8) // scale RMS to a visible range
: 0.5 + 0.5 * Math.sin(t * 0.8)
const coreX = mouseActive ? cx + (mx - cx) * 0.35 : cx
const coreY = mouseActive ? cy + (my - cy) * 0.35 : cy
ctx.fillStyle = `rgba(239, 68, 68, ${0.35 + corePulse * 0.5})`
ctx.beginPath()
ctx.arc(coreX, coreY, 8 + corePulse * 24, 0, Math.PI * 2)
ctx.fill()
// Optional: draw waveform as a soft ring inside the core
const wave = getWaveform()
if (wave) {
ctx.strokeStyle = 'rgba(232, 230, 225, 0.3)'
ctx.lineWidth = 1.2
ctx.beginPath()
const ringR = baseR * 0.18
for (let i = 0; i < wave.length; i++) {
const a = (i / wave.length) * Math.PI * 2
const r = ringR + wave[i] * baseR * 0.15
const x = coreX + Math.cos(a) * r
const y = coreY + Math.sin(a) * r
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
ctx.stroke()
}
},
}
export default lattice