Skip to content
MitPittPublic

Latest commit

Β 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

HSL-Noise

A noise generator and color space for noise profiles, addressed like CSS hsl() colors.

See it in the Live App.

screenshot

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

Quick start

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 output

dist/ is a fully static site, so any static host works.

Using it

  • Start audio lazily creates the AudioContext and 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.

1. Files

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.


2. The color space

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 $A(f) \in [0,1]$ sampled on the synthesis FFT bin grid, $f_k = k,\Delta f$ with $\Delta f = f_s/N$. It is an amplitude, not a power β€” that choice matters for how lightness is applied.

2.1 Gamut

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.

2.2 Hue β†’ centre frequency

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

$$f_c(H) = 20 \cdot 1000^{,H/240}\ \text{Hz},\qquad H \in [0,240]$$

2.3 Saturation β†’ bell width

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, $\sigma$. The perceptual catch is that your ear does not count octaves: a band sounds like a tone until it is about one critical band (ERB) wide, and like noise above that. The critical band is roughly a fixed 11 % of frequency above ~1 kHz, but a much larger share lower down (β‰ˆ 37 % at 100 Hz, and wider than the frequency itself at 20 Hz). An axis that is linear in $\sigma$ therefore spends almost all of the blue end below one critical band, because the FFT floor shrinks far faster than the critical band as frequency rises β€” which is exactly why "high saturation all sounds like a pure tone". The fix is to make the slider linear in how many critical bands wide the bell is, via the perceptual width $w = \ln(1 + \sigma/\sigma_{ERB})$: this is the old log scale while the band is wider than the critical band, and flattens below it. The same slider position is then the same number of critical bands at every hue (β‰ˆ 8Γ— ERB at 50 %, β‰ˆ 1.2Γ— at 80 %, and the 1Γ— ERB tone/noise boundary at β‰ˆ 83 % of the slider, within a couple of percent across the gamut), while the physical floor is still reached at exactly 100 %.

Exact math

The profile is a Gaussian in log2-frequency (so it is symmetric in octaves, not in Hz):

$$B(f) = \exp!\left(-\frac{1}{2}\left(\frac{\log_2(f/f_c)}{\sigma}\right)^{!2}\right),\qquad \sigma \ \text{in octaves}$$

$\sigma \to \infty$ is white noise; $\sigma \to 0$ is a sine. The narrowest realisable bell is bounded by the FFT bin width in Hz, while $\sigma$ is specified in octaves, so the limit is hue-dependent:

$$\sigma_{floor}(f_c) = \frac{\Delta f}{3,f_c\ln 2}$$

(0.28 octaves at 20 Hz but 0.0003 at 20 kHz, at $f_s$ = 48 kHz, $N$ = 4096.)

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

$$ERB(f) = 24.7\left(\frac{4.37,f}{1000}+1\right)\ \text{Hz},\qquad \sigma_{ERB}(f_c) = \frac{2}{2.3548,\ln 2}\operatorname{asinh}!\left(\frac{ERB(f_c)}{2f_c}\right)$$

(the Gaussian FWHM is $2.3548,\sigma$ octaves; solving $f_c(2^{k/2}-2^{-k/2})=ERB$ for $k=2.3548,\sigma$ gives that expression). $\sigma_{ERB}$ is 0.77 oct at 20 Hz but only 0.067 oct at high frequencies, while $\sigma_{floor}$ falls to $3\times10^{-4}$.

The slider is therefore linear in the perceptual width $w$, not in $\log\sigma$:

$$w(\sigma) = \ln!\left(1+\frac{\sigma}{\sigma_{ERB}(f_c)}\right),\qquad w = w_{flat} + \tau(S)\left(w_{floor}-w_{flat}\right),\qquad \sigma(S,f_c) = \sigma_{ERB}\left(e^{w}-1\right)$$

with $w_{flat}=w(\sigma_{flat})$, $w_{floor}=w(\sigma_{floor})$, $\sigma_{flat}=64$ oct, and the mild ease-in

$$\tau(S) = 0.45\left(1-(1-S)^{3.5}\right) + 0.55,S$$

  • $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 ($2.3548,\sigma$ octaves), Q ($f_c/\text{FWHM}{Hz}$), FWHM/ERB, and sat. gamut = $\ln(\sigma{flat}/\sigma_{floor}(f_c)) / \ln(\sigma_{flat}/\sigma_{floor}(20\text{kHz}))$ β€” the share of the best-case width range the FFT can reach at this hue (44 % at red, 100 % at blue). That is a physical limit, now independent of the perceptual slider.

2.4 Lightness β†’ plateau (up) / translation (down)

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 $f_c$, insert a rectangular plateau, and push both intact tails outward in octaves at the same rate until the further tail lands on 20 Hz or 20 kHz; the nearer tail is simply cropped. The plateau width is tied to the gamut, so $L=100%$ is exactly white noise by construction.

