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(ω)| ≤ 1at 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 droppedTILT_HZ(3 kHz) — rolloff by absolute frequency, not partial index, so the same material in a higher key doesn't get harsherMATERIAL_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.listReposByCollectionon 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