Divisions of Labor and Joy

Zachary · ·

The homepage of this site is a single <canvas> element running one cartoonishly large requestAnimationFrame loop. Everything you see and hear comes out of that loop: the waveform, the music, the interaction, the color. There’s no framework rendering it, no WebGL, no dependencies beyond a soundfont loader. Just ctx.lineTo and a lot of Math.sin. This is technically legal.

The waveform

The visual is modeled after Peter Saville’s cover for Joy Division’s Unknown Pleasures, which itself was a plot of successive radio pulses from the pulsar CP 1919. The original image was printed in a 1970 PhD thesis by Harold Craft before it became one of the most recognizable album covers in history.

The technique is simpler than it looks. Each row is a horizontal line whose y-position is displaced by layered sine waves at different frequencies:

function computeNoise(t, r, time) {
  var n = 0;
  n += Math.sin(t * 15 + r * 0.9 + time) * 0.2;
  n += Math.sin(t * 33 + r * 1.6 + time * 1.5) * 0.15;
  n += Math.sin(t * 70 + r * 2.5 + time * 0.3) * 0.1;
  n += Math.sin(t * 120 + r * 3.1 + time * 0.7) * 0.06;
  n += Math.sin(t * 8 + r * 0.4 + time * 0.6) * 0.25;
  n += Math.max(0, Math.sin(t * 22 + r * 1.2 + time * 1.0) - 0.2) * 1.6;
  n += Math.max(0, Math.sin(t * 45 + r * 2.0 + time * 0.7) - 0.35) * 1.2;
  n += Math.max(0, Math.sin(t * 11 + r * 0.35 + time * 1.3) - 0.3) * 1.3;
  n += Math.max(0, Math.sin(t * 65 + r * 2.8 + time * 0.5) - 0.5) * 0.7;
  return n;
}

The first five terms produce the smooth rolling base. The last four use Math.max(0, ...) to clip the sine wave, creating the sharp spikes that make it look like a radio signal rather than an ocean. Each row offsets the phase by its row index r, so no two rows look alike.

The key to the Unknown Pleasures look is occlusion. After drawing each row’s curve, the area beneath it is filled with the background color. This means later rows paint over earlier ones, producing the layered depth of the original. Without that fill, you’d just have a mess of overlapping lines.

Amplitude tapers to zero at both edges using Math.pow(Math.sin(t * Math.PI), edgePow), where edgePow increases on wider screens. The rows in the vertical center get the most displacement; rows near the top and bottom flatten out. This gives the whole thing a natural focal point without any explicit framing.

The piano

The song is a cover of Maxence Cyrin’s solo piano rendition of the Pixies’ “Where Is My Mind.” The note data came from an OnlineSequencer.net arrangement. The sequencer stores compositions in a protobuf-encoded format, so I wrote a Python script to decode it and extract all 527 note events as [time, midi, duration, velocity] tuples. Those are hardcoded as a flat array in the source.

Playback runs inside the same requestAnimationFrame loop as the waveform. A variable tracks elapsed time against the song’s start, and on each frame the sequencer advances through any note events whose timestamp has been reached:

while (seqStep < songNotes.length && songNotes[seqStep][0] <= elapsed) {
  var ev = songNotes[seqStep];
  playPiano(ev[1], ev[2], ev[3]);
  // ...
  seqStep++;
}

Because the sequencer and the renderer share the same frame, audio and visuals fire in the same tick. There’s no setTimeout, no separate clock, no drift. When a note plays, its effect on the waveform is visible on that exact frame.

Each note is played through the MusyngKite acoustic grand piano soundfont loaded via soundfont-player. Volume shaping boosts melody notes, cuts bass, and softens the high register so the mix doesn’t get harsh.

Connecting sound to shape

Every playing note maps its MIDI pitch to a waveform row. Low MIDI values go to rows near the top (visually low pitch, high on screen); high values go to rows near the bottom. When a note fires, it injects energy into that row:

rowEnergy[row] = Math.min(rowEnergy[row] + vol, 2.0);

That energy also bleeds into neighboring rows at 30% intensity, so chords don’t just light up isolated lines, they spread across the canvas. Energy decays over time with a 0.92 multiplier per frame after the note ends, so the visual trails behind the audio slightly.

When two notes land on the same row in the same tick, the second one gets nudged to an adjacent row:

if (tickUsed[row] && row > 0 && !tickUsed[row - 1]) row = row - 1;
else if (tickUsed[row] && row < joyRows - 1 && !tickUsed[row + 1]) row = row + 1;

This keeps every note visible even during dense chords. Without it, simultaneous notes would stack invisibly on the same line.

The color of each active row is derived from its MIDI pitch. Low notes get a warm hue (around 30 on the HSL wheel), high notes get a cool one (around 270). The alpha is tied to the row’s energy, so notes fade in color as they decay.

The chord display

While the song plays, the header shows the current chord name. A simple detection algorithm tries every pitch class as a potential root against a table of common chord shapes (major, minor, maj7, m7, dominant 7, dim, aug, sus2, sus4). If the bass note differs from the detected root, it appends a slash chord. It’s not a music theory engine, but it’s accurate enough for this song.

Interaction

Outside of music mode, the canvas is interactive. Moving the cursor highlights the nearest row. Clicking paints it with the current color (or a default highlight if no color is selected). Each row is also mapped to a note in a pentatonic scale, so clicking plays a tone. It’s a simple instrument.

The row count is dynamic. On load and resize, it recalculates based on viewport height: Math.max(12, Math.floor(baseY / 28)). Taller screens get more rows. The pentatonic mapping recalculates along with it, snapping MIDI values to the nearest note in the scale across a three-octave range.

All one loop

The thing I like about this setup is that there’s exactly one requestAnimationFrame callback. It calls seqFrame to advance the sequencer, updateChordDisplay to refresh the header, then draws every row of the waveform with whatever state those systems produced. There are no competing timers, no pub/sub, no event buses. The sequencer writes to rowEnergy and seqHoverRows; the renderer reads from them. Shared mutable state, the thing you’re told never to do, turns out to work fine when everything runs synchronously in the same frame.

The whole thing is about 800 lines of vanilla JavaScript in a single <script is:inline> block inside an Astro component. No build step touches it. It runs the same in dev and production.