Exact math

Up, $L \in [50%, 100%]$. With $t = 2L-1$:

$$D_{left} = \log_2(f_c/20),\qquad D_{right} = \log_2(20000/f_c),\qquad W = t,\max(D_{left}, D_{right})$$

and, for $d = |\log_2(f/f_c)|$,

$$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$ is the plateau half-width in octaves. At $W = \max(D_{left},D_{right})$ the further tail has been pushed exactly onto its boundary (20 Hz or 20 kHz) and expansion stops; the nearer tail crossed its boundary earlier and is simply cropped. There is no vertical offset and no ceiling clip at all β€” the OLA normalizer absorbs the added in-band energy, so loudness is unchanged.

  • $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, $L \in [0%, 50%]$. A downward translation,

$$A(f) = \max\big(B(f) + (2L-1),\ 0\big)$$

so $L = 0 \Rightarrow \mathrm{off} = -1 \Rightarrow A \equiv 0$: silence, hue and saturation inert. The normalizer targets a constant RMS (Β§4), so the cut is heard as a change of distribution; only at the very bottom, where the surviving tip's in-band energy drops below one bin, does the anti-clip gain cap let the level fade into black.

2.5 Naming

The name is computed from the triple β€” a texture prefix (alpha), a lightness prefix, a width class, a hue name, and tone vs noise β€” 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: tone when saturation is β‰₯ 93 % and the hue can reach Q β‰₯ 8, otherwise noise.
  • Special cases: transparent noise β€” silence at alpha ≀ 0, black noise β€” silence at lightness ≀ 1 %, white noise at lightness β‰₯ 98 %, grey noise at 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.

2.6 Alpha β†’ sparsity

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 $h = \mathrm{IDFT}(A)$ is the mask's impulse response. The endpoints are quoted in hits per second and divided by the live $f_s$, so they are exact on 44.1 kHz and 48 kHz alike. Because the impulse train is sparsified before the mask, every crackle is a band-limited burst that lives inside the bell and follows its slopes; no energy is splattered outside the mask. At $\alpha = 1$ the excitation is dense and the output is ordinary continuous noise; as $\alpha \to 0$ it thins to isolated impulses.

  • $\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 $P$ has variance $P$, so the frame is rescaled by $1/\sqrt{N P}$ to restore the unit-variance excitation the closed form assumes. That relative boost is capped at $+40$ dB ($1/\sqrt{P} \le 100$, i.e. $P \ge 10^{-4}$), so below $\alpha \approx 38%$ the texture thins faster than the level can compensate and the output fades by up to $\approx 17$ dB as $\alpha \to 0$ β€” a deliberate guard against an ultra-rare click leaving $[-1, 1]$. Above the cap the normalizer's RMS identity is preserved, so loudness holds and hue, saturation and lightness keep their meaning at every $\alpha$.

2.7 Comb β†’ metal ring

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 $f_c$: a mode always sits inside the bell, whatever color you chose. The slider scales the feedback, i.e. how many times the echo repeats before it dies. Because the bell's skirts fall off, colors near the very bottom or top of the gamut push the mode off the excited band and the ring weakens β€” the same "out of gamut" honesty as everywhere else. For $\alpha &lt; 1$ each sparse click rings, which is exactly the raindrop on metal; for dense noise the whole band takes on the resonance. Stereo runs two independent streams through identical combs.

Exact math

A per-channel feedback comb applied to the finished stream,

$$y[n] = x[n] + g,y[n-D],\qquad g = g_{max},\text{comb},\quad g_{max} = 0.98$$

with delay $D = \mathrm{round}(f_s/f_0)$ samples and fundamental

$$f_0 = \mathrm{clamp}(f_c,\ 100\ \text{Hz},\ 5000\ \text{Hz})$$

so its modes sit at $f_0, 2f_0, 3f_0,\dots$ with peak gain $1/(1-g)$ and nulls between. The decay time constant is

$$\tau = \frac{D}{f_s,\ln(1/g)}$$

β€” longer for low modes at the same loss, like a real plate. $0$ is a hard bypass.

To keep the comb a character control, the comb input is dosed by $1/G$, where $G$ is the comb's expected RMS gain for the current mask (the PSD goes as $A_k^2$):

$$G^2 = \frac{\sum_k A_k^2,|H_k|^2}{\sum_k A_k^2},\qquad |H_k|^2 = \frac{1}{1 - 2g\cos(2\pi k D/N) + g^2}$$

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 $\approx 2$ dB above the prediction β€” but it is close everywhere the ear can tell.


3. The engine

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 ($N = 4096$, hop $1024$, Hann). Because Hann at hop $N/4$ sums to a constant and independent random-phase frames add in power, the output level has a closed form β€” so the app can predict the loudness and normalise it without an envelope follower that might pump.

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.

