michlp / noise-bench
publicNoise Bench
Continuous colored noise in a single HTML file — white, pink, brown, blue, violet, gray and green. Generated in the browser, level-matched across colors, with a spectrum analyzer that plots the measured curve against the theoretical slope so you can check the −3 dB/octave rather than take it on faith.
No build step, no dependencies, no network requests, nothing recorded.
public/index.html is entirely self-contained — fonts included — and is the
whole application.
The colors
Five are power laws: spectral density falls off as 1/f^α, and the color name just encodes α. Two are not, and it's worth knowing which.
| Color | Slope | α | What it is |
|---|---|---|---|
| White | 0 dB/oct | 0 | Flat power per hertz. Bright and hissy, because equal power per hertz puts far more energy in the top octave than the bottom. |
| Pink | −3 dB/oct | 1 | Equal power per octave, which matches how hearing is organized. The usual choice for masking and sleep, and what most "white noise" apps actually play. |
| Brown | −6 dB/oct | 2 | Integrated white noise — a random walk, so Brownian rather than a color. Deep and rumbling. |
| Blue | +3 dB/oct | −1 | Pink inverted. Used in dithering, rarely for listening. |
| Violet | +6 dB/oct | −2 | Differentiated white noise. Sometimes used in tinnitus therapy. |
| Gray | contoured | — | Not a power law. White shaped by an inverse equal-loudness contour, so it reads as equally loud at every frequency — cutting 2–5 kHz where hearing is most sensitive and lifting both ends. Approximated here with four biquads. |
| Green | −3 dB + 500 Hz | — | Not a standard at all. No IEEE or ANSI definition exists; it's a de-facto term for mid-weighted noise. Implemented as pink with a broad lift around 500 Hz and the top rolled off. |
Gray and green are approximations of informal targets, and the page says so rather than presenting them as settled.
Controls
| Control | Effect |
|---|---|
| Low cut / High cut | 20 Hz–2 kHz and 500 Hz–20 kHz. Narrowing the band is the most effective way to make any color less fatiguing. |
| Waves | A slow amplitude swell, roughly the cadence of surf, with an adjustable 4–40 s period. |
| Volume | Ignored on iOS in Lock screen mode — see below. |
| Fade out after | 15 minutes to 8 hours, then a ramped stop. |
| Mono | Collapses to one correlated source. Off by default: independent left and right noise is wider and markedly less fatiguing on headphones. |
| Keep awake | Holds a Screen Wake Lock. Needs a secure context, so it works over HTTPS but not plain http://. |
| Lock screen | Switches playback path. Defaults on for touch devices. |
Space toggles playback; 1–7 select a color.
How it works
One seamlessly-looping stereo white buffer feeds seven parallel filter chains,
crossfaded on switch — instant color changes, and one buffer in memory instead
of seven. Pink is an IIRFilterNode running the standard four-tap
approximation; brown is a one-pole leaky integrator followed by a 22 Hz
high-pass to kill DC; violet is a one-sample differentiator; blue is pink
through that same differentiator.
Levels are measured, not guessed. On first play each chain is rendered
through an OfflineAudioContext, its RMS measured, and a makeup gain applied to
bring it to a common target. Unnormalized, brown and violet differ by tens of
decibels — enough to be a hazard at headphone volume when switching.
Two playback paths
iOS suspends an AudioContext when the tab backgrounds or the screen locks, but
keeps a media element playing. Those can't be reconciled in one signal path —
routing a media element back through createMediaElementSource puts it under the
AudioContext again — so the page carries both and lets you choose.
| Web Audio | Lock screen | |
|---|---|---|
| Survives screen lock | Android only | Both platforms |
| Lock-screen controls | No | Yes, with artwork |
| Live analyzer | Yes | Theoretical curve only |
| Changing color or band | Instant | Re-renders, about a second |
| Volume slider | Yes | Ignored on iOS |
| Default | Desktop | Touch devices |
HTMLMediaElement.volume is read-only on iOS, so in Lock screen mode there you
use the hardware buttons.
Making the loop inaudible
A looping media element is not sample accurate — browsers insert a small gap when one wraps, which on brown noise reads as a thump every time round. Lock screen mode therefore doesn't rely on gapless looping at all.
It renders 24 seconds of the selected color, discards a 3-second pre-roll so
every filter starts in steady state, and bakes a raised-sine envelope
sin(πt/L) into the result so the file fades to silence at both ends. Two
copies then play half a period apart. Because sin² + cos² = 1 they sum to
constant power — and each file's loop point now sits at zero amplitude, so
whatever the browser does when it wraps is inaudible: that copy contributes
nothing at that instant while the other is at full level.
Three details make that actually work:
- The offset comes from starting the second copy late, never from seeking.
Setting
currentTimeraces the element's load, and a seek that silently fails leaves both copies in phase — the envelope becomes 2·sin() rather than sin/cos and the output falls to zero once per loop. A phone speaker's low-frequency rolloff and AGC hide that completely; headphones expose it at once. Starting late makes the offset true by construction, and both elements then advance on the same audio clock, so they don't drift. - Each element gets its own
Bloband object URL. Sharing one resource between two media elements is contended on some browsers, and the second simply refuses to play. - There is no compressor in the render. Its gain reduction hasn't settled at sample zero, which left the head of the file louder than the tail. Peak is measured afterward and scaled only if it exceeds −6 dBFS, which preserves the RMS level-matching between colors instead of peak-normalizing them.
A health check runs every two seconds and measures what is actually happening
— whether the second copy is advancing, and whether it is genuinely half a
period behind. The measured offset appears in the Path readout as
media · 2 players · Δ12.0s. If the offset can't be held, the page falls back
to a single player with a crossfade-sealed loop and says so.
The wave LFO, when enabled, is fitted to an integer number of cycles per half loop, so both offset copies carry the same envelope value at any instant and it doesn't partially cancel.
Deploying
The whole app is public/index.html. Any static host will serve it.
npx wrangler deploy # Cloudflare Workers, per wrangler.toml
Check wrangler.toml against your wrangler version — the static-assets config
key has changed across releases.
Content-Security-Policy
Everything is inlined, so a default-deny policy will break the page silently. It needs three allowances:
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'
If 'unsafe-inline' is unacceptable, hash the script and style blocks instead —
the hashes need recomputing on every edit.
A note on file size
The three typefaces are embedded as base64 woff2, which is roughly 130 KB of the file. Base64 adds 33% to already-compressed woff2 and Brotli can't win much of it back. For a page opened once and left running that's irrelevant; on a high-traffic site, serve the fonts as separate cacheable files instead.
Browser support
Needs AudioContext, OfflineAudioContext and IIRFilterNode — Chrome 49+,
Firefox 50+, Safari 14.1+. Without IIRFilterNode the page falls back to
cruder biquad approximations rather than failing. Screen Wake Lock is optional
and the button disables itself where it's unavailable.
License
Public domain, under The Unlicense. Do whatever you like with it.
The embedded typefaces are third-party and keep their own licenses — OFL 1.1 for
Chivo and Public Sans, Apache 2.0 for Roboto Mono. See NOTICE; the
same notices ride along inside public/index.html beside the @font-face rules,
so they travel with the file wherever it's served.