Playing and making sounds
intermediate·7 min·Markdown for agents
What this lesson teaches
Wire sound into a web app the right way (unlock on a gesture, off by default, a visual twin for every sound) and make the files with code, a model or a free library.
Here is a tiny chat. Send a message with sound off: you still see it land, with "Sent" under it. Turn sound effects on in the setting above and send again: now a two-note tone joins the checkmark. Reload the page and the setting is still how you left it.
That small demo holds the five rules for sound on the web. Each one exists because someone shipped the opposite.
The five rules
- Off by default, with a switch people can find. In settings, reachable by keyboard, and remembered across visits. This is why sound on the web is trusted at all.
- No sound-only information. Every sound has a visual twin: the checkmark, the toast, the shake. Most people are muted most of the time.
- No autoplay, no sound on page load. A sound the person did not cause is a notification, and notifications need permission.
- Respect the device's silence. On iPhone, the silent switch mutes sounds like yours. Do not play UI sounds in a hidden tab.
- Treat the mute switch as reduced motion for your ears. There is no CSS setting for sound. If someone has asked for reduced motion, keep sound off and skip any "turn on sound?" prompt.
The bug everyone hits first
Create the audio engine when the page loads and it seems to work, except nothing plays. It starts suspended, and every sound is silently dropped until something wakes it. The symptom: "sound only works on the second click."
Before: created at load, on by default, sound on focus.
// starts suspended
const ctx = new AudioContext();
let enabled = true;
// talks over screen readers
button.onfocus = () =>
play("tick");After: created in the gesture, off until chosen.
let ctx: AudioContext | null = null;
const saved =
localStorage.getItem("sound");
let enabled = saved === "on";
// call inside a click or keydown
function unlock() {
ctx ??= new AudioContext();
if (ctx.state === "suspended") {
ctx.resume();
}
}
button.onclick = () => {
unlock();
if (enabled) play("send");
};Two more details. Play each sound from a fresh AudioBufferSourceNode; they are cheap, and it is the only web primitive with no start-up delay (an <audio> element adds tens of milliseconds). And never attach sound to focus: screen readers are already speaking when focus moves, and your tick lands on top of their voice.
Where the sounds come from
Three paths without paying anyone, and one with a key. Pick by what you need, not by what happens to be installed.
| You need | Path |
|---|---|
| Sounds right now, no files, tweakable in code | Synthesize them. A few lines of Web Audio, or ZzFX (under 1 KB) |
| Safe, decent sounds today | A free library with a licence: Kenney's interface packs, soundcn, Freesound |
| A custom material that matches the brand | An open-weight model like Stable Audio 3 Small-SFX, which runs on a laptop CPU |
| Iterate in words ("more felt, less glass") | ElevenLabs sound effects, with an API key |
Synthesis
Every sound in this module is : built in code, zero bytes of audio. For ticks, taps and two-note tones it beats any model. It is the same every time, and its pitch and length are numbers you can tie to the UI.
// A tick: 60 ms of high sine, fast decay.
// No file, no fetch.
function tick(ctx: AudioContext,
freq = 2400, ms = 60) {
const t = ctx.currentTime;
const end = t + ms / 1000;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.frequency.value = freq;
g.gain.setValueAtTime(0.5, t);
g.gain.exponentialRampToValueAtTime(
0.001, end);
o.connect(g).connect(ctx.destination);
o.start(t);
o.stop(end);
}Libraries
Kenney's packs are the most used free UI sounds in existence, which is also their problem: people recognize them. Use them as a starting family and re-pitch or layer. Whatever the source, keep one material. A grab-bag of free sounds is the audio version of the default icon set.
Models
Prompt any sound model with material + action + envelope + contour + purpose, never a UI noun. "A success sound" gets you a stock jingle. This gets you a sound:
Generate this family with ElevenLabs text-to-sound-effects (model eleven_text_to_sound_v2), all in one session with the same material phrase: tick: "single very short tick, fingernail on thin plastic, dry, no tail, bright, one-shot UI feedback" send: "two quick rising notes on a muted kalimba, warm, dry, short decay, confirmation" error: "short low dull thud with a slight downward pitch, rubber on wood, dry, no tail" Use duration_seconds 0.5 and prompt_influence 0.8 or higher. Then trim leading silence to zero, high-pass at 150 Hz, peak at -3 dBFS, mono. Commit the files and record the prompts and settings in a manifest.
Say "dry" and "no tail" every time; the default has room echo. Ask for half a second and cut it down. And commit the files: regenerating one sound next month gives you a different sound, and a family with one re-rolled member sounds patched.
Quick check
Sound plays on the second click but never on the first. What is the likely cause?
In the skill
The nodes your agent reads when it does this work.
- Sound playback on the webUnlock the AudioContext on the first gesture, decode once, one source node per play, a persisted mute that is off by default, and never information carried by sound alone.
sound/sound-playback-web - Sound generation without a keyNo API key needed — open-weight text-to-audio that runs on a CPU, procedural synthesis in under a kilobyte, and CC0 libraries you can ship commercially. A decision table for which path.
sound/sound-generation-open-source - Sound generation with ElevenLabsGenerate a whole sound family on demand with ElevenLabs — material-first prompts, high prompt_influence for functional sounds, short durations, one session per family, then trim and normalize.
sound/sound-generation-elevenlabs
Connected lessons
What this lesson builds on and leads to, across every course.