
Ui Sound Design
- 208 installs
- 34 repo stars
- Updated April 8, 2026
- dannyjpwilliams/ui-sound-design-skill
For development and infrastructure management.
About
ui-sound-design is an AI coding tool that enhances development workflows. Builders use it for infrastructure, integration, and platform development within the catalog ecosystem.
- ui-sound-design
- Development
Ui Sound Design by the numbers
- 208 all-time installs (skills.sh)
- Ranked #1,880 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dannyjpwilliams/ui-sound-design-skill --skill ui-sound-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 208 |
|---|---|
| repo stars | ★ 34 |
| Last updated | April 8, 2026 |
| Repository | dannyjpwilliams/ui-sound-design-skill ↗ |
What it does
For development and infrastructure management.
Files
▁ ▃ ▅ ▇ ▅ ▃ ▁ ▁ ▃ ▅ ▇ ▅ ▃ ▁ ▁ ▃ ▅ ▇ ▅ ▃ ▁
██╗ ██╗██╗ ███████╗ ██████╗ ██╗ ██╗███╗ ██╗██████╗
██║ ██║██║ ██╔════╝██╔═══██╗██║ ██║████╗ ██║██╔══██╗
██║ ██║██║ ███████╗██║ ██║██║ ██║██╔██╗ ██║██║ ██║
██║ ██║██║ ╚════██║██║ ██║██║ ██║██║╚██╗██║██║ ██║
╚██████╔╝██║ ███████║╚██████╔╝╚██████╔╝██║ ╚████║██████╔╝
╚═════╝ ╚═╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝
░ ▒ ▓ █ ▓ ▒ ░ ░ ▒ ▓ █ ▓ ▒ ░ ░ ▒ ▓ █ ▓ ▒ ░
██████╗ ███████╗███████╗██╗ ██████╗ ███╗ ██╗
██╔══██╗██╔════╝██╔════╝██║██╔════╝ ████╗ ██║
██║ ██║█████╗ ███████╗██║██║ ███╗██╔██╗ ██║
██║ ██║██╔══╝ ╚════██║██║██║ ██║██║╚██╗██║
██████╔╝███████╗███████║██║╚██████╔╝██║ ╚████║
╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝
▇ ▅ ▃ ▁ ▃ ▅ ▇ ▇ ▅ ▃ ▁ ▃ ▅ ▇ ▇ ▅ ▃ ▁ ▃ ▅ ▇Describe what your UI should sound like. Preview it, tweak it, and download it from the browser.
UI Sound Design
Translate plain-English sound descriptions into working Web Audio API code. No audio engineering background needed — describe what you want to hear, and this skill provides the synthesis knowledge to make it real.
Workflow
Every sound follows this loop: Describe → Generate → Listen → Refine (with optional Review for auditing existing code)
1. Describe
The user describes the sound in plain language. Ask clarifying questions using this framework:
Four questions before generating any sound:
1. What triggers it? (click, hover, toggle, notification, transition, success, error) 2. What's the emotional tone? (satisfying, subtle, urgent, playful, professional, minimal) 3. How prominent should it be? (barely perceptible, noticeable, attention-grabbing) 4. Any reference points? (iOS keyboard, Slack notification, macOS trash, game UI, "like a bubble popping") 5. Have an audio reference file? If the user has a .wav or .mp3 file they want to match, direct them to run the analyzer:
node skills/ui-sound-design/tools/analyze-sound.mjs path/to/reference.wavThen paste the output back. When a sound profile is provided: 1. Load references/audio-file-references.md for interpretation guidance 2. Read the synthesis_suggestion block for initial parameters 3. Match to the closest sound category using recipe_starting_point 4. Load that recipe from references/sound-recipes.md 5. Override recipe defaults with the profile's suggested parameters 6. Apply any vocabulary bridge terms from the profile's VOCABULARY MATCH section 7. Proceed to Generate as normal
If the user gives a vague request like "make a click sound", use sensible defaults from the recipes and generate immediately — don't over-ask.
2. Generate
1. Match the description to a sound category (see quick reference below) 2. Load the recipe from references/sound-recipes.md 3. Apply the vocabulary bridge to translate adjectives into parameter changes 4. For novel sounds not covered by recipes, compose from building blocks in references/web-audio-api.md 5. Output format: HTML preview by default (adapt assets/sound-preview.html), or ES module / React hook / class if requested
3. Listen
Provide the HTML preview file so the user can open it in a browser and hear the sound immediately. Each sound includes a download button that exports a .wav file for use in production code or handoff to developers. Include labeled buttons for each sound variation. The preview must:
- Handle AudioContext suspension (user gesture to start)
- Use the singleton AudioContext pattern
- Include visual feedback on play (the template handles this)
4. Refine
When the user gives feedback, translate it using the vocabulary bridge and adjust parameters. Common refinement patterns:
- "I like it but..." → tweak 1-2 parameters
- "Completely wrong" → try a different recipe/approach
- "Too much/little" → scale the relevant parameter up/down
- "More like X" → identify what makes X distinctive and match those characteristics
5. Review (optional)
Enter review mode when the user says "review", "audit", or "check my sound code", or pastes existing Web Audio code for evaluation.
Steps: 1. Load rules from references/audio-rules.md 2. Scan the code against each rule, starting with Critical priority 3. Report findings using the format in audio-rules.md — one line per violation with file:line — [rule-id] description 4. Provide a summary table (pass/fail counts by priority) 5. Suggest concrete fixes for each failing rule
When to stay in generate mode: If the user's request is ambiguous (e.g., "here's my click sound" without asking for review), default to the generative workflow. Only enter review mode when the intent to audit is clear.
Vocabulary Bridge
This is the core translation layer. When the user uses subjective language, map it to synthesis parameters:
| User Says | Parameter Change | Example |
|---|---|---|
| "Brighter" | Raise frequency or filter cutoff | Filter cutoff 1500 → 3000 Hz |
| "Warmer" | Lower filter cutoff, use sine/triangle wave | Switch sawtooth → sine, cutoff 3000 → 1200 |
| "Darker" | Lower frequency, reduce high harmonics | Add lowpass filter at 800 Hz |
| "Snappier" | Shorter attack and decay | Decay 0.15 → 0.05s |
| "Softer" | Lower volume, longer attack, gentle envelope | Volume 0.3 → 0.15, attack 0 → 0.01s |
| "Louder" / "More prominent" | Raise volume (max 0.8) | Volume 0.2 → 0.4 |
| "Fuller" / "Richer" | Layer oscillators, add detune | Add second osc detuned +7 cents |
| "Thinner" | Remove layers, use sine wave, raise highpass | Single sine, highpass at 500 Hz |
| "More metallic" | FM synthesis, inharmonic ratios | Mod ratio 1.4, increase mod depth |
| "More organic" / "Natural" | Use noise components, subtle randomness | Mix in filtered noise burst |
| "Shorter" / "Crisper" | Reduce total duration | Duration 0.15 → 0.06s |
| "Longer" / "More sustained" | Increase duration and sustain | Duration 0.1 → 0.3s, add sustain phase |
| "More playful" | Higher pitch, bounce/overshoot | Frequency +200 Hz, add pitch overshoot |
| "More professional" | Subtle, clean, minimal | Lower volume, sine wave, short duration |
| "Retro" / "8-bit" | Square wave, quantized pitch | Switch to square, use note frequencies |
| "Bubbly" | Rapid pitch drop, sine wave | startFreq 2000, quick exponential drop |
Sound Categories — Quick Reference
| Category | Duration | Recipe | Trigger | Key Character |
|---|---|---|---|---|
| Click | 10–80ms | references/sound-recipes.md#click | Button press, tap | Noise burst, bandpass filtered |
| Toggle | 80–200ms | references/sound-recipes.md#toggle | Switch on/off | Rising/falling pitch sweep |
| Hover | 30–80ms | references/sound-recipes.md#hover | Mouse enter | Gentle, nearly subliminal |
| Success | 200–500ms | references/sound-recipes.md#success | Task complete, save | Ascending major third |
| Error | 150–400ms | references/sound-recipes.md#error | Validation fail, rejected | Descending, buzzy |
| Warning | 150–350ms | references/sound-recipes.md#warning | Caution state | Double pulse, mid-range |
| Notification | 200–800ms | references/sound-recipes.md#notification | New message, alert | Bell-like FM synthesis |
| Whoosh | 100–400ms | references/sound-recipes.md#whoosh | Page transition, slide | Filtered noise sweep |
| Pop | 30–80ms | references/sound-recipes.md#pop | Add item, bubble, appear | Sine with pitch drop |
| Custom | varies | references/web-audio-api.md | Anything else | Compose from building blocks |
Critical Implementation Rules
AudioContext user-gesture requirement
Browsers block audio until a user interaction (click, tap, keydown). Always initialize or resume the AudioContext inside an event handler. The singleton pattern in references/web-audio-api.md handles this.
Never ramp gain to zero
exponentialRampToValueAtTime(0, ...) throws an error. Always ramp to 0.001 — it's inaudible but mathematically valid. This applies to every sound. No exceptions.
Node cleanup
- OscillatorNodes auto-disconnect after
stop()— no manual cleanup needed - BufferSourceNodes are one-shot — create a new one each play
- For long-lived filter/gain nodes, call
disconnect()when done - Never create a new AudioContext per sound — use the singleton
Volume safety
- Default volume:
0.3(gain value) - Maximum volume:
0.8— never exceed this - Hover sounds:
0.03–0.08(barely perceptible) - UI sounds should complement, not dominate — err on the side of quiet
Scheduling precision
Capture const now = ctx.currentTime once at the start of each sound function. Derive all scheduling times from now. Never read currentTime multiple times.
Use exponential ramps by default
exponentialRampToValueAtTime sounds natural for both volume and frequency. linearRampToValueAtTime sounds mechanical. Only use linear for sub-50ms transitions.
Output Formats
HTML Preview (default)
Adapt assets/sound-preview.html. Self-contained, no dependencies, opens in any browser. Best for the iterative listen-refine loop. Includes WAV download — click the download button on any sound to get a .wav file. Sound functions must use (ctx, dest) parameters with fallback defaults for download support, and each sound needs a matching entry in the durations map.
ES Module
// ui-sounds.js
export function playClick(options) { /* ... */ }
export function playSuccess(options) { /* ... */ }React Hook
// useUISound.js
export function useUISound() {
const ctxRef = useRef(null);
const getCtx = useCallback(() => { /* singleton */ }, []);
return { playClick, playSuccess, /* ... */ };
}Sound Library Class
Use the UISoundLibrary class from references/sound-recipes.md. Bundles all sounds with enable/disable and master volume control.
Resources
references/
- `web-audio-api.md` — Core Web Audio API building blocks: oscillators, envelopes, filters, noise, FM synthesis, factory patterns, common mistakes. Load when building custom sounds or understanding low-level mechanics.
- `sound-recipes.md` — Complete working implementations for all 9 sound categories plus a bundled
UISoundLibraryclass. Each recipe includes parameters, code, tuning guide, and variations. Start here for most requests. - `audio-rules.md` — Formal validation rules with IDs, priorities, and pass/fail examples. Load when reviewing existing code or when you need to verify generated output against best practices.
- `tone-js.md` — Tone.js abstractions for faster prototyping. Simplified synth types, recipe equivalents, effects, and a conversion guide to vanilla Web Audio. Load when the user prefers Tone.js or wants rapid iteration.
- `audio-file-references.md` — How to interpret sound profiles from the
analyze-sound.mjsCLI tool. Maps audio analysis metrics to synthesis parameters and recipe selection. Load when the user provides a sound profile from an audio file reference.
assets/
- `sound-preview.html` — Self-contained HTML template with all 10 default sounds, visual feedback, and AudioContext handling. Adapt this for every preview output.
tools/
- `analyze-sound.mjs` — CLI script that analyzes .wav/.mp3 files and outputs a sound profile. Run with
node tools/analyze-sound.mjs <file>. Zero dependencies — works with any Node.js installation. Supports .wav natively; other formats require ffmpeg. Outputs duration, envelope, spectral content, tonality, and a synthesis suggestion that maps directly to recipe parameters.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UI Sound Preview</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<style>
:root {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 9%);
--card-foreground: hsl(0 0% 98%);
--muted-foreground: hsl(240 5% 64.9%);
--border: hsl(0 0% 14.9%);
--ring: hsl(0 0% 60%);
--playing: hsl(0 0% 70%);
--playing-glow: hsl(0 0% 100% / 0.07);
--radius: 0.375rem;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--background);
color: var(--foreground);
min-height: 100vh;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
}
.container {
max-width: 760px;
margin: 0 auto;
padding: 3rem 2rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.title {
font-size: 0.8125rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.badge {
font-size: 0.6875rem;
color: var(--muted-foreground);
opacity: 0.6;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4375rem 0.875rem;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
color: var(--foreground);
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
}
.btn-secondary:hover {
background: hsl(0 0% 12%);
border-color: hsl(0 0% 25%);
}
.btn-secondary:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.btn-secondary svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.divider {
height: 1px;
background: var(--border);
margin-bottom: 1.5rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
width: 100%;
}
.card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
color: var(--card-foreground);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-align: left;
width: 100%;
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.card:hover {
background: hsl(0 0% 11%);
border-color: hsl(0 0% 20%);
}
.card:active {
transform: scale(0.98);
transition-duration: 0.05s;
}
.card:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
@keyframes playing-pulse {
0%, 100% { box-shadow: 0 0 0 1px var(--playing), 0 0 20px var(--playing-glow), inset 0 0 12px var(--playing-glow); }
50% { box-shadow: 0 0 0 1px var(--playing), 0 0 30px hsl(0 0% 100% / 0.1), inset 0 0 20px hsl(0 0% 100% / 0.06); }
}
.card.playing {
border-color: var(--playing);
background: hsl(0 0% 11%);
animation: playing-pulse 0.6s ease-in-out;
}
.card-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.label {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.01em;
}
.meta {
font-size: 0.6875rem;
color: var(--muted-foreground);
white-space: nowrap;
}
.dl {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--border);
color: var(--muted-foreground);
background: transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background-color 0.15s;
}
.dl:hover {
color: var(--foreground);
border-color: hsl(0 0% 25%);
background: hsl(0 0% 12%);
}
.dl svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.6875rem;
color: var(--muted-foreground);
opacity: 0.5;
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<div class="header-left">
<h1 class="title">UI Sound Preview</h1>
<span class="badge mono" id="sound-count"></span>
</div>
<button class="btn-secondary" onclick="downloadAll()" title="Download all sounds as WAV files">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download All
</button>
</header>
<div class="divider"></div>
<div class="grid" role="group" aria-label="Sound preview buttons">
<!-- Agent: Add or remove buttons to match generated sounds. Each button needs a matching entry in the durations map.
Button pattern:
<button class="card" onclick="playSound('sound_name', this)">
<div class="card-content"><span class="label">Sound Name</span><span class="meta mono">50ms · sine</span></div>
<span class="dl" onclick="event.stopPropagation(); downloadSound('sound_name')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
</button>
-->
<button class="card" onclick="playSound('click', this)"><div class="card-content"><span class="label">Click</span><span class="meta mono">50ms · noise</span></div><span class="dl" onclick="event.stopPropagation(); downloadSound('click')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span></button>
<button class="card" onclick="playSound('toggle_on', this)"><div class="card-content"><span class="label">Toggle On</span><span class="meta mono">120ms · sine</span></div><span class="dl" onclick="event.stopPropagation(); downloadSound('toggle_on')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span></button>
<button class="card" onclick="playSound('toggle_off', this)"><div class="card-content"><span class="label">Toggle Off</span><span class="meta mono">120ms · sine</span></div><span class="dl" onclick="event.stopPropagation(); downloadSound('toggle_off')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span></button>
<button class="card" onclick="playSound('hover', this)"><div class="card-content"><span class="label">Hover</span><span class="meta mono">60ms · sine</span></div><span class="dl" onclick="event.stopPropagation(); downloadSound('hover')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span></button>
<button class="card" onclick="playSound('success', this)"><div class="card-content"><span class="label">Success</span><span class="meta mono">320ms · sine</span></div><span class="dl" onclick="event.stopPropagation(); downloadSound('success')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span></button>
<button class="card" onclick="playSound('error', this)"><div class="card-content"><span class="label">Error</span><span class="meta mono">250ms · saw</span></div><span class="dl" onclick="event.stopPropagation(); downloadSound('error')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span></button>
<button class="card" onclick="playSound('warning', this)"><div class="card-content"><span class="label">Warning</span><span class="meta mono">240ms · tri</span></div><span class="dl" onclick="event.stopPropagation(); downloadSound('warning')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span></button>
<button class="card" onclick="playSound('notification', this)"><div class="card-content"><span class="label">Notification</span><span class="meta mono">400ms · FM</span></div><span class="dl" onclick="event.stopPropagation(); downloadSound('notification')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span></button>
<button class="card" onclick="playSound('whoosh', this)"><div class="card-content"><span class="label">Whoosh</span><span class="meta mono">200ms · noise</span></div><span class="dl" onclick="event.stopPropagation(); downloadSound('whoosh')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span></button>
<button class="card" onclick="playSound('pop', this)"><div class="card-content"><span class="label">Pop</span><span class="meta mono">60ms · sine</span></div><span class="dl" onclick="event.stopPropagation(); downloadSound('pop')" title="Download WAV" aria-label="Download WAV"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span></button>
</div>
<footer class="footer">
<span class="mono">Web Audio API</span>
<span>·</span>
<span class="mono">44.1kHz WAV</span>
</footer>
</div>
<script>
// --- AudioContext singleton ---
let audioCtx = null;
function getAudioContext() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
// --- Visual feedback ---
function flash(btn, name) {
btn.classList.add('playing');
const dur = (durations[name] || 0.3) * 1000;
setTimeout(() => btn.classList.remove('playing'), Math.max(dur, 200));
}
// --- Duration map (seconds) ---
// Agent: Update durations map when adding/removing sounds
const durations = {
click: 0.05, toggle_on: 0.13, toggle_off: 0.13, hover: 0.07,
success: 0.33, error: 0.26, warning: 0.25, notification: 0.41,
whoosh: 0.2, pop: 0.07
};
// --- Sound registry ---
// Agent: Replace or extend these functions with generated sounds. Use (ctx, dest) parameters for download support.
const sounds = {
click(ctx, dest) {
ctx = ctx || getAudioContext(); dest = dest || ctx.destination;
const now = ctx.currentTime;
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.05, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource(); src.buffer = buf;
const f = ctx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 2000; f.Q.value = 2;
const g = ctx.createGain(); g.gain.setValueAtTime(0.3, now); g.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
src.connect(f); f.connect(g); g.connect(dest); src.start(now);
},
toggle_on(ctx, dest) {
ctx = ctx || getAudioContext(); dest = dest || ctx.destination;
const now = ctx.currentTime;
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(500, now); o.frequency.exponentialRampToValueAtTime(700, now + 0.12);
g.gain.setValueAtTime(0.25, now); g.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
o.connect(g); g.connect(dest); o.start(now); o.stop(now + 0.13);
},
toggle_off(ctx, dest) {
ctx = ctx || getAudioContext(); dest = dest || ctx.destination;
const now = ctx.currentTime;
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(700, now); o.frequency.exponentialRampToValueAtTime(500, now + 0.12);
g.gain.setValueAtTime(0.25, now); g.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
o.connect(g); g.connect(dest); o.start(now); o.stop(now + 0.13);
},
hover(ctx, dest) {
ctx = ctx || getAudioContext(); dest = dest || ctx.destination;
const now = ctx.currentTime;
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine'; o.frequency.value = 2400;
g.gain.setValueAtTime(0.001, now); g.gain.exponentialRampToValueAtTime(0.08, now + 0.01); g.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
o.connect(g); g.connect(dest); o.start(now); o.stop(now + 0.07);
},
success(ctx, dest) {
ctx = ctx || getAudioContext(); dest = dest || ctx.destination;
const now = ctx.currentTime;
[0, 1].forEach(i => {
const o = ctx.createOscillator(), g = ctx.createGain(), t = now + i * 0.2;
o.type = 'sine'; o.frequency.value = 523 * Math.pow(1.25, i);
g.gain.setValueAtTime(0.3, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.12);
o.connect(g); g.connect(dest); o.start(t); o.stop(t + 0.13);
});
},
error(ctx, dest) {
ctx = ctx || getAudioContext(); dest = dest || ctx.destination;
const now = ctx.currentTime;
const o = ctx.createOscillator(), f = ctx.createBiquadFilter(), g = ctx.createGain();
o.type = 'sawtooth'; o.frequency.setValueAtTime(400, now); o.frequency.exponentialRampToValueAtTime(200, now + 0.25);
f.type = 'lowpass'; f.frequency.value = 1500;
g.gain.setValueAtTime(0.25, now); g.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
o.connect(f); f.connect(g); g.connect(dest); o.start(now); o.stop(now + 0.26);
},
warning(ctx, dest) {
ctx = ctx || getAudioContext(); dest = dest || ctx.destination;
const now = ctx.currentTime;
[0, 1].forEach(i => {
const o = ctx.createOscillator(), g = ctx.createGain(), t = now + i * 0.16;
o.type = 'triangle'; o.frequency.value = 600;
g.gain.setValueAtTime(0.25, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.08);
o.connect(g); g.connect(dest); o.start(t); o.stop(t + 0.09);
});
},
notification(ctx, dest) {
ctx = ctx || getAudioContext(); dest = dest || ctx.destination;
const now = ctx.currentTime;
const mod = ctx.createOscillator(), mg = ctx.createGain();
mod.frequency.value = 880 * 1.4; mg.gain.setValueAtTime(1500, now); mg.gain.exponentialRampToValueAtTime(0.001, now + 0.32);
const car = ctx.createOscillator(), cg = ctx.createGain();
car.frequency.value = 880; cg.gain.setValueAtTime(0.25, now); cg.gain.exponentialRampToValueAtTime(0.001, now + 0.4);
mod.connect(mg); mg.connect(car.frequency); car.connect(cg); cg.connect(dest);
mod.start(now); car.start(now); mod.stop(now + 0.41); car.stop(now + 0.41);
},
whoosh(ctx, dest) {
ctx = ctx || getAudioContext(); dest = dest || ctx.destination;
const now = ctx.currentTime;
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.2, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource(); src.buffer = buf;
const f = ctx.createBiquadFilter(); f.type = 'bandpass'; f.Q.value = 1;
f.frequency.setValueAtTime(500, now); f.frequency.exponentialRampToValueAtTime(4000, now + 0.2);
const g = ctx.createGain();
g.gain.setValueAtTime(0.001, now); g.gain.exponentialRampToValueAtTime(0.2, now + 0.06); g.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
src.connect(f); f.connect(g); g.connect(dest); src.start(now);
},
pop(ctx, dest) {
ctx = ctx || getAudioContext(); dest = dest || ctx.destination;
const now = ctx.currentTime;
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(1200, now); o.frequency.exponentialRampToValueAtTime(300, now + 0.06);
g.gain.setValueAtTime(0.3, now); g.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
o.connect(g); g.connect(dest); o.start(now); o.stop(now + 0.07);
}
};
function playSound(name, btn) {
if (sounds[name]) { sounds[name](); flash(btn, name); }
}
// --- WAV encoder ---
function writeString(view, offset, str) {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
}
function encodeWAV(audioBuffer) {
const sampleRate = audioBuffer.sampleRate;
const samples = audioBuffer.getChannelData(0);
const dataLength = samples.length * 2;
const buffer = new ArrayBuffer(44 + dataLength);
const view = new DataView(buffer);
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + dataLength, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeString(view, 36, 'data');
view.setUint32(40, dataLength, true);
let offset = 44;
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
offset += 2;
}
return new Blob([buffer], { type: 'audio/wav' });
}
// --- Download ---
async function downloadSound(name) {
if (!sounds[name]) return;
const duration = (durations[name] || 0.5) + 0.05;
const sampleRate = 44100;
const offlineCtx = new OfflineAudioContext(1, Math.ceil(sampleRate * duration), sampleRate);
sounds[name](offlineCtx, offlineCtx.destination);
const rendered = await offlineCtx.startRendering();
const blob = encodeWAV(rendered);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name + '.wav';
a.click();
URL.revokeObjectURL(url);
}
// --- Download All ---
async function downloadAll() {
const names = Object.keys(sounds);
for (const name of names) {
await downloadSound(name);
await new Promise(r => setTimeout(r, 100));
}
}
// --- Auto-update sound count badge ---
document.getElementById('sound-count').textContent = Object.keys(sounds).length + ' sounds';
</script>
</body>
</html>
Audio File References
When a user provides a sound profile from the analyze-sound.mjs CLI tool, use this guide to interpret the analysis and drive synthesis.
What Is a Sound Profile?
The user ran node tools/analyze-sound.mjs reference.wav on an audio file they want to match. The output contains:
1. SUMMARY — plain-English description of the sound 2. CLOSEST CATEGORY — which of the 9 sound categories best matches 3. VOCABULARY MATCH — adjectives mapped to analysis metrics (same terms as the vocabulary bridge) 4. DETAILED PROFILE — structured analysis data 5. synthesis_suggestion — concrete recipe parameters ready to apply
How to Use a Sound Profile
1. Read the synthesis_suggestion block first — it maps directly to recipe parameters 2. Load the recipe matching recipe_starting_point from sound-recipes.md 3. Override the recipe's defaults with the suggestion's values 4. Use the VOCABULARY MATCH terms to guide any further adjustments 5. Generate code and proceed to Listen → Refine as normal
The goal is to synthesize something that feels similar to the reference, not to reproduce it exactly. Web Audio synthesis has different characteristics than recorded audio — the profile gives you the right ballpark, and the user refines from there.
Field Reference
envelope
| Field | What It Means | Synthesis Mapping |
|---|---|---|
attack_ms | Time from silence to peak amplitude | setValueAtTime → linearRampToValueAtTime over this duration |
decay_ms | Time from peak to sustain level (or silence) | exponentialRampToValueAtTime over this duration |
sustain_level | Steady-state amplitude relative to peak (0–1) | Gain level during sustain phase |
release_ms | Time from sustain end to silence | Final exponentialRampToValueAtTime(0.001, ...) |
shape | percussive / sustained / pad-like / decaying | Determines overall envelope strategy |
decay_curve | exponential or linear | Use exponentialRamp or linearRamp accordingly |
peak_amplitude | Loudest point (0–1 scale) | Map to gain value (cap at 0.8) |
pitch
| Field | What It Means | Synthesis Mapping |
|---|---|---|
fundamental_hz | Detected pitch | Oscillator frequency |
confidence | How reliable the pitch detection is (0–1) | Below 0.4: likely noise-based, don't trust the frequency |
pitch_change | stable / rising / falling | Whether to use a frequency ramp |
spectrum
| Field | What It Means | Synthesis Mapping |
|---|---|---|
centroid_hz | Center of spectral mass (brightness) | See brightness mapping below |
rolloff_85_hz | Where 85% of energy sits below | Guides lowpass filter cutoff |
brightness | high / medium / low | Vocabulary bridge: bright/neutral/warm |
dominant_frequencies | Strongest frequency peaks | Primary oscillator + overtone frequencies |
harmonic_pattern | harmonic / partially-inharmonic / inharmonic | See harmonic mapping below |
tonality
| Field | What It Means | Synthesis Mapping |
|---|---|---|
tonal_percent | How much energy is in pitched content | > 70%: oscillator-based. < 30%: noise-based |
noise_percent | How much energy is broadband noise | High values → use BufferSource with noise |
classification | tonal / mixed / noise-based | Primary synthesis approach |
filter_estimate
| Field | What It Means | Synthesis Mapping |
|---|---|---|
type | lowpass / highpass / bandpass / none detected | BiquadFilterNode type |
cutoff_hz | Estimated filter cutoff frequency | BiquadFilterNode frequency |
q_estimate | Estimated resonance/Q | BiquadFilterNode Q parameter |
Note: For pure tones, the filter estimate may show bandpass at the fundamental frequency. This is an artifact of concentrated spectral energy — ignore it and use the oscillator frequency directly.
spectral_evolution
| Field | What It Means | Synthesis Mapping |
|---|---|---|
trend | stable / brightening / darkening | Whether to sweep filter cutoff over time |
pitchTrend | stable / rising / falling | Whether to sweep oscillator frequency |
synthesis_suggestion
This block contains ready-to-use parameters in the same format as sound-recipes.md:
| Field | Maps To |
|---|---|
approach | Which synthesis technique to use |
waveform | OscillatorNode type or noise for BufferSource |
recipe_starting_point | Which recipe to load as a base |
base_frequency | Oscillator frequency parameter |
duration | Total sound duration in seconds |
volume | Gain node value (already capped at 0.8) |
envelope | Attack/decay/sustain/release in seconds |
mod_ratio | FM synthesis modulator ratio (if applicable) |
mod_depth | FM synthesis modulation depth (if applicable) |
filter | Filter type, cutoff, and Q (if applicable) |
start_frequency / end_frequency | Frequency sweep range (if applicable) |
Analysis-to-Category Mapping
Use this table when the synthesis_suggestion doesn't feel right or when manually interpreting a profile:
| Analysis Finding | Best Category | Recipe |
|---|---|---|
| < 80ms, percussive, noise-based | Click | Noise burst through bandpass |
| < 80ms, tonal, falling pitch | Pop | Sine with rapid pitch drop |
| 80–200ms, pitch sweep | Toggle | Oscillator with frequency ramp |
| 200–800ms, inharmonic overtones | Notification | FM synthesis |
| Noise-based, spectral sweep | Whoosh | Filtered noise with frequency sweep |
| < 80ms, very quiet, tonal | Hover | Gentle sine |
| Rising pitch, tonal, 200–500ms | Success | Ascending interval |
| Falling pitch, harmonics, 150–400ms | Error | Descending sweep through lowpass |
| Double/triple pulse pattern, mid-range | Warning | Multi-pulse oscillator |
Brightness Mapping (Spectral Centroid → Vocabulary Bridge)
| Centroid Range | Vocabulary Term | Typical Synthesis |
|---|---|---|
| > 4000 Hz | Very bright | High filter cutoff, sawtooth/square, or high-frequency content |
| 2000–4000 Hz | Bright | Moderate filter cutoff, some harmonics |
| 1000–2000 Hz | Neutral | Default filter settings |
| 500–1000 Hz | Warm | Low filter cutoff, sine/triangle |
| < 500 Hz | Dark | Heavy lowpass filtering, sine wave |
Envelope → Vocabulary Bridge
| Envelope Characteristic | Vocabulary Term | Typical Parameter |
|---|---|---|
| Attack < 5ms | Snappy, Crisp | Near-zero attack time |
| Attack 5–20ms | Quick | Short attack |
| Attack > 50ms | Soft, Gentle | Gradual fade-in |
| Decay < 50ms | Percussive | Very fast decay |
| Decay 50–200ms | Punchy | Medium decay |
| Decay > 200ms | Sustained, Resonant | Long decay or sustain phase |
| Sustain > 0.3 | Full, Held | Add sustain phase to envelope |
| Sustain ≈ 0 | Transient, Plucky | No sustain, just attack → decay |
Harmonic Content → Synthesis Approach
| Pattern | What It Means | Synthesis |
|---|---|---|
| Harmonic (integer ratios) | Musical, pitched | Single oscillator (sine/saw/square) + filter |
| Partially inharmonic | Bell-like, metallic | FM synthesis with non-integer mod ratio |
| Inharmonic | Metallic, percussive | FM with high inharmonicity, or noise + filter |
| Minimal harmonics | Pure, thin | Single sine oscillator |
| Many harmonics | Rich, full | Sawtooth or layered oscillators with detune |
Limitations
- Short files (< 20ms): Spectral analysis has limited frequency resolution. Envelope detection may be inaccurate. Trust the tonality classification more than specific frequencies.
- Stereo files: The analyzer mixes to mono before analysis. Stereo-specific characteristics (panning, width) are lost.
- Compressed audio (MP3): Compression artifacts can affect spectral analysis, especially at low bitrates. WAV files give the most accurate profiles.
- Complex/layered sounds: The analyzer works best on single UI sounds. Multi-layered sounds may produce confusing harmonic analysis — focus on the overall spectral character (centroid, brightness) rather than individual peaks.
- The profile is a starting point. The Listen → Refine loop is where the sound gets dialed in. Don't try to match every parameter exactly — match the character and feel.
Audio Rules — Formal Validation Rulebook
Rules for validating and reviewing Web Audio API UI sound code. Each rule has an ID, priority, description, and pass/fail examples. Use during code review or as a checklist when generating sounds.
Priority Levels
| Priority | Meaning |
|---|---|
| Critical | Violations cause runtime errors, audio glitches, or resource leaks. Must fix. |
| High | Violations produce audibly wrong or wasteful output. Should fix. |
| Medium | Violations break best practices or produce suboptimal results. Fix when possible. |
---
Critical — Context Management
context-singleton
Use a single AudioContext for the entire application.
Browsers limit the number of AudioContexts. Creating one per sound leaks resources and eventually fails silently.
// ✅ PASS
let audioCtx = null;
function getAudioContext() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
// ❌ FAIL
function playSound() {
const ctx = new AudioContext(); // new context every call
// ...
}context-resume-suspended
Always check for and resume a suspended AudioContext.
After page load (before user gesture), the context starts suspended. Calling resume() is a no-op if already running, so always call it.
// ✅ PASS
const ctx = getAudioContext();
if (ctx.state === 'suspended') ctx.resume();
// ❌ FAIL
const ctx = getAudioContext();
// assumes context is running — silent on first interactionnode-cleanup
Disconnect nodes after playback ends.
OscillatorNodes auto-disconnect after stop(). BufferSourceNodes do not — use the onended callback to disconnect the entire signal chain.
// ✅ PASS — BufferSource with cleanup
const source = ctx.createBufferSource();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
source.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
source.start(now);
source.onended = () => {
source.disconnect();
filter.disconnect();
gain.disconnect();
};
// ✅ PASS — Oscillator (auto-disconnects after stop)
osc.start(now);
osc.stop(now + duration + 0.01);
// ❌ FAIL — BufferSource without cleanup
source.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
source.start(now);
// filter and gain nodes leak---
Critical — Envelope Safety
gain-no-zero-target
Never use `exponentialRampToValueAtTime(0, ...)`.
Exponential ramps cannot target zero — it throws a runtime error. Always ramp to 0.001 (inaudible).
// ✅ PASS
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
// ❌ FAIL
gain.gain.exponentialRampToValueAtTime(0, now + duration);gain-set-before-ramp
Always call `setValueAtTime()` before any ramp.
A ramp without a preceding setValueAtTime() has no defined start point. The browser may ramp from an unexpected value or ignore the ramp entirely.
// ✅ PASS
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
// ❌ FAIL
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
// no setValueAtTime — ramp start is undefined---
High — Envelope & Scheduling
exponential-over-linear
Use `exponentialRampToValueAtTime` by default.
Exponential ramps sound natural for both volume and frequency. Linear ramps sound mechanical. Only use linearRampToValueAtTime for sub-50ms transitions where the difference is inaudible.
// ✅ PASS — natural decay
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
// ⚠️ ACCEPTABLE — only for very short transitions
gain.gain.setValueAtTime(0.3, now);
gain.gain.linearRampToValueAtTime(0.001, now + 0.03);
// ❌ FAIL — linear for perceptible durations
gain.gain.setValueAtTime(0.3, now);
gain.gain.linearRampToValueAtTime(0.001, now + 0.2);scheduling-capture-once
Capture `ctx.currentTime` once per sound function.
Reading currentTime multiple times introduces drift between scheduled events because time advances between reads.
// ✅ PASS
const now = ctx.currentTime;
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.start(now);
osc.stop(now + duration + 0.01);
// ❌ FAIL
gain.gain.setValueAtTime(volume, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.start(ctx.currentTime); // different time than abovestop-after-envelope
Schedule `osc.stop()` slightly after the gain envelope ends.
Add at least 0.01s padding after the final gain ramp target. Stopping the oscillator before the envelope completes causes an audible click.
// ✅ PASS — 0.01s padding
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.stop(now + duration + 0.01);
// ❌ FAIL — stop at same time as envelope end
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.stop(now + duration);---
High — Sound Design
noise-for-percussion
Use white noise (BufferSource) for percussive, atonal sounds.
Clicks, whooshes, and snare-like sounds should use filtered noise, not oscillators. Oscillators produce pitched tones that sound musical rather than tactile.
// ✅ PASS — click using filtered noise
const buffer = ctx.createBuffer(1, ctx.sampleRate * 0.05, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
const source = ctx.createBufferSource();
source.buffer = buffer;
// ... bandpass filter → gain → destination
// ❌ FAIL — click using oscillator
const osc = ctx.createOscillator();
osc.frequency.value = 2000;
// sounds like a tone, not a clickoscillator-for-tonal
Use oscillators for pitched, melodic sounds.
Success chimes, toggles, notifications, and any sound that conveys pitch information should use oscillators (possibly with FM synthesis), not noise.
filter-for-character
Apply filters to shape the frequency character of a sound.
Raw oscillators and raw noise both benefit from filtering. A bandpass on noise creates focused clicks. A lowpass on sawtooth tames harshness. Always consider whether a filter would improve the sound.
---
Medium — Parameters
volume-max-0.8
Never set gain above 0.8.
UI sounds should complement the interface, not dominate it. Peak gain of 0.8 leaves headroom for the user's other audio. Default to 0.3.
// ✅ PASS
gain.gain.setValueAtTime(0.3, now); // default
gain.gain.setValueAtTime(0.6, now); // prominent
gain.gain.setValueAtTime(0.8, now); // maximum
// ❌ FAIL
gain.gain.setValueAtTime(1.0, now); // too loud
gain.gain.setValueAtTime(0.95, now); // exceeds ceilingfilter-Q-per-type
Use appropriate Q values for each sound category.
| Category | Q Range | Rationale |
|---|---|---|
| Click | 0.5–10 | Low Q = soft, high Q = focused/tonal |
| Whoosh | 0.5–5 | Low Q for broad sweep, moderate for tonal character |
| Error | 0.5–3 | Gentle filtering to tame sawtooth |
| Notification | — | FM synthesis handles character; filter rarely needed |
| Others | 0.5–5 | Safe general range |
Q above 15 causes audible ringing and self-oscillation — never appropriate for UI sounds.
duration-per-type
Keep durations within bounds for each sound category.
Sounds that overstay their welcome annoy users. Sounds that are too short lose their character.
---
Per-Sound-Type Parameter Bounds
Reference table for all 9 categories. Values outside these ranges are almost always wrong for UI contexts.
| Category | Duration | Volume | Filter Q | Attack | Key Constraint |
|---|---|---|---|---|---|
| Click | 10–80ms | 0.1–0.6 | 0.5–10 | 0ms (instant) | Noise source, not oscillator |
| Toggle | 80–200ms | 0.1–0.4 | — | 0ms | Frequency sweep direction = state |
| Hover | 30–80ms | 0.03–0.08 | — | 5–15ms | Must be subliminal |
| Success | 200–500ms | 0.15–0.5 | — | 0ms | Ascending pitch interval |
| Error | 150–400ms | 0.15–0.4 | 0.5–3 | 0ms | Descending pitch, dark timbre |
| Warning | 150–350ms | 0.15–0.4 | — | 0ms | Double pulse pattern |
| Notification | 200–800ms | 0.15–0.4 | — | 0ms | FM synthesis for bell character |
| Whoosh | 100–400ms | 0.1–0.4 | 0.5–5 | — | Noise + filter frequency sweep |
| Pop | 30–80ms | 0.15–0.5 | — | 0ms | Rapid pitch drop, sine wave |
---
Review Mode — Output Format
When auditing existing Web Audio code, report findings in this format:
Per-Finding
file:line — [rule-id] Description of the violationExample:
sounds.js:42 — [gain-no-zero-target] exponentialRampToValueAtTime targets 0; use 0.001
sounds.js:15 — [context-singleton] new AudioContext() called inside playClick(); use singleton
sounds.js:78 — [volume-max-0.8] gain set to 0.95; maximum is 0.8Summary Table
After all findings, provide a summary:
| Priority | Pass | Fail | Rules Checked |
|----------|------|------|---------------|
| Critical | 3 | 2 | context-singleton, context-resume-suspended, node-cleanup, gain-no-zero-target, gain-set-before-ramp |
| High | 4 | 1 | exponential-over-linear, scheduling-capture-once, stop-after-envelope, noise-for-percussion, oscillator-for-tonal, filter-for-character |
| Medium | 2 | 1 | volume-max-0.8, filter-Q-per-type, duration-per-type |Suggested Fixes
For each failing rule, provide the corrected code inline. Group fixes by file when reviewing multiple files.
UI Sound Recipes
Complete, working implementations for common UI sounds. Each recipe is self-contained and uses the shared getAudioContext() singleton from web-audio-api.md.
All recipes assume this shared context setup:
let audioCtx = null;
function getAudioContext() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}---
Click {#click}
Short noise burst through a bandpass filter. The foundation of tactile UI feedback.
| Parameter | Default | Range | Effect |
|---|---|---|---|
frequency | 2000 | 800–6000 | Higher = sharper, lower = softer |
Q | 2 | 0.5–10 | Higher = more tonal, lower = noisier |
duration | 0.05 | 0.02–0.1 | Shorter = crisper |
volume | 0.3 | 0.1–0.6 | Keep subtle for frequent interactions |
function playClick({ frequency = 2000, Q = 2, duration = 0.05, volume = 0.3 } = {}) {
const ctx = getAudioContext();
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * duration;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const source = ctx.createBufferSource();
source.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = frequency;
filter.Q.value = Q;
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
source.start(now);
}Tuning guide:
- "Softer" → lower frequency (1000), lower volume (0.15), longer duration (0.08)
- "Sharper/crisper" → higher frequency (4000), higher Q (5), shorter duration (0.03)
- "Mechanical/keyboard" → frequency 3000, Q 8, duration 0.03
Variations:
- Soft click:
{ frequency: 1200, Q: 1, duration: 0.06, volume: 0.15 } - Hard click:
{ frequency: 4000, Q: 6, duration: 0.03, volume: 0.4 } - Keyboard click:
{ frequency: 3500, Q: 8, duration: 0.025, volume: 0.25 }
Rules followed: context-singleton, gain-no-zero-target, gain-set-before-ramp, scheduling-capture-once, noise-for-percussion, volume-max-0.8
---
Toggle {#toggle}
Rising sine sweep for "on", falling for "off". Conveys binary state change through pitch direction.
| Parameter | Default | Range | Effect |
|---|---|---|---|
startFreq | 500/700 | 300–1200 | Starting pitch |
endFreq | 700/500 | 300–1200 | Ending pitch |
duration | 0.12 | 0.08–0.2 | Shorter = snappier |
volume | 0.25 | 0.1–0.4 | Keep consistent on/off |
function playToggle(isOn, { duration = 0.12, volume = 0.25 } = {}) {
const ctx = getAudioContext();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(isOn ? 500 : 700, now);
osc.frequency.exponentialRampToValueAtTime(isOn ? 700 : 500, now + duration);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.01);
}Tuning guide:
- "More obvious" → wider frequency range (300→900), longer duration (0.18)
- "Subtler" → narrower range (550→650), shorter duration (0.08)
- "More playful" → use triangle wave, add slight overshoot in frequency
Variations:
- Minimal toggle: Range 550–650, duration 0.08, volume 0.15
- Retro toggle: Square wave, range 400–800, duration 0.1
- Smooth toggle: Triangle wave, range 450–750, duration 0.15
Rules followed: context-singleton, gain-no-zero-target, gain-set-before-ramp, scheduling-capture-once, stop-after-envelope, exponential-over-linear, oscillator-for-tonal, volume-max-0.8
---
Hover {#hover}
Gentle high-frequency sine, very fast fade. Should be almost subliminal.
| Parameter | Default | Range | Effect |
|---|---|---|---|
frequency | 2400 | 1800–4000 | Higher = airier |
duration | 0.06 | 0.03–0.1 | Must be very short |
volume | 0.08 | 0.03–0.15 | Keep very quiet |
function playHover({ frequency = 2400, duration = 0.06, volume = 0.08 } = {}) {
const ctx = getAudioContext();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = frequency;
gain.gain.setValueAtTime(0.001, now);
gain.gain.exponentialRampToValueAtTime(volume, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.01);
}Tuning guide:
- "Warmer" → lower frequency (1800), use triangle wave
- "Brighter" → higher frequency (3500)
- "More presence" → increase volume to 0.12, add 2ms of attack
Variations:
- Glass hover:
{ frequency: 3200, duration: 0.08, volume: 0.06 } - Warm hover: Triangle wave,
{ frequency: 1800, duration: 0.07, volume: 0.1 }
Rules followed: context-singleton, gain-no-zero-target, gain-set-before-ramp, scheduling-capture-once, stop-after-envelope, exponential-over-linear, oscillator-for-tonal, volume-max-0.8
---
Success {#success}
Ascending two-tone major third. The interval creates a universally "positive" feeling.
| Parameter | Default | Range | Effect |
|---|---|---|---|
baseFreq | 523 | 400–800 | Base pitch (C5 default) |
interval | 1.25 | 1.125–1.5 | Frequency ratio (1.25 = major third) |
noteDuration | 0.12 | 0.08–0.2 | Length of each note |
gap | 0.08 | 0.04–0.15 | Silence between notes |
volume | 0.3 | 0.15–0.5 |
function playSuccess({ baseFreq = 523, interval = 1.25, noteDuration = 0.12, gap = 0.08, volume = 0.3 } = {}) {
const ctx = getAudioContext();
const now = ctx.currentTime;
[0, 1].forEach(i => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const startTime = now + i * (noteDuration + gap);
const freq = baseFreq * Math.pow(interval, i);
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(volume, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + noteDuration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(startTime);
osc.stop(startTime + noteDuration + 0.01);
});
}Tuning guide:
- "More triumphant" → wider interval (1.5 = perfect fifth), add third note at 2x baseFreq
- "Gentler" → triangle wave, lower volume (0.15), longer notes (0.18)
- "Brighter" → higher baseFreq (700), shorter gap (0.05)
Variations:
- Triumphant: Three notes ascending a major triad: baseFreq, ×1.25, ×1.5
- Gentle ding: Single triangle note at 880Hz, duration 0.3, volume 0.2
- Sparkle: Sine at 1047Hz, quick decay, add second oscillator detuned +7 cents
Rules followed: context-singleton, gain-no-zero-target, gain-set-before-ramp, scheduling-capture-once, stop-after-envelope, exponential-over-linear, oscillator-for-tonal, volume-max-0.8
---
Error {#error}
Low buzzy descending tone. Sawtooth through lowpass creates urgency without being alarming.
| Parameter | Default | Range | Effect |
|---|---|---|---|
startFreq | 400 | 200–600 | Starting pitch |
endFreq | 200 | 100–350 | Ending pitch (lower = more serious) |
filterFreq | 1500 | 800–3000 | Lower = darker/muffled |
duration | 0.25 | 0.15–0.4 | Longer = more noticeable |
volume | 0.25 | 0.15–0.4 |
function playError({ startFreq = 400, endFreq = 200, filterFreq = 1500, duration = 0.25, volume = 0.25 } = {}) {
const ctx = getAudioContext();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(startFreq, now);
osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration);
filter.type = 'lowpass';
filter.frequency.value = filterFreq;
filter.Q.value = 1;
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.01);
}Tuning guide:
- "Less alarming" → sine wave instead of sawtooth, lower volume (0.15)
- "More urgent" → higher startFreq (500), wider sweep, shorter duration (0.15)
- "Buzzy/harsh" → keep sawtooth, raise filterFreq to 3000, increase volume
Variations:
- Gentle error: Sine wave, 350→250Hz, duration 0.2, volume 0.15
- Critical error: Sawtooth, 500→150Hz, filterFreq 2500, two pulses with 0.1s gap
- Validation error: Triangle, 300→220Hz, duration 0.15, very quiet (0.12)
Rules followed: context-singleton, gain-no-zero-target, gain-set-before-ramp, scheduling-capture-once, stop-after-envelope, exponential-over-linear, oscillator-for-tonal, filter-for-character, volume-max-0.8
---
Warning {#warning}
Mid-range double pulse. The repetition signals "pay attention" without the negativity of an error.
| Parameter | Default | Range | Effect |
|---|---|---|---|
frequency | 600 | 400–900 | Mid-range feels advisory |
pulseDuration | 0.08 | 0.05–0.12 | Each pulse length |
gap | 0.08 | 0.05–0.12 | Between pulses |
volume | 0.25 | 0.15–0.4 |
function playWarning({ frequency = 600, pulseDuration = 0.08, gap = 0.08, volume = 0.25 } = {}) {
const ctx = getAudioContext();
const now = ctx.currentTime;
[0, 1].forEach(i => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const startTime = now + i * (pulseDuration + gap);
osc.type = 'triangle';
osc.frequency.value = frequency;
gain.gain.setValueAtTime(volume, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + pulseDuration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(startTime);
osc.stop(startTime + pulseDuration + 0.01);
});
}Tuning guide:
- "Softer" → lower frequency (450), sine wave, lower volume (0.15)
- "More urgent" → three pulses, shorter gap (0.05), higher frequency (750)
- "Friendlier" → sine wave, longer pulses (0.12), wider gap (0.12)
Variations:
- Subtle warning: Sine, 500Hz, single pulse, duration 0.15, volume 0.15
- Urgent warning: Triangle, 750Hz, three pulses, gap 0.05
- Advisory tone: Sine, 550Hz, two pulses, long gap (0.15)
Rules followed: context-singleton, gain-no-zero-target, gain-set-before-ramp, scheduling-capture-once, stop-after-envelope, exponential-over-linear, oscillator-for-tonal, volume-max-0.8
---
Notification {#notification}
Bell-like FM synthesis. Distinctive and attention-grabbing without being harsh.
| Parameter | Default | Range | Effect |
|---|---|---|---|
frequency | 880 | 600–1400 | Base pitch (A5 default) |
modRatio | 1.4 | 1.2–3.0 | Higher = more metallic |
modDepth | 1500 | 500–3000 | More = brighter/harsher |
duration | 0.4 | 0.2–0.8 | Bell sustain length |
volume | 0.25 | 0.15–0.4 |
function playNotification({ frequency = 880, modRatio = 1.4, modDepth = 1500, duration = 0.4, volume = 0.25 } = {}) {
const ctx = getAudioContext();
const now = ctx.currentTime;
const modulator = ctx.createOscillator();
const modGain = ctx.createGain();
modulator.frequency.value = frequency * modRatio;
modGain.gain.setValueAtTime(modDepth, now);
modGain.gain.exponentialRampToValueAtTime(0.001, now + duration * 0.8);
const carrier = ctx.createOscillator();
const carrierGain = ctx.createGain();
carrier.frequency.value = frequency;
carrierGain.gain.setValueAtTime(volume, now);
carrierGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
modulator.connect(modGain);
modGain.connect(carrier.frequency);
carrier.connect(carrierGain);
carrierGain.connect(ctx.destination);
modulator.start(now);
carrier.start(now);
modulator.stop(now + duration + 0.01);
carrier.stop(now + duration + 0.01);
}Tuning guide:
- "More bell-like" → modRatio 1.4, longer duration (0.6), lower modDepth (800)
- "More electronic" → modRatio 2.0, shorter duration (0.25), higher modDepth
- "Warmer/softer" → lower frequency (660), lower modDepth (500), longer duration
Variations:
- Chime sequence: Play two notifications, second at frequency × 1.5, staggered by 0.2s
- Soft bell:
{ frequency: 660, modRatio: 1.2, modDepth: 500, duration: 0.6, volume: 0.15 } - Alert ping:
{ frequency: 1100, modRatio: 2.0, modDepth: 2000, duration: 0.2, volume: 0.3 }
Rules followed: context-singleton, gain-no-zero-target, gain-set-before-ramp, scheduling-capture-once, stop-after-envelope, exponential-over-linear, oscillator-for-tonal, volume-max-0.8
---
Whoosh {#whoosh}
Filtered white noise with frequency sweep. Conveys movement and transition.
| Parameter | Default | Range | Effect |
|---|---|---|---|
startFilterFreq | 500 | 200–2000 | Filter sweep start |
endFilterFreq | 4000 | 2000–8000 | Filter sweep end |
duration | 0.2 | 0.1–0.4 | Total length |
Q | 1 | 0.5–5 | Higher = more tonal |
volume | 0.2 | 0.1–0.4 |
function playWhoosh({ startFilterFreq = 500, endFilterFreq = 4000, duration = 0.2, Q = 1, volume = 0.2 } = {}) {
const ctx = getAudioContext();
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * duration;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const source = ctx.createBufferSource();
source.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.Q.value = Q;
filter.frequency.setValueAtTime(startFilterFreq, now);
filter.frequency.exponentialRampToValueAtTime(endFilterFreq, now + duration);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.001, now);
gain.gain.exponentialRampToValueAtTime(volume, now + duration * 0.3);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
source.start(now);
}Tuning guide:
- "Faster/snappier" → shorter duration (0.1), wider frequency range
- "Swooshier" → higher Q (3), narrower frequency band
- "Reverse whoosh" → swap start/end filter frequencies (high→low)
- "Heavier" → lower frequency range (200→2000), longer duration
Variations:
- Quick swipe: duration 0.1, range 1000→6000, volume 0.15
- Heavy swoosh: duration 0.3, range 200→2000, Q 2
- Reverse (slide in): range 4000→500, duration 0.2
Rules followed: context-singleton, gain-no-zero-target, gain-set-before-ramp, scheduling-capture-once, exponential-over-linear, noise-for-percussion, filter-for-character, volume-max-0.8
---
Pop {#pop}
Short sine with rapid pitch drop. Satisfying, percussive feel for discrete actions.
| Parameter | Default | Range | Effect |
|---|---|---|---|
startFreq | 1200 | 600–2400 | Initial pitch (higher = lighter) |
endFreq | 300 | 100–600 | Final pitch |
duration | 0.06 | 0.03–0.1 | Very short |
volume | 0.3 | 0.15–0.5 |
function playPop({ startFreq = 1200, endFreq = 300, duration = 0.06, volume = 0.3 } = {}) {
const ctx = getAudioContext();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(startFreq, now);
osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.01);
}Tuning guide:
- "Bubblier" → higher startFreq (2000), sine wave, slightly longer (0.08)
- "Heavier/thunkier" → lower startFreq (600), lower endFreq (100), longer (0.1)
- "Snappier" → shorter duration (0.03), wider frequency range
Variations:
- Bubble pop:
{ startFreq: 2000, endFreq: 400, duration: 0.08, volume: 0.25 } - Deep pop:
{ startFreq: 600, endFreq: 100, duration: 0.1, volume: 0.35 } - Light tap:
{ startFreq: 1500, endFreq: 500, duration: 0.04, volume: 0.2 }
Rules followed: context-singleton, gain-no-zero-target, gain-set-before-ramp, scheduling-capture-once, stop-after-envelope, exponential-over-linear, oscillator-for-tonal, volume-max-0.8
---
Complete Sound Library
Bundle all sounds into a single class for easy integration.
class UISoundLibrary {
constructor() {
this.ctx = null;
this.enabled = true;
this.masterVolume = 0.3;
}
getContext() {
if (!this.ctx) this.ctx = new (window.AudioContext || window.webkitAudioContext)();
if (this.ctx.state === 'suspended') this.ctx.resume();
return this.ctx;
}
play(soundFn, options = {}) {
if (!this.enabled) return;
const volume = (options.volume || 0.3) * (this.masterVolume / 0.3);
soundFn.call(this, { ...options, volume: Math.min(volume, 0.8) });
}
click(options = {}) {
const ctx = this.getContext();
const now = ctx.currentTime;
const { frequency = 2000, Q = 2, duration = 0.05, volume = 0.3 } = options;
const bufferSize = ctx.sampleRate * duration;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const source = ctx.createBufferSource();
source.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = frequency;
filter.Q.value = Q;
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
source.start(now);
}
toggle(isOn, options = {}) {
const ctx = this.getContext();
const now = ctx.currentTime;
const { duration = 0.12, volume = 0.25 } = options;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(isOn ? 500 : 700, now);
osc.frequency.exponentialRampToValueAtTime(isOn ? 700 : 500, now + duration);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.01);
}
hover(options = {}) {
const ctx = this.getContext();
const now = ctx.currentTime;
const { frequency = 2400, duration = 0.06, volume = 0.08 } = options;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = frequency;
gain.gain.setValueAtTime(0.001, now);
gain.gain.exponentialRampToValueAtTime(volume, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.01);
}
success(options = {}) {
const ctx = this.getContext();
const now = ctx.currentTime;
const { baseFreq = 523, interval = 1.25, noteDuration = 0.12, gap = 0.08, volume = 0.3 } = options;
[0, 1].forEach(i => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const t = now + i * (noteDuration + gap);
osc.type = 'sine';
osc.frequency.value = baseFreq * Math.pow(interval, i);
gain.gain.setValueAtTime(volume, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + noteDuration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + noteDuration + 0.01);
});
}
error(options = {}) {
const ctx = this.getContext();
const now = ctx.currentTime;
const { startFreq = 400, endFreq = 200, filterFreq = 1500, duration = 0.25, volume = 0.25 } = options;
const osc = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(startFreq, now);
osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration);
filter.type = 'lowpass';
filter.frequency.value = filterFreq;
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.01);
}
warning(options = {}) {
const ctx = this.getContext();
const now = ctx.currentTime;
const { frequency = 600, pulseDuration = 0.08, gap = 0.08, volume = 0.25 } = options;
[0, 1].forEach(i => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const t = now + i * (pulseDuration + gap);
osc.type = 'triangle';
osc.frequency.value = frequency;
gain.gain.setValueAtTime(volume, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + pulseDuration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + pulseDuration + 0.01);
});
}
notification(options = {}) {
const ctx = this.getContext();
const now = ctx.currentTime;
const { frequency = 880, modRatio = 1.4, modDepth = 1500, duration = 0.4, volume = 0.25 } = options;
const mod = ctx.createOscillator();
const modGain = ctx.createGain();
mod.frequency.value = frequency * modRatio;
modGain.gain.setValueAtTime(modDepth, now);
modGain.gain.exponentialRampToValueAtTime(0.001, now + duration * 0.8);
const carrier = ctx.createOscillator();
const cGain = ctx.createGain();
carrier.frequency.value = frequency;
cGain.gain.setValueAtTime(volume, now);
cGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
mod.connect(modGain);
modGain.connect(carrier.frequency);
carrier.connect(cGain);
cGain.connect(ctx.destination);
mod.start(now);
carrier.start(now);
mod.stop(now + duration + 0.01);
carrier.stop(now + duration + 0.01);
}
whoosh(options = {}) {
const ctx = this.getContext();
const now = ctx.currentTime;
const { startFilterFreq = 500, endFilterFreq = 4000, duration = 0.2, Q = 1, volume = 0.2 } = options;
const bufferSize = ctx.sampleRate * duration;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const source = ctx.createBufferSource();
source.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.Q.value = Q;
filter.frequency.setValueAtTime(startFilterFreq, now);
filter.frequency.exponentialRampToValueAtTime(endFilterFreq, now + duration);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.001, now);
gain.gain.exponentialRampToValueAtTime(volume, now + duration * 0.3);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
source.start(now);
}
pop(options = {}) {
const ctx = this.getContext();
const now = ctx.currentTime;
const { startFreq = 1200, endFreq = 300, duration = 0.06, volume = 0.3 } = options;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(startFreq, now);
osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.01);
}
mute() { this.enabled = false; }
unmute() { this.enabled = true; }
setVolume(v) { this.masterVolume = Math.min(Math.max(v, 0), 0.8); }
}
// Usage:
// const sounds = new UISoundLibrary();
// sounds.click();
// sounds.toggle(true);
// sounds.success();
// sounds.notification();Tone.js — Simplified UI Sound Patterns
Tone.js wraps the Web Audio API with higher-level abstractions. Use it for faster prototyping when vanilla Web Audio feels verbose. Convert to vanilla Web Audio for production if bundle size matters.
Setup
CDN (quick prototyping)
<script src="https://unpkg.com/tone"></script>npm (production)
npm install toneUser gesture requirement
Same as vanilla Web Audio — must start audio from a user interaction:
document.addEventListener('click', async () => {
await Tone.start();
console.log('Audio ready');
}, { once: true });Synth Types
| Synth | Character | Best For | Key Parameters |
|---|---|---|---|
Tone.Synth | Clean, general purpose | Toggles, success/error tones | oscillator.type, envelope |
Tone.MembraneSynth | Punchy, drum-like | Pops, deep clicks, tactile feedback | pitchDecay, octaves |
Tone.MetalSynth | Metallic, inharmonic | Notifications, alerts, bells | modulationIndex, harmonicity |
Tone.NoiseSynth | Noise-based, percussive | Clicks, whooshes, transitions | noise.type, envelope |
Tone.PluckSynth | String-pluck, natural | Gentle feedback, organic sounds | attackNoise, resonance |
Recipes
Click
const clickSynth = new Tone.NoiseSynth({
noise: { type: 'white' },
envelope: { attack: 0.001, decay: 0.05, sustain: 0 }
}).toDestination();
clickSynth.volume.value = -20; // dB
function playClick() {
clickSynth.triggerAttackRelease('16n');
}Variations:
- Softer:
volume: -26,decay: 0.08 - Sharper:
noise.type: 'pink',decay: 0.03, add highpass filter at 2000Hz
Toggle
const toggleSynth = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.01, decay: 0.1, sustain: 0, release: 0.05 }
}).toDestination();
toggleSynth.volume.value = -18;
function playToggle(isOn) {
toggleSynth.triggerAttackRelease(isOn ? 'C5' : 'G4', '16n');
}Success
const successSynth = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.01, decay: 0.15, sustain: 0, release: 0.1 }
}).toDestination();
successSynth.volume.value = -16;
function playSuccess() {
const now = Tone.now();
successSynth.triggerAttackRelease('C5', '16n', now);
successSynth.triggerAttackRelease('E5', '16n', now + 0.15);
}Variation — triumphant:
function playTriumph() {
const now = Tone.now();
successSynth.triggerAttackRelease('C5', '16n', now);
successSynth.triggerAttackRelease('E5', '16n', now + 0.12);
successSynth.triggerAttackRelease('G5', '8n', now + 0.24);
}Error
const errorSynth = new Tone.Synth({
oscillator: { type: 'sawtooth' },
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.1 }
}).toDestination();
const errorFilter = new Tone.Filter(1500, 'lowpass').toDestination();
errorSynth.disconnect();
errorSynth.connect(errorFilter);
errorSynth.volume.value = -18;
function playError() {
errorSynth.triggerAttackRelease('E3', '8n');
}Notification (Bell)
const bellSynth = new Tone.MetalSynth({
frequency: 880,
envelope: { attack: 0.001, decay: 0.4, release: 0.1 },
harmonicity: 1.4,
modulationIndex: 8,
resonance: 3000,
octaves: 0.5
}).toDestination();
bellSynth.volume.value = -22;
function playNotification() {
bellSynth.triggerAttackRelease('16n');
}Variation — chime sequence:
function playChime() {
const now = Tone.now();
bellSynth.triggerAttackRelease('16n', now);
bellSynth.frequency = 1320; // Higher pitch
bellSynth.triggerAttackRelease('16n', now + 0.2);
bellSynth.frequency = 880; // Reset
}Whoosh
const whooshSynth = new Tone.NoiseSynth({
noise: { type: 'white' },
envelope: { attack: 0.05, decay: 0.15, sustain: 0, release: 0.05 }
}).toDestination();
const whooshFilter = new Tone.AutoFilter({
frequency: 8,
baseFrequency: 500,
octaves: 4
}).toDestination().start();
whooshSynth.disconnect();
whooshSynth.connect(whooshFilter);
whooshSynth.volume.value = -20;
function playWhoosh() {
whooshSynth.triggerAttackRelease('8n');
}Pop
const popSynth = new Tone.MembraneSynth({
pitchDecay: 0.03,
octaves: 4,
oscillator: { type: 'sine' },
envelope: { attack: 0.001, decay: 0.06, sustain: 0, release: 0.01 }
}).toDestination();
popSynth.volume.value = -16;
function playPop() {
popSynth.triggerAttackRelease('C4', '32n');
}Variations:
- Bubble:
octaves: 6,pitchDecay: 0.05, trigger atC5 - Deep thunk:
octaves: 2,pitchDecay: 0.08, trigger atC2
Effects
Keep effects subtle for UI sounds — they should enhance, not dominate.
Reverb (adds space/depth)
const reverb = new Tone.Reverb({ decay: 0.5, wet: 0.15 }).toDestination();
synth.connect(reverb);decay: 0.3–1.0 for UI sounds (longer = more ambient)wet: 0.1–0.25 (keep low — UI sounds should feel immediate)
Delay (adds rhythm/echo)
const delay = new Tone.FeedbackDelay({
delayTime: '16n',
feedback: 0.1,
wet: 0.1
}).toDestination();
synth.connect(delay);Use sparingly — delay on frequent interactions (clicks, hovers) becomes distracting.
Filter (shapes tone)
const filter = new Tone.Filter({
frequency: 2000,
type: 'lowpass',
rolloff: -12
}).toDestination();
synth.connect(filter);Volume in Tone.js
Tone.js uses decibels (dB), not 0–1 linear values:
| dB | Perceived | Use For |
|---|---|---|
| -30 | Very quiet | Hover sounds |
| -24 | Quiet | Subtle feedback |
| -18 | Moderate | Standard interactions |
| -12 | Present | Notifications, alerts |
| -6 | Loud | Important alerts (use sparingly) |
synth.volume.value = -18; // Set volume in dBConverting Tone.js to Vanilla Web Audio
When you need to remove the Tone.js dependency for production:
| Tone.js | Vanilla Equivalent |
|---|---|
new Tone.Synth() | createOscillator() + createGain() with envelope |
new Tone.NoiseSynth() | createBufferSource() with noise buffer + createGain() |
new Tone.MembraneSynth() | Oscillator with frequency.exponentialRampToValueAtTime() |
new Tone.MetalSynth() | FM synthesis (modulator → carrier) |
new Tone.Filter() | createBiquadFilter() |
new Tone.Reverb() | createConvolver() with impulse response |
triggerAttackRelease(note, duration) | Manual start()/stop() with gain envelope |
Tone.now() | audioCtx.currentTime |
| Volume in dB | Math.pow(10, dB / 20) for linear gain |
Conversion strategy: 1. Prototype with Tone.js for speed 2. Get the sound right with the user 3. Convert to vanilla Web Audio using references/web-audio-api.md patterns 4. The UISoundLibrary class in references/sound-recipes.md is already vanilla
When to Use Tone.js vs Vanilla
| Scenario | Recommendation |
|---|---|
| Quick prototype / hearing the concept | Tone.js |
| Production app, bundle size matters | Vanilla Web Audio |
| Complex synthesis (FM, AM, granular) | Tone.js |
| Simple UI sounds (clicks, toggles) | Vanilla Web Audio |
| User wants to tweak interactively | Tone.js (faster iteration) |
| Final sound library for shipping | Vanilla Web Audio |
Web Audio API — Core Building Blocks
Reference for programmatic UI sound synthesis. All examples use vanilla Web Audio API with no dependencies.
AudioContext Setup
A single AudioContext should be shared across all sounds. Browsers require a user gesture before audio can play.
// Singleton pattern — create once, reuse everywhere
let audioCtx = null;
function getAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
// Resume if suspended (happens after page load without interaction)
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
return audioCtx;
}User gesture requirement: Call getAudioContext() inside a click/touch/keydown handler the first time. After that, it works anywhere.
Oscillator Types
The oscillator is the primary sound source. Each waveform has a distinct character.
| Type | Character | Best For | Harmonics |
|---|---|---|---|
sine | Pure, clean, smooth | Subtle tones, hover sounds, gentle notifications | Fundamental only |
square | Hollow, retro, buzzy | Toggle clicks, 8-bit style sounds, alerts | Odd harmonics |
sawtooth | Bright, harsh, rich | Error sounds, warnings, aggressive feedback | All harmonics |
triangle | Soft, mellow, muted | Soft clicks, gentle confirmations, background tones | Odd harmonics (weaker) |
function playTone(frequency, type, duration) {
const ctx = getAudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type; // 'sine', 'square', 'sawtooth', 'triangle'
osc.frequency.value = frequency; // Hz
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + duration);
}Gain Envelopes (ADSR)
The envelope shapes how a sound's volume changes over time. This is the single most important pattern for making sounds feel natural.
ADSR = Attack, Decay, Sustain, Release:
- Attack: Time to reach peak volume (0 = instant click, 0.01 = soft onset)
- Decay: Time to fall from peak to sustain level
- Sustain: Volume level held during the middle of the sound
- Release: Time to fade to silence after the sound ends
function playWithEnvelope(frequency, { attack = 0.01, decay = 0.1, sustain = 0.3, release = 0.1, peak = 0.5 } = {}) {
const ctx = getAudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const now = ctx.currentTime;
osc.frequency.value = frequency;
osc.connect(gain);
gain.connect(ctx.destination);
// Attack: silence → peak
gain.gain.setValueAtTime(0.001, now);
gain.gain.exponentialRampToValueAtTime(peak, now + attack);
// Decay: peak → sustain
gain.gain.exponentialRampToValueAtTime(sustain, now + attack + decay);
// Release: sustain → silence
const releaseStart = now + attack + decay + 0.1;
gain.gain.setValueAtTime(sustain, releaseStart);
gain.gain.exponentialRampToValueAtTime(0.001, releaseStart + release);
osc.start(now);
osc.stop(releaseStart + release + 0.01);
}Critical: exponentialRampToValueAtTime cannot ramp to 0 — it throws an error. Always ramp to 0.001 instead.
White Noise Generation
White noise is essential for click, whoosh, and percussion sounds.
function createNoiseBuffer(duration = 1) {
const ctx = getAudioContext();
const sampleRate = ctx.sampleRate;
const length = sampleRate * duration;
const buffer = ctx.createBuffer(1, length, sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}
function playNoise(duration = 0.1) {
const ctx = getAudioContext();
const source = ctx.createBufferSource();
const gain = ctx.createGain();
source.buffer = createNoiseBuffer(duration);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
source.connect(gain);
gain.connect(ctx.destination);
source.start(ctx.currentTime);
}Filter Types (BiquadFilterNode)
Filters shape the frequency content of a sound, making it brighter, darker, or more focused.
| Type | Effect | Frequency Range | Use Case |
|---|---|---|---|
lowpass | Removes highs, keeps lows | 200-5000 Hz cutoff | Warm/muffled sounds, soft clicks |
highpass | Removes lows, keeps highs | 200-2000 Hz cutoff | Thin/airy sounds, removing muddiness |
bandpass | Keeps a frequency band | 500-4000 Hz center | Focused clicks, telephone-like quality |
notch | Removes a frequency band | Any | Removing specific resonances |
function playFilteredNoise(filterType, frequency, Q, duration) {
const ctx = getAudioContext();
const source = ctx.createBufferSource();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
source.buffer = createNoiseBuffer(duration);
filter.type = filterType; // 'lowpass', 'highpass', 'bandpass'
filter.frequency.value = frequency;
filter.Q.value = Q; // Resonance: 0.5 (gentle) to 15 (sharp)
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
source.start(ctx.currentTime);
}Q factor guide:
- 0.5–1: Gentle slope, natural sound
- 1–5: Noticeable filtering, focused
- 5–15: Sharp resonance, pronounced peak
- 15+: Ringing, almost self-oscillating
Frequency Sweeps
Sweeping frequency over time creates movement — rising for positive actions, falling for negative.
function frequencySweep(startFreq, endFreq, duration, type = 'sine') {
const ctx = getAudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const now = ctx.currentTime;
osc.type = type;
osc.frequency.setValueAtTime(startFreq, now);
// Exponential sweep sounds more natural
osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration);
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.01);
}
// Rising = positive (success, toggle on)
// frequencySweep(400, 800, 0.15);
// Falling = negative (error, toggle off, dismiss)
// frequencySweep(800, 400, 0.15);Linear vs exponential ramps:
linearRampToValueAtTime: Even change per second. Sounds mechanical.exponentialRampToValueAtTime: Proportional change. Sounds natural. Use this by default.- Linear is only better for very short sweeps (<50ms) where the difference is inaudible.
Layering Oscillators
Combining multiple oscillators creates richer, more complex sounds.
function playRichTone(frequency, duration = 0.3) {
const ctx = getAudioContext();
const now = ctx.currentTime;
const masterGain = ctx.createGain();
masterGain.gain.setValueAtTime(0.3, now);
masterGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
masterGain.connect(ctx.destination);
// Layer 1: Fundamental
const osc1 = ctx.createOscillator();
osc1.frequency.value = frequency;
osc1.connect(masterGain);
// Layer 2: Slightly detuned for warmth
const osc2 = ctx.createOscillator();
osc2.frequency.value = frequency;
osc2.detune.value = 7; // cents (1/100th of a semitone)
osc2.connect(masterGain);
// Layer 3: Octave above for brightness
const osc3 = ctx.createOscillator();
osc3.frequency.value = frequency * 2;
const osc3Gain = ctx.createGain();
osc3Gain.gain.value = 0.3; // Quieter than fundamental
osc3.connect(osc3Gain);
osc3Gain.connect(masterGain);
[osc1, osc2, osc3].forEach(osc => {
osc.start(now);
osc.stop(now + duration + 0.01);
});
}Detune values:
- 3-7 cents: Subtle warmth (chorus-like)
- 10-25 cents: Noticeable thickening
- 50+ cents: Dissonant, unsettling (use for error sounds)
FM Synthesis (Bell/Metallic Tones)
Frequency Modulation creates complex, inharmonic tones — perfect for bells, chimes, and notification sounds.
function playBellTone(frequency = 880, duration = 0.6) {
const ctx = getAudioContext();
const now = ctx.currentTime;
// Modulator oscillator (not heard directly)
const modulator = ctx.createOscillator();
const modGain = ctx.createGain();
modulator.frequency.value = frequency * 1.4; // Non-integer ratio = inharmonic (bell-like)
modGain.gain.setValueAtTime(frequency * 2, now);
modGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
// Carrier oscillator (the sound you hear)
const carrier = ctx.createOscillator();
const carrierGain = ctx.createGain();
carrier.frequency.value = frequency;
carrierGain.gain.setValueAtTime(0.3, now);
carrierGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
// Connect modulator → carrier frequency
modulator.connect(modGain);
modGain.connect(carrier.frequency);
// Connect carrier → output
carrier.connect(carrierGain);
carrierGain.connect(ctx.destination);
modulator.start(now);
carrier.start(now);
modulator.stop(now + duration + 0.01);
carrier.stop(now + duration + 0.01);
}Modulator ratio guide:
- Integer ratios (1, 2, 3): Harmonic, musical
- Non-integer ratios (1.4, 2.76): Inharmonic, metallic, bell-like
- Higher modulation depth: More overtones, brighter/harsher
- Decaying modulation: Sound starts bright, becomes pure
Reusable Factory Pattern
Wrap sound generation in a factory that returns a play function.
function createSound(setup) {
return function play(options = {}) {
const ctx = getAudioContext();
const now = ctx.currentTime;
setup(ctx, now, options);
};
}
// Usage
const click = createSound((ctx, now, { volume = 0.3 } = {}) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.setValueAtTime(1800, now);
osc.frequency.exponentialRampToValueAtTime(200, now + 0.05);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.06);
});
click(); // Play with defaults
click({ volume: 0.5 }); // LouderCommon Mistakes
Gain click/pop artifacts
Problem: Abrupt gain changes cause audible clicks. Fix: Always ramp gain values. Never assign gain.value directly during playback. Use setValueAtTime followed by a ramp.
// BAD — causes clicks
gain.gain.value = 0;
// GOOD — smooth fade
gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.02);Creating AudioContext per sound
Problem: Each new AudioContext() allocates system resources. Browsers limit the count. Fix: Use the singleton pattern above. One context for the entire app.
Memory leaks from node accumulation
Problem: Audio nodes are not garbage collected while connected. Fix: Oscillators auto-disconnect after stop(). For long-lived nodes, call disconnect() when done. BufferSource nodes are one-shot — create a new one each time.
BufferSource cleanup with onended
BufferSourceNodes (used for noise-based sounds like clicks and whooshes) do not auto-disconnect their downstream nodes. Use the onended callback to clean up the entire signal chain:
const source = ctx.createBufferSource();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
source.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
source.start(now);
source.onended = () => {
source.disconnect();
filter.disconnect();
gain.disconnect();
};This prevents filter and gain nodes from accumulating in memory across repeated plays. See rule node-cleanup in audio-rules.md.
exponentialRamp to zero
Problem: exponentialRampToValueAtTime(0, ...) throws because you can't exponentially approach 0. Fix: Always ramp to 0.001 instead. It's inaudible but mathematically valid.
linearRamp vs exponentialRamp
Problem: linearRampToValueAtTime sounds unnatural for volume and frequency changes. Fix: Use exponentialRampToValueAtTime by default. Linear is only appropriate for very short transitions (<50ms) or special effects.
Scheduling in the past
Problem: Using ctx.currentTime across multiple lines — time advances between reads. Fix: Capture const now = ctx.currentTime; once at the start and derive all times from now.
Per-Sound-Type Parameter Bounds
Quick reference for safe parameter ranges across all 9 UI sound categories. Values outside these ranges are almost always wrong. See audio-rules.md for the full rule definitions.
| Category | Duration | Volume | Filter Q | Attack | Key Constraint |
|---|---|---|---|---|---|
| Click | 10–80ms | 0.1–0.6 | 0.5–10 | 0ms | Noise source, not oscillator |
| Toggle | 80–200ms | 0.1–0.4 | — | 0ms | Sweep direction = state |
| Hover | 30–80ms | 0.03–0.08 | — | 5–15ms | Must be subliminal |
| Success | 200–500ms | 0.15–0.5 | — | 0ms | Ascending interval |
| Error | 150–400ms | 0.15–0.4 | 0.5–3 | 0ms | Descending, dark timbre |
| Warning | 150–350ms | 0.15–0.4 | — | 0ms | Double pulse |
| Notification | 200–800ms | 0.15–0.4 | — | 0ms | FM synthesis |
| Whoosh | 100–400ms | 0.1–0.4 | 0.5–5 | — | Noise + filter sweep |
| Pop | 30–80ms | 0.15–0.5 | — | 0ms | Rapid pitch drop |
Validation Checklist
Self-check for generated Web Audio code. Every item should pass before shipping.
- [ ] Singleton context — Uses shared
getAudioContext(), nevernew AudioContext()per sound - [ ] Suspended check — Calls
resume()if context state is'suspended' - [ ] `setValueAtTime` before ramp — Every
exponentialRampToValueAtTime/linearRampToValueAtTimehas a precedingsetValueAtTime - [ ] No ramp to zero — All exponential ramps target
0.001, never0 - [ ] Single `currentTime` capture —
const now = ctx.currentTimecaptured once, all scheduling derived fromnow - [ ] Oscillator stop padding —
osc.stop(now + duration + 0.01)— at least 0.01s after envelope ends - [ ] Volume ceiling — No gain value exceeds
0.8 - [ ] Exponential ramps — Using
exponentialRampToValueAtTimefor durations > 50ms - [ ] BufferSource cleanup — Noise-based sounds use
source.onendedto disconnect filter/gain nodes - [ ] Duration in range — Sound duration falls within the bounds for its category (see table above)