Acoustics

Chladni figures

Sand on a metal plate. Bow it and the sand runs away from everywhere that is moving and piles up along the lines that are not. You end up looking directly at a standing wave.

EXP-028

Move the cursor over the canvas — it is part of the simulation.

Drag your finger across the canvas — it is part of the simulation.

What to look for

Change m and n by one and the figure reorganises completely, which is the point: these patterns are not continuous in the parameters, they are discrete modes. Setting m equal to n cancels the expression exactly and the plate goes silent, and the sand just sits wherever it happened to be.

The maths

u(x,y) = cos(nπx)cos(mπy) − cos(mπx)cos(nπy)

A mode of a square plate with free edges. The subtraction is what matters: it is the antisymmetric combination of two degenerate modes, and it is what produces the curved, non-obvious figures rather than a plain grid.

u = 0

The nodal lines. Every point where the plate is not moving at all, at any moment in the cycle. This is where the sand ends up, so the pattern you see is literally the zero set of a function.

ṙ ∝ −∇|u| + ξ·|u|

How each grain is moved: downhill on the amplitude, plus a random kick whose size is the local amplitude. Where the plate is loud the grain bounces and wanders; where it is silent the kick is zero and the grain stays. Nobody tells the sand where the lines are.

How it is built

A few thousand grains, each following the gradient of |u| estimated by finite differences, plus noise proportional to the local amplitude. That second term is the whole mechanism and it is not a trick: it is why real sand accumulates at nodes, because a vibrating region keeps throwing grains until one lands somewhere that does not throw it back. I measured it: mean |u| across the grains starts at 0.541 for a random scatter and settles at 0.070.

The code, step by step

Cut straight from lab.js. These are the real lines that run above, not a simplified version.

01

The mode shape

const u = (x, y, m, n) =>
  Math.cos(n * Math.PI * x) * Math.cos(m * Math.PI * y) -
  Math.cos(m * Math.PI * x) * Math.cos(n * Math.PI * y);

Two standing waves with their indices swapped, subtracted. The subtraction is not decoration: it is the antisymmetric combination of two modes that happen to have the same frequency, and it is what turns a boring grid of squares into the curved figures Chladni actually drew.

02

How a grain finds a node

