How I Built Procedural Ambient Sounds with the Web Audio API
8 min read
Most ambient sound apps ship megabytes of pre-recorded audio loops. You've heard them — that awkward splice every 30 seconds where the rain recording restarts. HushWork takes a different approach: every noise type is generated procedurally in your browser using the Web Audio API. No downloads. No loops. No seams.
This article walks through the architecture, the signal processing, and the trade-offs of building real-time ambient audio in the browser.
Why Procedural Generation?
Pre-recorded loops have three problems:
File size. A single high-quality 5-minute rain loop is 5–10 MB. When you offer 20+ sounds, that's 100–200 MB of audio the user has to download. For a PWA that works offline, that's a non-starter.
Looping artifacts. No matter how carefully you crossfade, loops eventually feel repetitive. Your brain picks up on the pattern, and the sound that was calming becomes irritating.
No layering flexibility. When sounds are pre-recorded, mixing is limited to volume. You can't shape the frequency content or create variations.
Procedural generation solves all three: zero file size for noise types, infinite non-repeating audio, and full control over the frequency spectrum.
The Web Audio API Basics
The Web Audio API provides a node-based audio graph. You create source nodes, processing nodes, and connect them to an output destination. Everything runs on a dedicated audio thread, so it won't block your UI.
The core architecture looks like this:
AudioContext
└─ Source Node (noise buffer / audio element)
└─ BiquadFilterNode (frequency shaping)
└─ GainNode (volume control)
└─ destination (speakers)
Each sound in HushWork is an independent chain of nodes. When you layer rain + brown noise + birds, three separate chains run in parallel and mix at the destination.
Generating White Noise
White noise is the foundation. It has equal energy across all frequencies — pure randomness.
const bufferSize = sampleRate * 2; // 2 seconds of audio
const buffer = audioContext.createBuffer(1, bufferSize, sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1; // random values between -1 and 1
}
You create an AudioBufferSourceNode, assign this buffer, set it to loop, and connect it to the output. That's white noise — flat frequency spectrum, equal power at every frequency.
But white noise sounds harsh and hissy. Most people find it fatiguing after a few minutes. That's where filtering comes in.
Shaping Brown Noise
Brown noise (also called Brownian or red noise) emphasizes low frequencies and rolls off the highs. It sounds like a deep rumble — distant thunder, a waterfall, the inside of an airplane. It's the most popular noise type for focus, especially among ADHD users.
The mathematical definition: brown noise has a power spectral density proportional to 1/f², meaning energy decreases by 6 dB per octave as frequency increases.
In practice, you can approximate this by applying a lowpass filter to white noise:
const filter = audioContext.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.value = 400; // cutoff frequency in Hz
filter.Q.value = 0.5; // gentle rolloff, no resonance peak
Connect: white noise source → lowpass filter → gain → destination.
The cutoff frequency and Q factor control the character. A lower cutoff (200 Hz) gives a deeper, more thunderous tone. A higher cutoff (600 Hz) lets more mid-range through, sounding closer to a steady rain.
Pink Noise: The Middle Ground
Pink noise sits between white and brown. Its power density is proportional to 1/f — energy drops 3 dB per octave. It sounds more balanced than brown noise but softer than white. Think of steady rainfall or wind through trees.
Pink noise is trickier to generate accurately because a simple lowpass filter doesn't match the 1/f slope precisely. The approach that works well in practice is a multi-stage filter:
// Approximate pink noise with cascaded first-order filters
// Each filter contributes ~3dB/octave rolloff at specific frequency bands
const filters = [
{ type: "lowshelf", frequency: 100, gain: -3 },
{ type: "lowshelf", frequency: 1000, gain: -3 },
{ type: "lowshelf", frequency: 5000, gain: -6 },
];
This cascaded approach gives a reasonable 1/f approximation that sounds natural to human ears. Perfect mathematical accuracy matters less than perceptual quality.
Other Noise Colors
The same principle extends to other noise types:
- Green noise — Bandpass-filtered around 500 Hz. A narrow, nature-like tone that some find less fatiguing than broadband noise.
- Blue noise — High-pass filtered white noise (1/f⁻¹). Emphasizes higher frequencies. Sounds like a sharp hiss. Some people use it for tinnitus masking.
- Violet noise — Even steeper high-frequency emphasis (1/f⁻²). Aggressive and bright.
Each is just white noise run through different filter configurations. The Web Audio API's BiquadFilterNode supports lowpass, highpass, bandpass, lowshelf, highshelf, and more — enough to approximate any noise color.
Nature Sounds: A Hybrid Approach
Not everything can be procedurally generated. Rain has a recognizable texture. Birds have specific calls. Fireplace crackle has a particular pattern. For these, HushWork uses short audio recordings — but with a twist.
Instead of one long loop, we use shorter samples (15–30 seconds) with randomized crossfading:
// Crossfade between two buffers with randomized timing
const fadeTime = 2 + Math.random() * 3; // 2-5 second crossfade
gainA.gain.linearRampToValueAtTime(0, ctx.currentTime + fadeTime);
gainB.gain.linearRampToValueAtTime(volume, ctx.currentTime + fadeTime);
The randomized fade timing prevents the brain from detecting the loop point. Combined with slight playback rate variations, this makes short samples feel like continuous, non-repeating audio.
Volume Control and Mixing
Each sound chain has its own GainNode. When the user adjusts a volume slider, we ramp the gain value smoothly to avoid clicks:
gainNode.gain.linearRampToValueAtTime(newVolume, ctx.currentTime + 0.05);
The 50ms ramp eliminates the audible "pop" that happens when you set gain instantaneously. Small detail, but it makes the difference between a polished and an amateur audio experience.
Browser Quirks and Gotchas
AudioContext suspension. Browsers require a user gesture before allowing audio playback. The AudioContext starts in a "suspended" state and must be resumed on a click or keypress. HushWork resumes the context on the first user interaction.
Background tab throttling. Browsers throttle JavaScript timers in background tabs. Fortunately, the Web Audio API runs on a separate thread and continues playing when the tab is backgrounded. This is critical for a focus app — the sound shouldn't stop when you switch tabs.
Memory management. Each AudioBufferSourceNode is single-use: once you call stop(), it can't be restarted. You create a new one each time. For looping noise, this is fine — the buffer is reused, only the node is recreated.
Sample rate consistency. Different devices have different default sample rates (44100, 48000, sometimes 96000). Generate your noise buffers at the context's sampleRate to avoid resampling artifacts.
Performance Considerations
Running multiple audio chains simultaneously is surprisingly lightweight. The Web Audio API is optimized for real-time processing:
- CPU usage: 5–8 active sound chains (noise generators + filters + gains) use less than 2% CPU on modern hardware.
- Memory: A 2-second noise buffer at 48kHz is ~384 KB. Reused across restarts.
- Latency: Not relevant for ambient sound — we're not building a musical instrument. The default buffer size is fine.
The real performance concern is the UI thread. HushWork uses requestAnimationFrame for volume meters and timer displays, but the audio processing itself is completely off the main thread.
The Result
This architecture gives HushWork several advantages over loop-based competitors:
- Instant loading. No audio files to download for noise types. The app is usable in under a second.
- Infinite variation. Procedural noise never repeats. No looping artifacts, ever.
- True offline support. As a PWA, the noise generators work without any network connection.
- Tiny footprint. The entire app, including nature sound samples, is a fraction of the size of a single competitor's audio library.
- Seamless layering. Mix any combination of sounds with independent volume control.
If you want to hear the difference, open HushWork and try layering brown noise with rain. No sign-up, no download — just one browser tab.
Related: Brown Noise vs White Noise · Best Ambient Sounds for Studying · The Science of Deep Work
Ready to try focused work?
Open HushWork →