HOW IT WAS MADE
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.
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.
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.
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.
One canvas, one audio graph, one bridge between them. The analyser node is the hinge of the whole site:
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.
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
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.
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.
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.
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 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.
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".
| SIGNAL | RANGE | DRIVES |
|---|---|---|
| uBass | 25–160 Hz | central glow radius & intensity, ring base radius, fold depth |
| uMid | 160 Hz–2 kHz | ember light inside the velvet folds |
| uTreble | 2–9 kHz | cream sparks, film-grain amount |
| uLevel | composite | domain-warp strength, ring brightness |
| uFreq[64] | full spectrum | ring radius per polar angle |
| uMouse | pointer | slow parallax pull on the smoke (4% lerp) |
| uRipple | touch | a click plucks a pentatonic bell (pitch follows x) and expands a light ripple from the touch point |
| uWarpM / uRingM / uGrainM | score | the 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.
The "static" half of the name is a stack of deliberately cheap tricks, each one frame-rate-friendly:
hash() per pixel, re-seeded by time; amount rises with treble energy, so cymbal-range sound literally makes the image grainier.gl_FragCoord.y at 1.2% amplitude; subliminal, but the image feels like it is being broadcast.smoothstep darkening plus a CSS radial-gradient layer on top, doubling the depth at the corners.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:
position:fixed at z-0; the content column scrolls over it with a gradient from transparent to ink, so the shader's glow bleeds into the top of the first section.scrollY > 40; anchor links (Score / Engine / Shader / Console / Colophon) fade in after entry, and an IntersectionObserver with a narrow root-margin band marks the active section with an ember dot.[data-reveal] element rises 34px via GSAP ScrollTrigger (once:true, start 88%). If GSAP fails to load, nothing is hidden — CSS never sets the initial state, so the page degrades to fully visible. prefers-reduced-motion skips the whole block.e.target.closest('#room'), so selecting text in the anatomy no longer rings bells.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.
devicePixelRatio capped at 2; the shader is the only per-pixel cost and runs one pass, 5-octave fBm ×5 calls.webglcontextlost is caught (preventDefault + rebuild on restore); if WebGL is missing entirely, a CSS radial gradient stands in and the page still works.prefers-reduced-motion slows the simulation 3.3× and disables grain animation and entrance choreography.The order matters less than the discipline of the bridge. If you want to build your own room:
movLive object, and both the synth and the shader reading from it. Ten lines of conductor buy you an hour of non-wallpaper.