const e = 0.004;
for (let k = 0; k < g.length; k++) {
  const p = g[k];
  const a = Math.abs(u(p[0], p[1], m, n));
  const gx = (Math.abs(u(p[0] + e, p[1], m, n)) - Math.abs(u(p[0] - e, p[1], m, n))) / (2 * e);
  const gy = (Math.abs(u(p[0], p[1] + e, m, n)) - Math.abs(u(p[0], p[1] - e, m, n))) / (2 * e);
  // Dos ruidos distintos. El proporcional a la amplitud es el que
  // expulsa los granos de las zonas que vibran. El constante es el que
  // los deja caminar A LO LARGO de la línea nodal una vez que llegan:
  // sin él se quedan pegados en el primer punto que tocan y la figura
  // sale como charcos sueltos en vez de curvas. Medido: 0.038 sextuplica
  // la cobertura sin sacarlos del nodo (|u| medio 0.070 contra 0.068).
  const kick = a * 0.010, walk = 0.038;
  p[0] += -gx * 0.0016 + (Math.random() - 0.5) * kick + (Math.random() - 0.5) * walk;
  p[1] += -gy * 0.0016 + (Math.random() - 0.5) * kick + (Math.random() - 0.5) * walk;
  p[0] = Math.max(0, Math.min(1, p[0]));
  p[1] = Math.max(0, Math.min(1, p[1]));

Two terms. Downhill on |u|, by finite difference. And a random kick whose size is the local amplitude — that is the real mechanism: a vibrating patch of plate keeps throwing grains until one lands where it will not be thrown again. No grain knows where the nodal lines are; they are just the only places that stop shaking.

03

The field underneath

const RES = 120, img = ctx.createImageData(RES, RES), d = img.data;
const rgb = acc.split(',').map(Number);
for (let j = 0; j < RES; j++) for (let i = 0; i < RES; i++) {
  const a = Math.abs(u(i / RES, j / RES, m, n)) / 2;
  const o = (j * RES + i) * 4;
  d[o] = rgb[0] * a * 0.30; d[o+1] = rgb[1] * a * 0.30; d[o+2] = rgb[2] * a * 0.30; d[o+3] = 255;
}
const off = document.createElement('canvas');
off.width = off.height = RES; off.getContext('2d').putImageData(img, 0, 0);
ctx.drawImage(off, px, py, S, S);

A low-resolution buffer of |u| drawn faintly under the grains, scaled up with smoothing. It is there so you can see what the sand is responding to, and it makes it obvious that the grains are settling exactly on the dark curves rather than approximately near them.

Why it matters

Chladni toured Europe doing this in the 1780s and it was the closest thing to seeing sound. Napoleon set a prize for explaining it mathematically; Sophie Germain won it in 1816 after three attempts, working outside the academy because she was not allowed inside it. The theory of vibrating plates came out of that prize.

The whole thing

Everything above, in one piece. This is the entire experiment as it lives in lab.js.

FULL

lab.js · chladni

{ id: 'chladni',
      name: L('Chladni figures', 'Figuras de Chladni'),
      note: L('Sand on a vibrating plate walks off the loud parts and piles on the silent ones.',
              'La arena sobre una placa que vibra huye de lo que suena y se apila en lo callado.'),
      params: [
        { key: 'm',    label: L('Mode m', 'Modo m'), min: 1, max: 9, step: 1, def: 3 },
        { key: 'n',    label: L('Mode n', 'Modo n'), min: 1, max: 9, step: 1, def: 5 },
        { key: 'grain', label: L('Grains', 'Granos'), min: 400, max: 6000, step: 100, def: 2600 }
      ],
      make(w, h) {
        let g = [], K = 0;
        const seed = (n, w, h) => {
          g = [];
          for (let i = 0; i < n; i++) g.push([Math.random(), Math.random()]);
          K = n;
        };
        seed(2600, w, h);
        // Placa cuadrada libre: la combinación antisimétrica es la que produce
        // las figuras que dibujó Chladni en 1787.
        const u = (x, y, m, n) =>
          Math.cos(n * Math.PI * x) * Math.cos(m * Math.PI * y) -
          Math.cos(m * Math.PI * x) * Math.cos(n * Math.PI * y);
        return {
          reset() { seed(K, w, h); },
          step(ctx, w, h, t, acc, P, M) {
            if ((P.grain | 0) !== K) seed(P.grain | 0, w, h);
            const m = P.m | 0, n = P.n | 0;
            const S = Math.min(w, h) * 0.78, px = (w - S) / 2, py = (h - S) / 2;

            // Campo de fondo
            const RES = 120, img = ctx.createImageData(RES, RES), d = img.data;
            const rgb = acc.split(',').map(Number);
            for (let j = 0; j < RES; j++) for (let i = 0; i < RES; i++) {
              const a = Math.abs(u(i / RES, j / RES, m, n)) / 2;
              const o = (j * RES + i) * 4;
              d[o] = rgb[0] * a * 0.30; d[o+1] = rgb[1] * a * 0.30; d[o+2] = rgb[2] * a * 0.30; d[o+3] = 255;
            }
            const off = document.createElement('canvas');
            off.width = off.height = RES; off.getContext('2d').putImageData(img, 0, 0);
            ctx.drawImage(off, px, py, S, S);
            ctx.strokeStyle = `rgba(${acc},0.35)`;
            ctx.lineWidth = 1; ctx.strokeRect(px, py, S, S);

            // Cada grano baja por el gradiente de |u| y salta según lo fuerte
            // que vibre donde está. Donde u = 0 no hay salto: ahí se queda.
            const e = 0.004;
            for (let k = 0; k < g.length; k++) {
              const p = g[k];
              const a = Math.abs(u(p[0], p[1], m, n));
              const gx = (Math.abs(u(p[0] + e, p[1], m, n)) - Math.abs(u(p[0] - e, p[1], m, n))) / (2 * e);
              const gy = (Math.abs(u(p[0], p[1] + e, m, n)) - Math.abs(u(p[0], p[1] - e, m, n))) / (2 * e);
              // Dos ruidos distintos. El proporcional a la amplitud es el que
              // expulsa los granos de las zonas que vibran. El constante es el que
              // los deja caminar A LO LARGO de la línea nodal una vez que llegan:
              // sin él se quedan pegados en el primer punto que tocan y la figura
              // sale como charcos sueltos en vez de curvas. Medido: 0.038 sextuplica
              // la cobertura sin sacarlos del nodo (|u| medio 0.070 contra 0.068).
              const kick = a * 0.010, walk = 0.038;
              p[0] += -gx * 0.0016 + (Math.random() - 0.5) * kick + (Math.random() - 0.5) * walk;
              p[1] += -gy * 0.0016 + (Math.random() - 0.5) * kick + (Math.random() - 0.5) * walk;
              p[0] = Math.max(0, Math.min(1, p[0]));
              p[1] = Math.max(0, Math.min(1, p[1]));
            }

            ctx.fillStyle = `rgba(${acc},0.85)`;
            for (let k = 0; k < g.length; k++)
              ctx.fillRect(px + g[k][0] * S - 0.6, py + g[k][1] * S - 0.6, 1.5, 1.5);

            ctx.font = '11px monospace';
            ctx.fillStyle = `rgba(${acc},0.55)`;
            ctx.fillText('u = cos(nπx)cos(mπy) − cos(mπx)cos(nπy)      grains settle where u = 0', 12, h - 28);
            ctx.fillStyle = `rgba(${acc},0.95)`;
            ctx.fillText('m = ' + m + '   n = ' + n + '   ' + g.length + ' grains', 12, h - 12);
          }
        };
      }
    },