The site makes noise now
8 min read
Press S, then hover my name. A pencil goes around it. Hover Neon and a chain pulls, a tube stutters on, and a hum sits under the page until you move away.
Or just press these, which is faster:
The thing I keep coming back to is that delight and function aren't separate features you trade against each other. A sound that tells you something is both. A sound that doesn't is just noise you'll turn off, except people can't turn off yours specifically, they close the tab.
So I added sound, then wrote a skill to audit it, ran it against my own site, and deleted a third of what I'd built.
An agent wrote nearly all of the code here, same as the neon sign. The part worth writing down is the split, because it's sharper with audio than with anything else I've built: the agent can measure and can't hear. I can hear and can't measure. Every useful thing below came out of that gap.
The first rule is that most things get nothing
The skill opens with a decision tree, and almost every element exits at the first branch: does this sound carry information the user can't already see? If no, and it isn't the one signature moment of the product, there's no sound. That's it.
I had a tick on every project row hover. Six rows. It failed its own test the moment I wrote the test down: six rows is a list, and a list is exactly where feedback becomes a texture you have to sit through. The press sound stayed, because pressing is a deliberate act and deserves an answer. The hover one is gone.
The other half of that rule: every sound needs a visual equivalent. Most people never hear yours. Muted tabs, headphones out, autoplay policy not satisfied yet. If sound is the only signal a form submitted, it silently didn't submit for almost everyone.Gain numbers lie, so measure
I had three hover sounds sitting at wildly different volumes and no idea, because I'd been tuning them by ear one at a time. The gain values looked reasonable next to each other: 0.05 on one, 0.5 on another.
They were 19dB apart. Roughly four times as loud.
Gain isn't comparable across sources. 0.05 on a synthesized 12ms burst and 0.5 on a normalized 700ms sample are completely different amounts of sound. The only honest way to compare is to render each one offline through its real signal chain and measure the peak:
const peak = (buf) => {
const d = buf.getChannelData(0);
let m = 0;
for (let i = 0; i < d.length; i++) m = Math.max(m, Math.abs(d[i]));
return 20 * Math.log10(m + 1e-12);
};This is the first place the split showed up. I could hear that something was off and had no idea which sound or by how much. The agent could render every sound offline through its real signal chain and hand me numbers in seconds, and would have happily left them 19dB apart forever, because nothing about the code was wrong.
Doing that gave me a ladder to level against. Continuous sounds around −40 dBFS, hover about −38, a deliberate press −27, a notification −20, an alert −12. The gaps are what make escalation legible. My chain pull was at −19.2, which is notification level, fired by moving a mouse.
One step per level, and the gaps are what make escalation readable.
Everything on the site now sits within a few dB of its neighbours, and it took measuring to find that out. Ears are terrible at absolute loudness and great at comparison, which is the exact opposite of what you need when tuning things one at a time.
Synthesis wins some, loses others
I tried to synthesize the pencil stroke. I analyzed a real recording, matched its envelope, matched its spectrum, built it out of filtered noise with grain and micro-transients baked into the buffer.
It lost. Badly, and immediately, in a blind A/B against the original recording.
That A/B is the thing the agent couldn't do. It built the preview page, put both versions behind buttons 400ms apart, and then had to wait for someone with ears. It kept offering to keep tuning the synthesis, and it was right that the numbers were converging. The numbers were just measuring the wrong thing.
Same length, same envelope, same measured peak, which is why both meters move identically. The recording still wins and I can’t point at the number that says so.
A one-shot from a physical object carries instability that survives being described accurately. You can match every measurement and still miss, because what makes a pencil sound like a pencil is the part that isn't in the average.
Then I tried the reverse. I generated a hum for the neon sign, and I'd previously synthesized one. The generated version rang my ears. So did my first synthesized attempt, which had three partials around 11kHz detuned so they beat against each other.
Sustained sounds go the other way, and not for the reason I expected. It isn't realism. You can't take a frequency out of a recording. A generated bed arrives with whatever the model put in it, and if part of that content fatigues, your options are a filter that guts the sound or a different generation with its own problems. With synthesis you simply never make the offending partial.
The hum that shipped is a 97Hz triangle with two harmonics, behind a 900Hz lowpass. Nothing above 291Hz, so there's nothing left to ring. It measures 0.1% of its energy above 2kHz, which is the band the ear is most sensitive to and the reason long sounds get tiring.
So: recordings for one-shot physical events, synthesis for anything that sustains. I had that backwards in the first draft of the skill and had to rewrite it after the site proved me wrong.
Generators can't count in milliseconds
For the sounds I did generate, I started by describing timing. "One sharp crack, then three unstable flickers around 300ms, settling by 700ms."
Zero useful structure. Then I tried plain scene description, which is what every prompting guide recommends: "old fluorescent light turning on in an empty room." That was worse. A featureless wash, no transients at all.
What worked was literal enumeration:
"electric crack, buzz, crack, buzz, crack, buzz, steady electrical hum.
Close mic, dry, no room."Seven events, evenly spread. The comma count is the only timing control you have. "Three failed clicks" produces nothing, because a number in prose isn't a beat. Prose about a sound gets rendered as ambience; the sound's own name gets rendered as an event.
Positional words work too, roughly. "At the very start" moved a peak from spread-across-the-clip to 0.91 in the first 20ms. But placement is probabilistic: I ran the identical prompt twice and got peaks at 0ms and 920ms. Generate three, measure all three, keep one.
Most of the library sounds came from uppbeat.io/sfx, which is where I'd start before generating anything. A real recording of the actual object beats a generation more often than people expect, and it's faster.The bug that made all of it feel broken
For a while the sound "took a moment to start working." Load the page, hover, nothing. Click something, hover again, and now it works.
I assumed loading. It wasn't: the clips fetch in 5ms and resume() returns in under 1ms. It was three logic problems stacked.
An AudioContext starts suspended and only a real user gesture can resume it. My unlock listener was in the bubble phase, so when you clicked a row, the row's own handler ran first, found no context, and gave up. The click that unlocked audio was itself always silent. Moving the listener to the capture phase fixed the ordering.
Then resume() is async, so even after the gesture the context is still technically suspended for a tick, and I was dropping anything that arrived in that window, which is exactly the window the unlocking click lives in. Now I track "has this page seen a gesture" separately from the context state.
And a retrigger guard was being spent before the readiness check, so a blocked attempt also silenced the next 90ms.
None of those are audio problems. They're ordering problems that happen to be inaudible, which is the worst kind, because the symptom is silence and silence has a hundred causes.
This one went the other way round: I could only report "it takes a moment to work," which is useless. The agent instrumented the AudioContext, timed the fetches, patched createBufferSource to count actual start() calls, and found three bugs in about a minute. I'd have blamed the network and moved on.
Press S
The whole layer is muted by default under prefers-reduced-motion, persists your choice, and the toggle sits in the corner with a keyboard shortcut. That query is about motion, but the intent is calm, and someone who asked an interface to stop moving didn't ask it to start talking.
The skill's on skills.sh if you want to point it at your own interface. Most of what it does is tell you to delete things, which is the correct amount of interface sound to add.
It's also written for the half of the job the agent is good at: count the start() calls, render it offline, compare the dBFS, check the guard order. Not "make it feel satisfying." It can't hear it, and neither can a rule that vague.