A cymatic sand table you play in the browser.
Nodal renders ~15,000 luminous grains that walk down the amplitude gradient of a
real Chladni standing-wave field and settle on its nodal lines — the dark curves
where the surface doesn't move. Sweep the drive frequency with your cursor and a
formless dust cloud snaps into a precise figure; a sine oscillator plays the exact
frequency the grains are dancing to. It's one self-contained index.html —
vanilla JavaScript, Canvas2D, and Web Audio, no build step and no dependencies. It
runs offline by double-clicking the file.
It is built around one idea: the visible figure is honest physics, not a canned animation. Every nodal line a grain lands on is a zero of an equation you can read in the source, and the pitch you hear is proportional to that mode's true eigenfrequency.
Grains follow a gradient descent on the field magnitude |F|, moving along
−F·∇F (which is −½∇F²). The force vanishes as F → 0, so grains decelerate and
settle exactly on the nodal lines. A temperature term — random jitter scaled by
|F| · amplitude — keeps antinodes (high |F|) agitated while nodes stay calm, so
the lines self-assemble out of noise. Two plate geometries share this same solver:
f(x, y) = cos(nπx)·cos(mπy) − cos(mπx)·cos(nπy) on the unit square
36 modes (integer pairs n < m, both in 1…9), ordered by eigenfrequency
ω = √(n² + m²). Displayed frequency is 90 · ω, which lands the lattice in an
audible ~201–1084 Hz.
Square-plate eigenfrequencies are degenerate: distinct modes can share the same
ω (e.g. (1,8) and (4,7), both √65). Those modes sit next to each other in
the sorted list, so scrubbing through them morphs the geometry while the pitch
holds constant — the honest way to experience degeneracy, with no extra gesture.
A ⟳ marker in the readout flags when you're inside a degenerate cluster.
u(r, θ) = J_m(k·r)·cos(mθ) on the disc, with k·R = α_{m,s}
The Chladni modes of a clamped circular membrane (a drumhead — a Helmholtz
problem, not a biharmonic metal plate). J_m is the order-m Bessel function of
the first kind, and α_{m,s} is its s-th positive zero, which fixes the
wavenumber so the rim is always a node. The figures are concentric rings (one per
interior zero), m nodal diameters, and the rim circle. 28 modes (m = 0…6,
s = 1…4) ordered by α; displayed frequency is 53 · α, giving ~127–1077 Hz.
On honesty. The round plate is a membrane model. A real metal Chladni plate is a stiff biharmonic (
∇⁴) plate with different radial solutions and frequency spacing; the membrane is the standard, honest interactive choice and is labelled as such in the source and the in-app equation card. The displayed Hz is proportional to each geometry's true eigenfrequency (a constant chosen for audibility), not an absolute physical pitch.
- Press and drag the plate — horizontal = pitch (which mode), vertical = intensity.
- Release — a "harmonic stop": the figure crystallizes as the grains settle and the tone fades.
- Hold Shift while dragging — lock intensity, sweep pitch cleanly.
- Tap — pour a fresh clump of grains at the cursor; it disperses into the figure.
- Listen mode — sing, whistle, or play a tone and the plate resonates at the nearest natural frequency it hears.
| Key | Action | Key | Action |
|---|---|---|---|
G |
Square ⇄ round geometry | L |
Listen (microphone) |
A |
Auto-sweep (hands-free tour) | I |
Show the field equation |
1–4 |
Palette | R |
Reseed grains |
S |
Save plate as PNG | H |
Hide all UI (clean view) |
Space |
Mute audio | ←→ ↑↓ |
Fine pitch / intensity |
A hairline control rail (top-right ⋮) holds the same controls plus a four-slot
rack for capturing and recalling favorite plates (saved to localStorage,
with per-slot geometry-aware thumbnails).
Detects the dominant pitch via an FFT peak (with parabolic sub-bin refinement, an
octave guard, a noise gate, and median + EMA smoothing), then snaps the scrub to
the nearest mode in log-frequency space. Input loudness drives the amplitude. The
synth oscillator mutes while listening so you hear only your own input (and there's
no acoustic feedback), and a ◉ REC glyph shows in the readout whenever the mic is
live. It only requests microphone access on an explicit press, and surfaces the
real error if access is denied.
Four density ramps named after photographic processes — Bronze Dust, Selenium, Cyanotype, Platinum. Color is carried entirely by grain density; there is no second color channel.
grains (typed-array SoA) → gradient-descent step → splat into a 640² float
accumulation buffer → log/Reinhard tonemap through a 1-D palette LUT →
drawImage-upscale to the device-pixel canvas
- Grains live in four
Float32Arrays (x, y, vx, vy). Physics is closed-form per grain — cheap trig for the square plate; for the round plate, a 513-point radial lookup table ofJ_m(k·r)andJ_m'(k·r)is built once per mode so there are zero Bessel evaluations per grain. - Bessel functions use Miller (downward) recurrence with Neumann normalization — the stable method for
n > x— and McMahon-seeded Newton iteration for the zeros. Verified against reference values to ~1e-11. - Rendering is decoupled from display resolution: the accumulation buffer and tonemap run at a fixed 640², then a single
drawImageupscales to the DPR canvas. The tonemap uses a fixed white-point so the figure never flickers or blows out. A frame-time guard degrades accumulation resolution first, grain count second. - Audio is one sine oscillator (plus a quiet octave overtone for small speakers) whose frequency equals the displayed Hz, ramped with
setTargetAtTime. TheAudioContextis created lazily on first gesture (autoplay-safe). - The round plate's disc emerges from grain density falling to zero past the rim (grains reflect off the circle), not from a mask.
Single file, ~1,050 lines, ~54 KB. Hot path measures ~2.5 ms/frame at 15k grains.
The aesthetic is instrument-grade scientific apparatus, not an arcade demo. The
plate is the only hero; controls hide in a hairline rail and equipment-style
labels until you reach for them. One warm accent carries the grains; dim cyan is
reserved strictly for hairline UI. No gradients-as-decoration, no glassmorphism, no
emoji, a single offline monospace stack, and motion lives almost entirely in the
grain physics. prefers-reduced-motion is respected.
- Double-click
index.html. It's fully self-contained and works offline overfile://(it uses a classic<script>, not an ES module, specifically so this works). - Or serve it:
python3 -m http.server 8000then openhttp://localhost:8000/.
Needs a modern browser with Canvas2D. Audio and microphone are optional — the simulation runs silently without them, and a fallback notice appears if Canvas is unavailable.
Concept, physics, and architecture were chosen and pressure-tested with a
verification-first process: the circular-membrane gradient (including the easily-
mistaken sign asymmetry between ∂u/∂x and ∂u/∂y), two independent Bessel
implementations cross-checked against each other and known values, and the
microphone DSP recipe were all reviewed before being written, and every claim
above — Bessel accuracy, the rim-is-a-node boundary condition, frame rate, and the
frequency mappings — was confirmed live in the browser.