3.1 Message protocol

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.

3.2 Bin evaluation is peak-hold

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 $|\log f - \log f_c|$, that maximum is exact and costs one exp per bin: evaluate at the point of the bin's span nearest to $f_c$. This keeps the mask peak exactly 1 (so the lightness cut behaves as drawn), stops a plateau edge from being nicked by a sub-bin notch, and guarantees a sub-bin bell never falls between bins.

3.3 The sparsity gate lives on the excitation

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 $\alpha &lt; 1$ the frame is built the other way round from the dense path: fill the time buffer with a random $\pm1$ impulse train, forward-FFT it, multiply every bin by the mask (a zero-phase filter), inverse-FFT. This is circular convolution $h * e$ with $h = \mathrm{IDFT}(A)$, so the crackle is band-limited and honours the bell's slopes. A post-OLA gate would instead multiply the signal by a wideband square wave and splatter energy across every bin β€” the defect this avoids. Two fast paths remain: $\alpha \ge 1$ keeps the dense random-phase synthesis, and $\alpha \le 0$ hard-mutes the output.


4. Level: energy normalization and master

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

$$g = \frac{R \cdot N}{\sqrt{3\sum_k A_k^2}},\qquad R = 0.12\ (\approx -18.4\ \text{dBFS})$$

capped at $g_{flat}\sqrt{K_{band}}$ (the one-bin case). Concretely, the cap binds once $\sum_k A_k^2 &lt; 1$, i.e. once a cut tip carries less energy than a single unit bin; below that the output RMS is $R\sqrt{\sum_k A_k^2}$ and fades smoothly to silence at $L = 0$. Off: $g = g_{flat}$, the gain that makes a flat in-band mask hit $R$.

Master volume is a post-analyser GainNode ($v^2$ law). The graph is deliberately split:

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.)


5. Visualisers

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 $A_k|H_k|$ β€” the shape the comb actually produces β€” so the overlay keeps agreeing with the measurement, while the top canvas stays the pre-comb mask the color asked for.


6. Design decisions

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 ($\sqrt{\text{off}}$ in amplitude); dB-domain offsetting can never reach zero, so black would not be silence. Linear amplitude is also what makes "a horizontal line cutting the bell" literally true on the linear plot.

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 $\sigma(S)$ curve, so at red everything above S β‰ˆ 47 % was bit-identical β€” half the slider and most of the color names were redundant. A second version mapped the gamut log-linearly per hue, which fixed red but moved the dead zone to the top of the high hues: because $\sigma_{floor}$ falls far faster than the ERB as $f_c$ rises, most of the blue end sat below the tone/noise boundary and saturation only came alive below β‰ˆ 65 %. The fix is to interpolate $w(\sigma)=\ln(1+\sigma/\sigma_{ERB})$ rather than $\log\sigma$ β€” the old log-uniform axis wherever the bell is wider than the critical band, flattening below it β€” so the sub-ERB region is a fixed sliver of the internal axis (≀ ln 2, the top β‰ˆ 10 % of travel, i.e. above β‰ˆ 83 % of the slider) at every hue and equal travel is equal FWHM/ERB. The cost remains that bandwidth in Hz grows roughly as $\sqrt{f_c}$ instead of being constant-Q, which is closer to ERB/critical-band behaviour than constant-Q was.

Alternatives rejected

(a) Raising $N$ until the floor disappears β€” a 20 Hz sine would need $N \approx 3.4$ M, i.e. 70 s of latency. (b) A single hue-independent perceptual curve, which cannot honour the hue-dependent floor. (c) A log-linear gamut map per hue, which is what moved the dead zone to the top of the high hues.

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 $f_c$, grow a rectangle, move the intact tails outward; there is no vertical translation to calibrate, so the width is tied directly to the gamut, $W = t\cdot\max(D_{left},D_{right})$. This keeps $L=50%$ exactly the bell and crops the nearer tail only when physics demands it. Below 50 % the bell is translated down in linear amplitude, so the skirts fall below zero and only the tip survives; because normalization now targets a constant RMS (Β§4), this reads as a distribution change, not a fade. (The βˆ’6 dB / βˆ’20 dB figures are now the mask's own cut depths, not the output level; the output stays level until the anti-clip cap takes over near black.)

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 $2L$ below 50 %, which made darkening feel like a volume knob and swamped the (interesting) profile change. A constant target lets the cut be heard as a change of distribution, and the gain cap at the one-bin case handles the approach to zero instead of an explicit fade that would fight the shape.

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.

$W_{max}$ is the larger octave distance from $f_c$ to 20 Hz / 20 kHz, so at full lightness the further tail always finishes exactly on its boundary and no slider position attempts to push a tail into a region that does not exist. The nearer tail is cropped, which is the honest out-of-gamut consequence.

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.

$N = 4096$, hop $N/4$.

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. $N=8192$ halves the width floor (better red-hue gamut) at the cost of 170 ms smear; since the UI reads $N$ from the worklet's config message and the saturation axis re-derives its endpoints from $\Delta f$, changing it needs no other edits.

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 $f_s/N$ β€” which is why it is an "almost" pure sine.

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 $\alpha&lt;1$, independent sparse excitations), so the left and right channels are genuinely decorrelated. The mask β€” and therefore the whole color space β€” is untouched, and the per-channel RMS identity still holds, so switching width changes no level. Off, a single stream is duplicated to both channels, which is the original mono behaviour at half the synthesis cost. Measured spectrum and level come from one channel rather than a mono down-mix, because a down-mix of two decorrelated streams would sit β‰ˆ 3 dB low and drift from the predicted RMS.

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$ keeps every crackle inside the bell and leaves the OLA machinery untouched.

