HOW IT WAS MADE

Anatomy of a
room that plays itself

Velvet Static is a generative ambient instrument: nine WebAudio voices compose an endless piece, a silent conductor cycles it through four movements, and a single WebGL fragment shader turns its spectrum into breathing velvet. The room itself is dependency-free — no samples, no textures, no images; only the scroll layer around it borrows GSAP. Everything below is synthesized at runtime.

01

The concept

The seed was two words: velvet and static. Velvet is the low end — deep wine reds, soft folds, slow movement, warmth. Static is the high end — film grain, vinyl crackle, tape hiss, the shimmer of analog media. The site treats those as the two poles of one instrument: every sound the synth makes lives somewhere between a sub-bass fold of fabric and a dust-crackle of light, and the shader maps that same axis to color and texture.

Nothing is prerecorded and nothing loops exactly. Chord pads drift on a randomized ~24-second cycle, bells land on a minor pentatonic at random intervals, and the analyser feeding the shader guarantees the visuals never repeat either. Sound begins only after a deliberate gesture — the ENTER button — as browsers require, so the threshold is designed as a curtain rise: a velvet gradient panel lifts off the top of the viewport over 1.9 seconds (a single transform, GPU-cheap), the title and button ride up after it with staggered transition delays, the master gain swells over four seconds, and the frequency ring wakes. Below the room, the site continues: a four-movement score, the engine, the shader, a console manual and a colophon, all reachable from a sticky nav.

02

Palette & type

Five values, one mood. Dark scenes die when they are flat black, so the base is a plum-tinted ink and every shadow leans wine. The ember orange is the only loud color and it is rationed: the glow core, the ring, the active dot.

INK#0d060b
WINE#4a1026
ROSE#b4506b
EMBER#d98a3a
CREAM#f2dfc7

Type is Fraunces — a soft, slightly wonky serif with variable optical sizing (opsz 144 at display sizes gives the high-contrast, ink-trap look) — against IBM Plex Mono for anything that behaves like an instrument label: letterspaced, 10–12px, all caps. The italic word "Static" carries an animated gradient sheen, the one typographic special effect on the page.

Three moods (Wine, Ember, Nocturne) swap the shader's four palette uniforms and retune the synth's root note. The palette is never switched — it is lerped 3% per frame, so a mood change is a slow crossfade of the whole room, not a repaint.

03

Architecture

One canvas, one audio graph, one bridge between them. The analyser node is the hinge of the whole site:

9 voices ──> GainNode (master) ──> Compressor ──> AnalyserNode ──> speakers │ getByteFrequencyData() │ ┌────────────────────────┴─────────────┐ ▼ ▼ 4 scalar features 64-band ring bass · mid · treble · level (64×1 LUMINANCE texture) │ │ └──────────> WebGL uniforms <─────────┘ │ ▼ fragment shader (velvet smoke, breathing glow, frequency ring, grain)

Before entry — and any time audio is muted or unavailable — a fakeAnalyse() function feeds the same uniforms with slow layered sines, so the room breathes even in silence. The shader never knows the difference.

04

The synth — nine voices

  1. Drone ×3 — sawtooth oscillators at the root, detuned −5 / 0 / +4 cents, summed into one lowpass filter.
  2. Filter breath — a 0.038 Hz LFO (≈26-second cycle) sweeps the lowpass cutoff ±150 Hz, so the drone slowly opens and closes like breathing.
  3. Sub pulse — a sine two octaves down, its gain modulated by a second slow LFO.
  4. Chord pads ×2 — triangle swells over four-note minor-9 shapes, 7-second attacks, drifting every 21–28 s.
  5. Bells — paired sines (f and 2.01f for a slight bell inharmonicity) on a minor pentatonic, random pan, fed into a feedback delay.
  6. Vinyl crackle — a looped 2-second buffer of sparse random impulses through a 6.8 kHz lowpass.
  7. Tape hiss — looped white noise through a gentle bandpass at 4.2 kHz, mixed at 0.006.

