a playable wind chime, rung by new records across atproto chimes.waow.tech
README.md

chimes #

Play a set of wind chimes in your browser. Tap a tube or use keys 1–5. Listening adds notes sampled from new records across atproto, any app and any collection, capped at 1.4 per second so each can decay. An author always rings the same tube; longer records strike harder. Pause is available in the page and in system media controls. Save your tuning to your PDS to share it in the garden.

The canvas draws rigid tubes suspended from a wooden cap, with a clapper and wind catcher. Pointer hit testing uses the same rotated geometry as rendering; strikes move the tube immediately, without color flashes or weather labels.

The activity breadcrumb charts received messages per second over a five-second window. Expand it for new-record rate, connection source, and the hourly median from relay-eval’s trailing 90-day profile. This is context, not an exact like-for-like benchmark: relay sequences and received JSON messages can differ. The reference line and chart scale are explicit; if the profile is unavailable, no baseline is invented. Pause clears measurements; reconnect starts fresh.

Live at chimes.waow.tech. Client-only (vite + vanilla TS, bun), no backend: the firehose is a websocket, the garden is public XRPC, and your tuning is a record on your own repo.

why this exists #

A previous browser wind chime blew out people's speakers on the bamboo setting. The cause was a Karplus-Strong loop whose damping filter was a BiquadFilter with the default Q = 1. A 2-pole lowpass at Q=1 peaks at 1.1547× at its cutoff, so the "safely under 1" feedback gain of 0.965 was actually 1.114 round-trip — every strike was an oscillator growing ~11% per lap, for a full second, dozens overlapping. Separately, a DelayNode inside a feedback cycle is clamped to at least one 128-sample render quantum, so every note above ~375 Hz was pitched wrong regardless of what was asked for.

So the synth here is built so that neither failure is expressible:

  • the loop filter is a one-zero averager — |H(ω)| ≤ 1 at every frequency by construction, with no Q to get wrong
  • a DC blocker sits inside the loop, so even the one frequency where the averager is unity-gain can't accumulate. Round-trip gain is strictly < 1 everywhere; the loop cannot self-oscillate
  • voices are a fixed pool — the firehose cannot allocate
  • a real limiter, then tanh, sit between everything and the speaker
  • the delay line is fractional, so pitch is pitch

All of it lives in one file, public/chime-processor.js, which is the only place that decides how loud anything is.

the audio tests #

bun run scripts/test-audio.ts

Runs the real worklet outside the browser (stubbed AudioWorkletProcessor) and asserts the things that went wrong before, plus the perceptual regressions found while building it:

check what it caught
bamboo decays / silent by 11s the runaway loop
pitch within 15 cents, 175–660 Hz the 128-sample delay floor
storm stays below full scale 230 strikes/s, 20s — peak must stay < 1
storm does not grow over time slow accumulation
materials within 6 dB metal measured ~9 dB louder than wood (A-weighted)
strikes are repeatable identical gusts varied by 7 dB at random
no meaningful DC offset limiter rectification

The repeatability one was a real bug: each mode's two detuned copies started at random relative phase, and since the beat period is longer than the note, a strike could land in a null and nearly cancel.

scripts/measure-loop-delay.py calibrates DC_LEAD — the in-loop DC blocker contributes phase lead proportional to the period, which made every tube ring sharp. The closed form for it was wrong in both magnitude and sign, so it's measured against a direct simulation of the loop instead.

hearing, as three constants #

In public/chime-processor.js:

  • PARTIAL_CEILING (12 kHz) — partials above this are dropped
  • TILT_HZ (3 kHz) — rolloff by absolute frequency, not partial index, so the same material in a higher key doesn't get harsher
  • MATERIAL_TRIM — A-weighted loudness match across materials

Fundamentals stay in ~260–740 Hz across all keys, which keeps the inharmonic partials (tubular bell series: 1, 2.76, 5.4, 8.93, 13.34) out of the ear's most sensitive band.

the garden #

Your set is one record, tech.waow.chimes.tuning, rkey self, on your own PDS.

  • write — OAuth, scoped to exactly that collection and nothing else
  • read — com.atproto.sync.listReposByCollection on the relay finds everyone who has hung one; Slingshot fetches each record. No index of our own, no auth, works signed out
  • each set is tagged day / night / either, and the garden sorts by whichever half of the day it is where you're standing

Lexicon: lexicons/tech/waow/chimes/tuning.json. Note reverb is an integer 0–100 — atproto lexicons have no float type.

develop #

bun install
bun run dev
bun run check     # tsc + node --check on the worklet

In dev the scene is exposed as window.scene to inspect motion:

scene.wind = 1; // gentle shared sway

deploy #

bun run deploy    # runs the audio tests, builds, publishes to wisp

One-time domain setup:

wispctl domain claim zzstoatzz.io --domain chimes.waow.tech    # prints DNS records
# add those records to the waow.tech zone
wispctl domain add-site zzstoatzz.io --domain chimes.waow.tech --site chimes