A noise generator and color space for noise profiles, addressed like CSS hsl() colors.
See it in the Live App.
HSL-Noise maps noise profiles to colors. The classical Colors of noise (white, brownian, pink, blue, violet) are far too few, and they are slopes of a power spectrum rather than colors β a one-dimensional family named by analogy. This app replaces that with a constructive space: dial in a color and hear exactly the band it describes, or dial in a band and read a precise color name off it.
It pairs the HSL color space with Gaussian distributions:
- Hue is the centre frequency of the bell curve, from red (0Β° = 20 Hz) to blue (240Β° = 20 kHz). Violets and magentas are omitted to signify that this is not a circle β audio frequencies don't perceptually mix the way colors do β and green (120Β°) sits in the middle as a truly neutral color, neither warm nor cold.
- Saturation decides the width of the bell. Fully desaturated colors become grey (white noise); a fully saturated color becomes an (almost) pure tone.
- Lightness cuts or expands the bell. Dark colors have the bell's skirts trimmed and sound dull or hollow; bright colors sound fuller, until it is all white noise again.
- Alpha replicates Velvet noise. Transparent colors are sparse β fuzzy or grainy.
- Comb filter is a ringing resonance, like the "twang" of raindrops hitting glass or metal.
HSL-Noise cannot perfectly recreate classical noises like Brownian or Pink, because they have straight slopes, not bell curves, but can be approximated.
Examples:
hsl(136, 70%, 50%) β green noise centred on 1 kHz
hsl(0, 80%, 50%) β a narrow red rumble around 20 Hz
hsl(240, 99%, 50%) β a near-pure 20 kHz tone
hsl(any, 0%, 50%) β grey: white noise
hsl(any, any, 100%) β white: full-band noise
hsl(any, any, 0%) β black: silence
hsla(136, 70%, 50%, 0.1) β 1 kHz noise, thinned to sparse crackles
hsla(any, any, any, 0) β fully gated: silence
Requires Node β₯ 22.12 and a browser with Web Audio AudioWorklet (current Chrome/Edge,
Firefox, Safari) served over http://localhost or HTTPS β the worklet is loaded as a
module, so file:// will not work.
npm install
npm run dev # dev server (http://localhost:4321)
npm run build # static output in dist/
npm run preview # serve the built outputdist/ is a fully static site, so any static host works.
- Start audio lazily creates the
AudioContextand worklet; the visuals run even when stopped, fed by an analyser that taps the worklet before the master fader. - Sliders: Hue (centre frequency), Saturation (bell width), Lightness (recut), Alpha (density) and Comb filter. Hue, saturation and lightness compute the mask; alpha gates the excitation that feeds it; comb is a post-synthesis character effect. The
βΊbuttons reset Lightness/Alpha/Comb. - Master is a post-analyser gain, so it never affects a meter or a plot.
- Options: Energy normalization (hold RMS constant across colors β off shows the raw spectrum), Auto-sweep hue (slowly pans the centre frequency), Stereo (independent left/right streams). See Β§4 and Β§6.
- Presets are grouped (Classical Noise Colors, Favourites, and the full set of CSS colors as noise). Sparse presets get a dashed outline, combed ones a glow.
- Randomize HSL / HSLA / All rolls fresh points in the space.
- Swatch +
hsl()/rgb()/hex strings are copyable; click one to copy it. - The URL always carries the profile (
?hue=β¦&sat=β¦&lig=β¦&alpha=β¦&comb=β¦), so a link reproduces the sound. - Panels are draggable/resizable on desktop (GridStack); the layout persists in
localStorage, and Reset layout restores the default. Below 1040 px the page falls back to a plain stacked column. - Three plots, described in Β§5: the theoretical mask, the live spectrum with the theory overlaid, and the waveform; the Stats panel reports the predicted and measured RMS side by side.
- Export renders the current color to an MP3 in memory.
| File | Role |
|---|---|
src/lib/constants.js |
Every tuning constant, in one place. |
src/lib/util.js |
Clamp, dB, master taper and number formatting helpers. |
src/lib/color-space.js |
The color space: HSL state β bin-indexed mask + readouts. Pure. |
src/lib/retention.js |
Alpha β excitation retention and the compensation cap. |
src/lib/naming.js |
Human-readable name for a triple (dark narrow red noise). |
src/lib/css.js |
The swatch, hsl()/rgb()/hex strings and the accent color. |
src/lib/comb.js |
Comb delay/feedback and its magnitude curve, for the live plot. |
src/lib/presets.js |
Preset groups and the default state. |
src/lib/url.js |
Query string β state, for shareable links. |
src/lib/audio.js |
Worklet engine: audio graph, message protocol, analyser taps. |
src/lib/export.js |
Offline render (OfflineAudioContext) and the MP3 worker wrapper. |
src/lib/mp3.worker.js |
lamejs MP3 encoder running off the main thread. |
src/lib/draw.js |
Canvas painters for the three visualisers. |
src/lib/index.js |
Barrel: the whole library behind one import. |
src/dsp/worklet.js |
AudioWorkletProcessor hsl-noise: synthesiser, stereo + comb. |
src/scripts/controller.js |
DOM β library glue and the animation loop. |
src/scripts/export-ui.js |
Export widget wiring: duration, offline render, MP3 worker, download. |
src/scripts/grid.js |
GridStack drag/resize layout, persisted in localStorage. |
src/components/*.astro |
The UI panels (color, spectra, waveform, stats, presets, header, footer). |
src/layouts/Base.astro |
Page shell, global CSS and the dashboard grid styles. |
src/pages/index.astro |
The single page: panel layout and script bootstrapping. |
The pure half of src/lib/ has no DOM and no AudioContext, so it runs in a browser, on the server, or in a test harness. src/dsp/worklet.js is shipped as-is (Vite ?url) and must stay self-contained: a worklet module cannot import the app's ES modules, so the gate/comb endpoints in it mirror src/lib/constants.js and must be kept in sync.
A color becomes a mask β a smooth curve saying how loud each frequency should be. Hue moves it, saturation narrows it, lightness crops or flattens it.
The mask is a magnitude
The system covers 20Hzβ20kHz. Whatever the shape pushes outside that window is cut off, so colors near either end are partly "out of gamut".
Bins outside the window are always zero, including when the lightness plateau pushes a tail past them. Hues near 20 Hz or 20 kHz therefore lose part of their bell to the edge; this is the deliberate analogue of the visible spectrum ending.
Hue is where the noise sits: red is a 20 Hz rumble, blue a 20 kHz hiss, and equal turns of the dial are equal musical intervals.
The mapping is logarithmic, so 1 octave = 24Β° and 1 semitone = 2Β°. The slider stops at 240Β° (red lowest, blue highest); the unused 240β360Β° arc is the one place the hue-wheel analogy breaks, because audio frequencies don't mix like colors.
Exact math
Saturation is how wide the band is. At 0 % every frequency plays at once (white noise); at 100 % it is squeezed as close to a single tone as the FFT allows. The slider is laid out so that the same position always means the same perceived width, whatever color you are on.
The bell is a Gaussian in log-frequency, so its width is a number of octaves,
Exact math
The profile is a Gaussian in log2-frequency (so it is symmetric in octaves, not in Hz):
(0.28 octaves at 20 Hz but 0.0003 at 20 kHz, at
The axis between flat and that floor is anchored to the critical band, not to the floor. The ear stops hearing a band as a tone once its width reaches the equivalent rectangular bandwidth, so define the boundary
(the Gaussian FWHM is
The slider is therefore linear in the perceptual width
with
-
$S=0 \Rightarrow \sigma = 64$ oct: flat to better than 0.1 dB across the whole band, i.e. white. -
$S=1 \Rightarrow \sigma = \sigma_{floor}$ : the purest tone available at that hue, at every hue. - For
$\sigma \gg \sigma_{ERB}$ the$+1$ is negligible, so$w \approx \ln\sigma$ and the axis is the old log-uniform one: wide bands behave exactly as before. - For
$\sigma \ll \sigma_{ERB}$ the logarithm flattens to$w \approx \sigma/\sigma_{ERB}$ , so the sub-ERB region is a bounded interval of the axis (at most$\ln 2$ , the top β 10 % of travel) at every hue. The pure-tone dead zone can never grow with$f_c$ . - Because
$w$ is linear in the travel$\tau$ and$\sigma_{ERB}$ is the one-ERB width, equal slider travel is (to first order) equal FWHM/ERB at every hue: β 8Γ ERB at$S=50%$ , β 1.2Γ at$S=80%$ , and the 1Γ ERB tone/noise boundary at β 83 % of the slider (within a couple of percent across the gamut). -
$\tau$ spends less slider travel crossing the region that is still audibly flat ($\sigma \gtrsim 25$ oct), so the bottom of the axis isn't dead either.
Reported per hue: Ο, FWHM (
Lightness reshapes the distribution, not the volume. Below halfway it cuts the bell's skirts, keeping only the tip β a tighter, hollower band at the same loudness β until black snaps to silence. Above halfway it stops being a bell and grows a flat top, pushing the tails out towards the edges of hearing until white is every frequency at once.
The axis is two genuinely different operations meeting at 50 %, and both are shape changes: normalization holds the RMS steady, so the master fader owns level. Down is a downward translation of the bell (a linear-amplitude cut): the skirts fall away and only the tip survives, which sounds narrower and hollower, not quieter. 0 % is exactly zero. Up is structural: split the bell at
Exact math
Up,
and, for
$$A(f) = \begin{cases} 1, & d \le W\[4pt] \exp!\left(-\dfrac{1}{2}\left(\dfrac{d-W}{\sigma}\right)^{!2}\right), & d > W \end{cases}$$
-
$W = 0$ at$L = 50%$ : the intact bell,$A = B$ . -
$W = \max(D_{left},D_{right})$ at$L = 100%$ : every in-band bin has$d \le W$ , so$A \equiv 1$ β white noise, hue and saturation inert. - The plateau edges are vertical cliffs at
$f_c,2^{\pm W}$ ; the tails only ever move outward, so a tail can be cropped but never folded back onto the plateau.
Down,
so
The name is computed from the triple β a texture prefix (alpha), a lightness prefix, a width class, a hue name, and
tonevsnoiseβ so it always describes what you are actually hearing.
- Texture (from alpha, omitted at β₯ 99.9 %):
velvet(β₯ 85 %),fuzzy(β₯ 70 %),grainy(β₯ 55 %),specks of(below). - Lightness:
dark(β€ 20 %),deep(β€ 40 %), none (40β60 %),bright(β₯ 60 %),pale(β₯ 80 %). - Width:
washed(< 20 %),wide(20β45 %), none (45β75 %),narrow(75β93 %),pure(β₯ 93 %). - Hue:
red,orange,yellow,green,cyan,blue(boundaries at hue 14/34/62/140/192/241). - Kind:
tonewhen saturation is β₯ 93 % and the hue can reach Q β₯ 8, otherwisenoise. - Special cases:
transparent noise β silenceat alpha β€ 0,black noise β silenceat lightness β€ 1 %,white noiseat lightness β₯ 98 %,grey noiseat saturation β€ 3 %.
Because the axis is perceptually uniform but the physical floor still binds at red,
pure red noise means "as pure as 20 Hz gets here" (Qmax β 2.2, below the Q β₯ 8 needed to
be called a tone), which is reported honestly by the Q / gamut readouts.
The fourth number is texture. At 100% you hear a continuous hiss; as you lower it the sound thins into single sharp clicks β rain on a window, or a Geiger counter β until 0% is silence.
Alpha sets the retention probability of the excitation β the random source feeding each synthesis frame β and the mask then shapes that excitation, so every click is a tiny burst of exactly the band you chose rather than a broadband tick. Retention is a bounded exponential in time with two hard endpoints: 0 % is absolute silence, 1 % is one raindrop every ten seconds, and 100 % lands on the temporal-fusion threshold (β 2400 hits/s), where discrete clicks fuse back into continuous noise. Sparsifying the source keeps the crackle inside the bell and keeps the closed-form loudness intact.
Exact math
$$P(\alpha) = p_{min}\left(\frac{p_{max}}{p_{min}}\right)^{!\alpha},\qquad p_{min} = \frac{0.1}{f_s},\qquad p_{max} = \frac{2400}{f_s},\qquad e(t) = \begin{cases}\pm1, & \text{with probability } P\[2pt] 0, & \text{otherwise}\end{cases}, \qquad B(t) = (h * e)(t)$$
where
-
$\alpha = 0$ is absolute silence (hard mute, not a fade to near-zero). -
$\alpha = 1%$ is one raindrop every ten seconds ($\approx 0.11$ hits/s at 48 kHz). -
$\alpha = 100%$ lands exactly on the temporal-fusion threshold ($2400$ hits/s). - In between, the log-linear map spends equal slider travel on equal ratios of hit rate, which is where the perceptual change is largest.
A unit impulse train of density
The comb is a resonant echo: it repeats the sound a moment later, slightly quieter, over and over, so frequencies whose period matches the echo survive and ring. Its pitch follows the color, and the slider sets how long it rings β drop the noise on metal.
A resonance needs somewhere for its energy to come from, so the comb's fundamental tracks the color centre
Exact math
A per-channel feedback comb applied to the finished stream,
with delay
so its modes sit at
β longer for low modes at the same loss, like a real plate.
To keep the comb a character control, the comb input is dosed by
The dose is folded into the frame gain, so the pre-comb RMS identity is preserved and the
predicted loudness stays valid. It is not exact β the comb is a time-varying resonator, and
on the narrowest, deepest colors the measured RMS can sit up to
To make one frame of noise: give every frequency the loudness the mask asks for, but a random starting phase, add them up, and fade the slice in and out so slices can be stacked without clicks. Random phase is what makes it noise instead of a chord
A magnitude spectrum is filled from the mask with uniformly random phases, Hermitian-mirrored, inverse-FFT'd, windowed and overlap-added at 75 % overlap (
Exact math
$$\mathrm{var}{frame} = \frac{2}{N^2}\sum{k=1}^{N/2-1} A_k^2 \qquad\Rightarrow\qquad \mathrm{RMS} = \frac{g}{N}\sqrt{3\sum_k A_k^2}$$
The app shows "rms predicted" next to "rms measured" as a live assertion β they should agree to a fraction of a dB.
Whenever a control moves, the main thread sends the mask to the audio thread; the audio thread replies once with its FFT size, so the UI never hard-codes it.
Main thread β worklet, on any parameter change (rAF-throttled):
port.postMessage({ type:'mask', mask:Float32Array(N/2+1).buffer, gain, alpha, stereo, comb, combFc }, [buffer])Worklet β main thread, once at construction: { type:'config', N, HOP, sampleRate }. The
UI adopts N from that message, so changing the FFT size is a one-line edit in one file.
Each FFT bin takes the tallest point of the bell across the sliver of frequency it owns, not the value at its centre, so a very narrow bell can't slip between bins and vanish.
Since the bell is monotone in exp per bin: evaluate at the point of the bin's span nearest to
Alpha thins the raw clicks before the color shapes them, so each click is a burst of your band, not a full-spectrum tick.
For
The app predicts a frame's loudness from its shape and scales it, so the three color axes only ever change the distribution, never the volume β a one-bin tone would otherwise be about 33 dB quieter than white noise.
Normalization targets a constant RMS for every color, so hue, saturation and lightness are purely shape controls; the master fader owns level. The gain is capped at the all-energy-in-one-bin case (β +33 dB), so a very dark, near-single-tone tip is not boosted without bound β the last stretch into black fades through that cap instead. With normalization off the gain is fixed, so the level follows raw mask energy β the "before" demo.
Exact math
capped at
Master volume is a post-analyser GainNode (
worklet βββ¬ββ splitter(2) ββ analyser ββ zeroGain ββ destination (silent tap: meters + live FFT, ch 0)
βββ fadeGain βββββ masterGain ββ destination (audible, stereo passthrough)
so master volume and the start/stop fade can never influence any visualiser or readout.
The splitter exists because an AnalyserNode down-mixes its input to mono: with stereo on,
the two decorrelated streams would read β 3 dB below the predicted per-channel RMS (Β§6). Tapping
a single channel keeps the predicted-vs-measured assertion honest in both modes. (The zero-gain
sink exists because an AnalyserNode with no path to the destination is not guaranteed to be
pulled.)
The top canvas draws the shape about to be synthesised; the middle draws the spectrum measured from the audio with that shape overlaid. The third canvas draws the raw waveform.
Both spectral canvases rasterise the same bin arrays that are posted to the worklet, so the dots always sit on the curve. The dB ghost is clamped to the plot floor so hard edges draw as cliffs, and its absolute offset is auto-aligned to the measured curve near the mask peak, because a broadband mask and a coherent tone relate to analyser bin levels differently (PSD versus line spectrum). Only the shape comparison is meaningful, so only the shape is drawn. When the comb is engaged the live ghost switches to
Gaussian in log-frequency, not in Hz.
A band should sound like "the same kind of band" wherever it sits, and its width should have a musical unit.
Symmetric in octaves, so equal slider steps are equal musical intervals.
Offset in linear amplitude, not power or dB.
the cut has to reach exactly zero at black, which a dB offset never can.
power-domain offsetting makes the pedestal appear even more abruptly (
Saturation axis is perceptually uniform, anchored to the critical band.
the slider should sound like it is doing something at every position, on every hue.
The first experiment version used one hue-independent
Alternatives rejected
(a) Raising
Lightness up inserts a plateau; down cuts the skirts.
growing a flat top makes white exactly white by construction, while cutting the skirts keeps only the tip of the bell β a tighter, hollower band rather than a quieter one.
Above 50 % the operation is structural β split the bell at
Normalization targets a constant RMS; lightness is a distribution axis.
the fader owns level, so the color axes are free to be purely about shape.
An earlier version scaled the target by
Plateau width is calibrated to the gamut, not to the bell energy.
the tails should stop at the edge of hearing, not at an arbitrary loudness.
Spectral overlap-add synthesis over filtered white noise.
it can draw any shape exactly, and its loudness is known in closed form.
biquad cascades cannot realise an arbitrary mask, cannot reach very high Q at low frequencies without numerical trouble, and give no closed-form level; an oscillator bank of ~1000 partials is too expensive and sounds metallic when desaturated. Spectral OLA realises the drawn mask by construction, makes lightness clipping trivial, and yields the exact RMS identity that powers the normalizer. Costs: ~85 ms of parameter smear (one window), and a hard floor on achievable bandwidth.
a good compromise between how finely it can shape frequency and how quickly it responds.
bin 11.7 Hz, parameter update every 21 ms, crossfade 85 ms, ~47 frames/s.
Random phase per frame.
coherent phases would sound like a periodic buzz, not noise.
the price is that a maximum-saturation "tone" is really very narrowband noise with a slow warble of order
Stereo by independent streams, default on.
one mask, two independent noise realisations, so the picture and the sound stay in agreement at either width.
With stereo on, each hop synthesises two frames from the same mask and gain but with independent random phases (and, for
Peak-hold per bin over centre sampling.
what you see drawn is what gets synthesised.
centre sampling lets a sub-bin needle land between bins, which both drops the level and β worse β lets a moderate lightness cut delete a tone the plot still shows.
Alpha acts on the excitation, before the mask.
thin the clicks first, color them second, and everything stays inside the band.
Gating the finished signal convolves the output with the gate's wideband spectrum and sprays energy across every bin; gating the mask or pre-windowed frames changes the RMS identity and pumps with the window. Sparsifying the source and filtering it by
A strictly bounded exponential retention curve.
the sparsity slider gets real endpoints instead of a guessed power law.
A +40 dB compensation cap.
rare clicks get boosted a lot, so the boost is capped to avoid clipping.
The comb's fundamental follows the color; its input is level-dosed.
a pitch that tracked a fixed frequency would leave most colors unexcited, and a resonance that changed the level would not be a color control.
A feedback comb only amplifies what is already there, so a fixed delay would ring only for the handful of colors whose band happens to cover a mode. Tying
| Check | Expected | Why it holds |
|---|---|---|
|
|
nothing changes |
|
|
|
silence |
|
|
|
white noise |
|
| no audible step | plateau grows continuously from |
|
| plateau at |
further tail stops at 20 Hz/20 kHz |
|
|
|
every position audibly different | linear in |
| same |
same FWHM/ERB |
|
| normalization on, sweep S | constant loudness | |
| normalization on, sweep |
shape changes, level held | constant target |
|
|
tightens to the tip, not quieter | skirt cut; RMS held until the one-bin cap |
| predicted vs measured RMS | agree within ~0.3 dB (above the +40 dB cap) | exact OLA power identity |
| bin dots vs drawn curve | dots on the curve | both rasterised from maskArr
|
| master volume | meters and both spectra unchanged | master is after the analyser tap |
| identical to the dense engine | random-phase path kept, no gate | |
| texture thins, loudness held | excitation rescaled |
|
| absolute silence | hard mute, not a fade | |
| one event every ten seconds |
|
|
| sparse alpha | crackle stays inside the bell | impulse train is filtered by |
| level finally falls, no clip | relative compensation capped at |
|
| alpha sweep at fixed color | all color readouts constant | alpha never touches maskArr
|
| stereo off | both channels bit-identical | one frame duplicated to every output channel |
| stereo on | channels decorrelated, level held | independent phases/excitations from the same mask and gain |
| predicted vs measured RMS, stereo on | still agree within ~0.3 dB | analyser taps one channel, not the mono down-mix |
| comb = 0 | exact bypass | no comb path when feedback is zero |
| comb on | band rings, level held | input dosed by |
| comb pitch | follows |
|
| comb with sparse alpha | each click becomes a ping | feedback comb after the colored impulse |
| comb at deep red / high blue | ring weakens | mode drifts off the excited band (out of gamut) |
| predicted vs measured RMS, comb on | agree within ~2 dB | expected-gain dose; not exact for a resonator |
| Constant | Value | Meaning |
|---|---|---|
FMIN / FMAX
|
20 / 20000 Hz | gamut; the "visible spectrum" |
HUE_MAX |
240 | red β blue span; 24Β° per octave |
SIG_FLAT |
64 oct | Ο at S = 0 (white) |
FLOOR_BINS |
3 | narrowest bell: |
ERB_C, ERB_K
|
24.7 Hz, 0.1079 | ERB(f) = ERB_C + ERB_KΒ·f; anchors the axis at 1 ERB |
WARP_W, WARP_Q
|
0.45, 3.5 | bottom-end ease-in of the perceptual saturation axis |
R_TARGET |
0.12 | reference RMS, β β18.4 dBFS |
N, HOP
|
4096, 1024 | FFT size and 75 % overlap hop (worklet is authoritative) |
GATE_HITS_MIN |
0.1 /s | retention at Ξ± = 0βΊ (one raindrop per ten seconds) |
GATE_HITS_MAX |
2400 /s | retention at Ξ± = 1β» (temporal-fusion threshold) |
GATE_COMP_CAP |
100 (+40 dB) | max relative excitation boost; keeps sparse clicks safe |
COMB_FB_MAX |
0.98 | comb feedback at slider = 100 % |
COMB_FREQ_MIN |
100 Hz | lowest comb fundamental (longest delay / ring) |
COMB_FREQ_MAX |
5000 Hz | highest comb fundamental (shortest ring) |
The upper lightness half adds no constant: the plateau half-width is derived per frame,
Constants that are not tuning knobs live alongside these: DEC (the 3 decades of the
gamut), DB_BOT (β100 dBFS, the bottom of every dB plot), TWO_PI, FREQ_TICKS
(the log-axis gridlines), and DEFAULT_N / DEFAULT_HOP (fallbacks until the worklet
reports its own).
- Violet arc: extend hue past 240Β° into violets and magentas.
- Stereo width: a mid/side control between the mono and fully decorrelated extremes.