The reverb has no samples

The convolver's impulse response is generated at load: 3.4 seconds of white noise with an exponential decay envelope. It is indistinguishable from a plate reverb at ambient settings and costs zero bytes of assets.

makeIR(seconds, decay) {
  const C = this.C, rate = C.sampleRate, len = Math.floor(rate * seconds);
  const buf = C.createBuffer(2, len, rate);
  for (let ch = 0; ch < 2; ch++) {
    const d = buf.getChannelData(ch);
    for (let i = 0; i < len; i++)
      d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
  }
  return buf;
}

main.js — generated impulse response

Generative scheduling

There is no timeline. Pads and bells reschedule themselves with randomized setTimeout chains — precise enough for ambient music, where nothing needs to land on a grid:

// voice 9 · sparse pentatonic bells into the feedback delay
const semi = PENTA[(Math.random() * PENTA.length) | 0] + 12;
const f = this.note(semi);          // root · 2^(semi/12)
g.gain.setValueAtTime(0.0001, now);
g.gain.exponentialRampToValueAtTime(vel, now + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, now + 2.6);
...
this.timers.push(setTimeout(ring, 1800 + Math.random() * 4200));

main.js — bell voice + self-rescheduling

Mood changes never restart oscillators. The root frequency glides via setTargetAtTime(root, now, 3.5) — a 3.5-second exponential slew, so switching from D minor to A minor sounds like the room retuning itself.

05

The score — four movements

Ambient music without any long-term shape turns into wallpaper. The fix is a silent conductor: a movement table cycles the room through four states — Drift (1:10), Bloom (1:25), Undertow (1:10), Ash (0:55) — then loops. A movement is not a recording; it is a set of tendencies:

const MOVEMENTS = [
  { num: 'I',  name: 'DRIFT',    dur: 70,
    bellMin: 3.4, bellMax: 7.6, padVol: 0.030, filterMul: 0.80, subMul: 1.00,
    warp: 0.85, ring: 0.85, grain: 0.85 },
  { num: 'II', name: 'BLOOM',    dur: 85,
    bellMin: 1.3, bellMax: 3.2, padVol: 0.046, filterMul: 1.40, subMul: 0.85,
    warp: 1.10, ring: 1.30, grain: 0.90 },
  // III UNDERTOW: sub ×1.55, warp ×1.45 · IV ASH: pads ×0.4, grain ×1.6
];

main.js — a movement is tendencies, not a tape

The left half of each row schedules the synth (bell interval range, pad swell volume, filter and sub multipliers); the right half morphs the shader (uWarpM, uRingM, uGrainM). Crucially, nothing is ever set — a movLive object lerps toward the current movement at 0.8% per frame (~10 s to settle), and both worlds read from it. Slow audio params (cutoff, sub weight, master) are glided by a 400 ms "conductor" interval using setTargetAtTime, so no AudioParam ever steps.

The score page section is live: the active movement card carries an ember border and a progress bar, and the HUD shows D MINOR · MVT II — BLOOM. The clock starts at entry, so the piece always opens with Drift — the room settles before it blooms.

The intensity fader

One visible fader (HUSH → ROOM → SWELL, also on ↑/↓) scales the same levers the conductor moves: bell density (interval × 1.65 − 0.9·i), pad and bell velocity, filter brightness, master gain, and the shader's warp/ring/grain drive. Score and fader multiply, so a hushed Bloom stays recognisably Bloom.

06

The shader — velvet smoke

The whole background is one fragment shader on a fullscreen triangle (three vertices, no quad seam). The velvet is two-pass domain-warped fBm — fractal noise fed its own output as coordinates, which is what turns flat noise into folded fabric:

// two-pass domain warp; audio level deepens the folds
float warp = 0.9 + uLevel * 0.85 + uBass * 0.45;
vec2 q = vec2(fbm(pp * 1.35 + vec2(0.0, t)),
              fbm(pp * 1.35 + vec2(5.2, t * 1.31)));
vec2 r = vec2(fbm(pp * 1.35 + warp * q + vec2(1.7, 9.2) + t * 0.62),
              fbm(pp * 1.35 + warp * q + vec2(8.3, 2.8) - t * 0.41));
float f = fbm(pp * 1.35 + warp * r);

// velvet layering: deep -> wine folds -> ember light -> cream sparks
vec3 col = uColDeep;
col = mix(col, uColWine,  smoothstep(0.12, 0.78, f));
col = mix(col, uColEmber, smoothstep(0.52, 0.97, r.y * f * 1.7) * (0.5 + uMid * 0.75));
col += uColCream * pow(clamp(q.x * f, 0.0, 1.0), 3.2) * (0.20 + uTreble * 0.85);

fragment shader — domain warp + color layering

The frequency ring

The 64 analyser bands are written into a 64×1 LUMINANCE texture every frame (texSubImage2D, ~64 bytes — free). The shader samples it by polar angle, so the ring's radius is literally the spectrum wrapped into a circle. The band layout is mirrored (ringData[i] = ringData[63−i]) so the circle closes seamlessly with the texture's REPEAT wrap:

float ang = atan(p.y, p.x) / 6.2831853 + 0.5;
float fr  = texture2D(uFreq, vec2(ang, 0.5)).r;
float ringR = 0.335 + fr * 0.12 + uBass * 0.028;
float sharp = exp(-pow((d - ringR) * 110.0, 2.0));  // crisp line
float halo  = exp(-pow((d - ringR) * 20.0,  2.0));  // soft bloom

fragment shader — spectrum as a circle

Two gaussians of different width give the line-plus-bloom look without any post-processing pass.

07

Audio → visual mapping

Band energies are averaged from getByteFrequencyData, shaped with pow(x·1.9, 1.4) for contrast, then smoothed 14% per frame so the visuals sit slightly behind the sound — that lag is what reads as "breathing" instead of "flickering".

SIGNALRANGEDRIVES
uBass25–160 Hzcentral glow radius & intensity, ring base radius, fold depth
uMid160 Hz–2 kHzember light inside the velvet folds
uTreble2–9 kHzcream sparks, film-grain amount
uLevelcompositedomain-warp strength, ring brightness
uFreq[64]full spectrumring radius per polar angle
uMousepointerslow parallax pull on the smoke (4% lerp)
uRippletoucha click plucks a pentatonic bell (pitch follows x) and expands a light ripple from the touch point
uWarpM / uRingM / uGrainMscorethe current movement's morph targets, lerped over ~10 s and scaled by the intensity fader

One calibration lesson: with real audio the drone parks the low FFT bins near 255, so a naive min(1, x·1.9) pegs the bass feature at 1.0 and every dynamic downstream flattens. The bass curve gets a softer knee (pow(min(1, x·1.12), 1.8)) and the ring texture uses a per-band tilt plus a soft-knee power curve instead of a hard clamp — loud bins keep their shape, and the ring stays sculptural instead of inflating into a blown-out circle.

08

Analog artifacts

The "static" half of the name is a stack of deliberately cheap tricks, each one frame-rate-friendly:

09

The page around the room

The first release of Velvet Static was only a threshold — an enter screen with nothing behind it. The enhancement kept the room untouched as a fixed, full-viewport canvas and built the site underneath it:

On imagery: no AI images were generated for this site, deliberately. Its entire identity is "every pixel is procedural" — the colophon says so in type — and a raster photograph anywhere on the page would falsify the claim the code makes. Where a picture was needed (palettes, signal path, movements), the design system draws it from live DOM instead.

10

Performance & care

11

Replicate it

The order matters less than the discipline of the bridge. If you want to build your own room:

←  RETURN TO THE ROOM