Sound follows motion
An interface sound is motion you can't look away from. Give it a budget set by how often it's heard, and land it on the movement it belongs to.
Most interfaces are silent, and most should stay that way. The ones that do make sound tend to get it wrong in the same way: a sound designed on its own, heard once in a review, approved for being pleasant, then played four hundred times a day. Pleasant once is not pleasant four hundred times.
On this site, motion is decided by one question: how often will this be seen? A page view or a hover happens constantly, so it gets little or no motion; something rare can take its time. Sound needs the same question asked harder. You can glance away from motion. You can't glance away from a sound.
Interface sounds in the Lab is a kit of eight built this way, each beside the control it belongs to. Turn the volume up a little and press things. This is the thinking behind it.
A budget by frequency
Decide how often a sound will be heard before deciding what it sounds like, and let that set a ceiling on how long and how loud it can be. Four bands are enough:
/** How long and loud a sound may be, by how often it's heard. */
export const LIMITS = {
// A press.
constantly: { seconds: 0.04, loudness: 0.2 },
// A switch, a choice.
often: { seconds: 0.12, loudness: 0.25 },
// Send, save, delete, a mistake.
sometimes: { seconds: 0.6, loudness: 0.45 },
// A notification.
rarely: { seconds: 1.2, loudness: 0.5 },
};A press is heard all day, so it gets forty milliseconds at most, and in practice twenty: less a sound than the feel of a click. A notification is the rarest thing an interface does, so it can ring for most of a second. Everything else falls between.
Then make the budget a test rather than a guideline. Every sound declares how often it's heard, and a unit test fails if one runs longer or louder than its band allows. The day someone makes the click a little richer, the test says no before anyone has to hear it four hundred times.
it("keeps every sound as short and quiet as its band", () => {
for (const sound of Object.values(SOUNDS)) {
const band = LIMITS[sound.often];
expect(lengthOf(sound)).toBeLessThanOrEqual(band.seconds);
expect(loudnessOf(sound)).toBeLessThanOrEqual(band.loudness);
}
});Recipes, not recordings
The usual route is recorded sounds, and they fight the budget. Each is a file to load, decode and keep in memory, and changing one means going back to an audio editor. A sound synthesised as it plays is a few lines of data. The press in the kit is a few milliseconds of bright noise and a falling blip:
tap: {
often: "constantly",
voices: [
// The part of a press you feel: a burst of high noise.
{
kind: "noise", filter: "highpass", from: 3000, q: 0.7,
at: 0, attack: 0.001, decay: 0.01, gain: 0.1,
},
// A blip falling from 1800 to 1100 Hz.
{
kind: "tone", wave: "sine", from: 1800, to: 1100,
at: 0, attack: 0.001, decay: 0.018, gain: 0.05,
},
],
vibrate: [8],
},Being data is what makes the budget testable: a recipe's length and loudness can be read without playing it. Playing one is Web Audio: an oscillator or a buffer of noise through a filter, an envelope on a gain, and everything through one compressor so two sounds at once never clip. Two browser rules shape the rest. An AudioContext only starts from a user gesture, so make it in the first press, not on load. And an exponential fade can't reach zero, so fade to a ten-thousandth instead.
const SILENT = 0.0001; // exponential ramps can't reach zero
const end = start + voice.attack + voice.decay;
const gain = context.createGain();
gain.gain.setValueAtTime(SILENT, start);
gain.gain.linearRampToValueAtTime(voice.gain, start + voice.attack);
gain.gain.exponentialRampToValueAtTime(SILENT, end);One key, one exception
Sounds heard together should sound like they belong together. Keep every tuned one in a single key. A pentatonic key is forgiving, since none of its notes clash, so a switch landing on top of a save still sounds deliberate. Make pairs mirror each other: on is two notes rising, off is the same two falling, and the ear learns which is which without being taught.
Then break the rule once, on purpose. The error is the only sound outside the key, two low notes falling a semitone: wrong, without being an alarm, which is what a mistyped code deserves. Keep the alarm for something that is one.
Land on the motion
A sound with no movement attached seems to come from somewhere else. Pair each one with the motion of the thing it belongs to, and line up the moments: the click on the frame the button presses in, the error's two notes with the field's shake, the bell's strike as the card pops in, a falling pitch while a deleted row shrinks. They should end together too. The sound is the motion you can hear, not a second event after it.
Vibration follows the same rule. On a phone that can buzz, each sound gets a pattern no longer than itself: eight milliseconds for a press, two short pulses for the error. Offer it only on touch screens. Desktop Chrome reports that it can vibrate, then doesn't.
Never the only signal
Sound is the first thing to go: a muted laptop, a phone on silent, music in another tab, someone who can't hear it. So it can never carry meaning alone. The error has words under the field, saving has its tick, the notification is a card on screen. Reduced motion is the same rule seen from the other side: the shakes and pops go, the sounds stay, and neither was ever the only way to know what happened.
The test is an afternoon with the sound on. Use the interface for a few hours with the volume up. Any sound you'd mute is too long, too loud, or played too often.