A strictly bounded exponential retention curve.

the sparsity slider gets real endpoints instead of a guessed power law.

$P = p_{min}(p_{max}/p_{min})^{\alpha}$ gives $\alpha = 0$ exactly silence and $\alpha = 1$ exactly the temporal-fusion threshold, with equal travel meaning equal ratio of hit rate. Because the endpoints are quoted per second and divided by the live $f_s$, the same slider position means the same hit rate on every device.

A +40 dB compensation cap.

rare clicks get boosted a lot, so the boost is capped to avoid clipping.

$1/\sqrt{P}$ diverges as $\alpha \to 0$, so an isolated survivor could clip. Capping the multiplier at 100 keeps the graph safe and turns the sub-38 % region into a real (if gradual, β‰ˆ 17 dB) fade to silence rather than a clipping hazard.

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 $f_0$ to $f_c$ guarantees a mode inside the bell at every hue, and the ring range is then clamped to 100 Hz–5 kHz so the delay stays practical and the top of the gamut does not collapse to a 2-sample loop. The expected RMS gain for the current mask is computed in closed form and dosed into the frame gain, so the comb changes character, not loudness. (An earlier idea β€” the comb before the mask, on the excitation β€” would have kept the ring broadband and let the color select it, but the dense path has no continuous excitation stream to filter; post-synthesis is the only place the state can ring across frames.)


7. Sanity checks

Check Expected Why it holds
$S=0$, sweep hue nothing changes $\sigma = 64$ oct β‡’ flat to < 0.1 dB over 10 octaves
$L=0$, sweep hue/sat silence $\mathrm{off}=-1$ β‡’ $A\equiv 0$
$L=100%$, sweep hue/sat white noise $W=\max(D_{left},D_{right})$ β‡’ $d\le W$ for every bin
$L: 50 \to 51%$ no audible step plateau grows continuously from $W=0$
plateau at $L=100%$ further tail stops at 20 Hz/20 kHz $W$ capped by $\max(D_{left},D_{right})$
$S: 0 \to 100%$ at any hue every position audibly different linear in $w=\ln(1+\sigma/\sigma_{ERB})$; sub-ERB share ≀ ln 2
same $S$ at any hue same FWHM/ERB $w$ normalised by $\sigma_{ERB}(f_c)$
normalization on, sweep S constant loudness $g \propto 1/\sqrt{\sum A_k^2}$
normalization on, sweep $L&lt;50$ shape changes, level held constant target $R$; caps only once $\sum A_k^2&lt;1$
$L: 50 \to 0$, light band 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
$\alpha=1$ identical to the dense engine random-phase path kept, no gate
$\alpha: 1 \to 0.5$ texture thins, loudness held excitation rescaled $1/\sqrt{NP}$; RMS identity holds
$\alpha = 0$ absolute silence hard mute, not a fade
$\alpha = 1%$ one event every ten seconds $P = p_{min}(p_{max}/p_{min})^{0.01} \approx 0.11$ hits/s
sparse alpha crackle stays inside the bell impulse train is filtered by $A$, not gated post-OLA
$\alpha \lesssim 38%$ level finally falls, no clip relative compensation capped at $+40$ dB
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 $1/G$, the mask-weighted comb RMS gain
comb pitch follows $f_c$, clamped 100 Hz–5 kHz $D=\mathrm{round}(f_s/f_0)$ puts a mode inside the bell
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

8. Tuning constants

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: $\sigma_{floor} = \Delta f/(3 f_c \ln 2)$
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, $W = (2L-1)\max(D_{left}, D_{right})$, and expansion simply stops at $W_{max}$.

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).

9. Possible extensions

  • Violet arc: extend hue past 240Β° into violets and magentas.
  • Stereo width: a mid/side control between the mono and fully decorrelated extremes.

Releases

Packages

Contributors

Languages