Let the riddim wobble drive the liquid drop and bring back its texture

Two new features follow the bass line itself: its envelope against the last moment,
which rises and falls with every wub, and how open its filter is (spectral centroid
of the bass and mids). Each wub swells the drop, and as the filter opens its surface
crinkles and spikes like before. Every layer keeps a floor, so the drop never goes
still and nothing snaps in from nothing; riddim gets its soft terraces back. Kicks
still make it jump.
This commit is contained in:
nvrl committed 2026-11-01 14:54:43 +01:00
1 parent cccdf1d11c
commit b09a7370ca
6 files changed
+101 -38

No files matched your search

+15 -4
View File
@@ -9,7 +9,7 @@ import type { Look } from "./style";
const FOV = (30 * Math.PI) / 180;
/// The canvas is this much larger than the art slot, so the liquid can spill out.
export const SPILL = 1.8;
export const SPILL = 2;
const DIST = SPILL / Math.tan(FOV / 2);
const MORPH_IN = 1.4; // s from flat artwork to liquid
// The jump: a spring that each prominent kick or bass hit kicks upwards. Hits are
@@ -21,6 +21,9 @@ const STRETCH = 0.08; // stretch per unit of speed: long while flying, squashed
const PUMP = 0.7; // size kick of a snare hit
const PUMP_STIFF = 160;
const PUMP_DAMPING = 12;
const THROB = 0.09; // size swing with the bass line's envelope
const SNARE_ATTACK = 0.03; // s; spikes grow in rather than pop
const SNARE_RELEASE = 0.15; // s
const TEXTURE_SIZE = 512;
const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR …
const SLOW_FOR = 2; // … seconds step the quality down
@@ -28,8 +31,8 @@ const SLOW_FOR = 2; // … seconds step the quality down
const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]];
const UNIFORMS = [
"uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uDrop",
"uGain", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer",
"uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uBass", "uGrowl",
"uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer",
"uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat",
] as const;
@@ -56,6 +59,7 @@ export class LiquidBlob {
/// Springs, position and speed: the jump (upwards) and the pump (size).
private jump = [0, 0];
private pump = [0, 0];
private snare = 0;
/// Null where WebGL2 is not available.
static create(touch: boolean): LiquidBlob | null {
@@ -189,7 +193,13 @@ export class LiquidBlob {
gl.uniform1f(u.uKick, f.kick);
gl.uniform1f(u.uKickAge, f.kickAge);
gl.uniform1f(u.uDrop, f.drop);
gl.uniform1f(u.uBass, f.bass * look.throb);
gl.uniform1f(u.uGrowl, f.growl);
this.snare += (f.snare - this.snare) * (1 - Math.exp(-dt / (f.snare > this.snare ? SNARE_ATTACK : SNARE_RELEASE)));
gl.uniform1f(u.uSnare, this.snare);
gl.uniform1f(u.uGain, look.gain);
gl.uniform1f(u.uSpike, look.spike);
gl.uniform1f(u.uStep, look.step);
gl.uniform1f(u.uTwist, look.twist);
gl.uniform1f(u.uRipple, look.ripple);
gl.uniform1f(u.uShock, look.shock);
@@ -224,7 +234,8 @@ export class LiquidBlob {
jump[0] = Math.min(MAX_LIFT, Math.max(-MAX_LIFT / 2, jump[0]));
// Long while it flies up, squashed as it comes down and lands.
const stretch = 1 + Math.min(0.18, Math.max(-0.15, jump[1] * STRETCH + Math.min(0, jump[0]) * 1.2));
const size = 1 + pump[0];
// Each "womb" of the bass line swells the whole drop.
const size = 1 + pump[0] + THROB * look.throb * f.bass;
const side = size / Math.sqrt(stretch);
const u = this.u;
this.gl.uniform3f(u.uScale, side, size * stretch, side);
+21 -12
View File
@@ -64,9 +64,9 @@ uniform float uDist; // camera distance
uniform float uTime; // flow time, runs with the tempo
uniform float uMorph; // 0 flat disc (the static artwork), 1 liquid
// Music, see features.ts.
uniform float uLevel, uWobble, uKickAge, uDrop;
uniform float uLevel, uWobble, uKickAge, uSnare, uDrop, uBass, uGrowl;
// Character of the track, see style.ts.
uniform float uGain, uTwist, uRipple, uShock;
uniform float uGain, uSpike, uStep, uTwist, uRipple, uShock;
// The jump on prominent hits, from the springs in blob.ts.
uniform vec3 uScale; // squash and stretch
uniform float uLift; // upwards
@@ -79,20 +79,29 @@ out vec2 vFlat; // front projection of the resting sphere, for the artwork
${NOISE}
// Only broad, smooth shapes: the drop always wobbles like jelly and never creases.
// Every layer keeps a floor, so the drop never goes still and nothing snaps in from
// nothing; the music only turns each layer up and down.
float disp(vec3 p) {
vec3 q = p;
float a = uTwist * (p.y + 0.3 * sin(uTime)) * (0.5 + uWobble);
q.xz = mat2(cos(a), -sin(a), sin(a), cos(a)) * q.xz;
float t = uTime;
float body = snoise(q * 0.9 + vec3(0.0, t * 0.35, t * 0.2)) * (0.11 + 0.05 * uLevel + 0.07 * uDrop + 0.1 * uJelly);
float sway = snoise(q * 1.7 - vec3(t * 0.5, 0.0, t * 0.3)) * (0.035 + 0.03 * uWobble + 0.05 * uJelly);
float ripple = snoise(q * 2.6 + vec3(t * 1.1, -t * 0.8, 0.0)) * 0.02 * uWobble * uRipple;
// After each kick a broad wave runs from the front of the drop round to the back.
// Big lobes: swell on every "womb" of the bass line.
float lobes = snoise(q * 1.1 + vec3(0.0, t * 0.35, t * 0.2))
* (0.07 + 0.05 * uLevel + 0.11 * uBass + 0.08 * uDrop + 0.08 * uJelly);
// Terraces with soft edges: stiff, but no torn shards.
float steps = lobes * 10.0;
lobes = mix(lobes, (floor(steps) + smoothstep(0.3, 0.7, fract(steps))) / 10.0, uStep);
float sway = snoise(q * 1.7 - vec3(t * 0.5, 0.0, t * 0.3)) * (0.03 + 0.03 * uWobble);
// Crinkles open up with the wobble's filter: dark "oo" smooth, bright "wah" rough.
float ripples = snoise(q * 3.2 + vec3(t * 1.3, -t, 0.0)) * (0.012 + 0.035 * uWobble + 0.045 * uGrowl * uBass) * uRipple;
float n = snoise(q * 2.4 - vec3(0.0, t * 0.6, t * 0.9));
float spikes = pow(max(n, 0.0), 3.0) * (0.05 + 0.25 * uSnare + 0.15 * uGrowl * uBass + 0.1 * uDrop) * uSpike;
// After each kick a wave runs from the front of the drop round to the back.
float angle = acos(clamp(p.z, -1.0, 1.0));
float wave = exp(-pow((angle - uKickAge * 4.5) * 1.3, 2.0)) * exp(-uKickAge * 2.5) * 0.06 * uShock;
float wave = exp(-pow((angle - uKickAge * 5.0) * 1.8, 2.0)) * exp(-uKickAge * 2.8) * 0.09 * uShock;
float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0));
return clamp((body + sway + ripple + wave) * uGain + dent, -0.3, 0.32);
return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4);
}
vec3 shape(vec3 p) {
@@ -126,7 +135,7 @@ in vec2 vFlat;
uniform sampler2D uArt;
uniform vec3 uAccent;
uniform float uTime, uMorph, uKick, uDrop;
uniform float uTime, uMorph, uKick, uDrop, uBass, uGrowl;
uniform float uHue; // rim hue offset, radians
uniform float uIri; // how much the rim hue drifts
uniform float uMetal; // sharper, brighter highlights
@@ -147,7 +156,7 @@ void main() {
// Looking through the surface bends the artwork like water does.
vec2 bend = n.xy * (0.22 + 0.1 * uDrop) * uMorph;
vec2 uv = 0.5 + vec2(vFlat.x, -vFlat.y) * 0.48 - vec2(bend.x, -bend.y) * 0.5;
vec2 split = n.xy * (0.004 + 0.02 * uKick) * uMorph;
vec2 split = n.xy * (0.004 + 0.02 * uKick + 0.008 * uGrowl * uBass) * uMorph;
vec3 art = vec3(texture(uArt, uv + split).r, texture(uArt, uv).g, texture(uArt, uv - split).b);
art = mix(art, smoothstep(0.0, 1.0, art), 0.6 * uDrop);
@@ -157,7 +166,7 @@ void main() {
rim = mix(rim, vec3(1.0, 0.35, 0.1), uHeat * 0.6);
vec3 col = art * mix(1.0, 0.45, fres * uMorph);
col += rim * fres * (0.6 + 0.7 * uKick + 0.4 * uDrop) * uMorph;
col += rim * fres * (0.5 + 0.7 * uKick + 0.4 * uDrop + 0.5 * uBass) * uMorph;
col += spec * uMorph;
outColor = vec4(col, 1.0);
}
+12 -6
View File
@@ -8,6 +8,12 @@ export interface Look {
flow: number;
/// Overall size of the movement.
gain: number;
/// Spikes on snares and bright wobbles.
spike: number;
/// 0 smooth, 1 stepped (stiff, quantised) lobes.
step: number;
/// How much the bass line's envelope swells the whole drop.
throb: number;
/// How high prominent hits make the drop jump.
bounce: number;
/// Spring stiffness of the jump: higher snaps back faster, lower floats.
@@ -25,14 +31,14 @@ export interface Look {
hue: number;
}
const BASE: Look = { flow: 1, gain: 1, bounce: 1, stiff: 90, twist: 0.15, ripple: 1, shock: 1, iri: 0.1, metal: 0.3, heat: 0, hue: 0 };
const BASE: Look = { flow: 1, gain: 1, spike: 0.5, step: 0, throb: 1, bounce: 1, stiff: 90, twist: 0.15, ripple: 1, shock: 1, iri: 0.1, metal: 0.3, heat: 0, hue: 0 };
const TAGS: Record<string, (l: Look) => void> = {
riddim: (l) => Object.assign(l, { bounce: 1.3, stiff: 120, metal: 0.9, shock: 1.4, ripple: 0.7 }),
tearout: (l) => Object.assign(l, { bounce: 1.4, gain: l.gain * 1.2, heat: 0.7 }),
deathstep: (l) => Object.assign(l, { bounce: 1.4, gain: l.gain * 1.2, heat: 0.9, metal: 0.7 }),
melodic: (l) => Object.assign(l, { iri: 0.8, bounce: 0.6, stiff: 55, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1 }),
colourbass: (l) => Object.assign(l, { iri: 1, bounce: 0.8, stiff: 70, metal: 0.15 }),
riddim: (l) => Object.assign(l, { step: 0.5, spike: 0.6, throb: 1.4, bounce: 1.3, stiff: 120, metal: 0.9, shock: 1.4, ripple: 1.2 }),
tearout: (l) => Object.assign(l, { spike: 1.3, bounce: 1.4, gain: l.gain * 1.15, heat: 0.7 }),
deathstep: (l) => Object.assign(l, { spike: 1.4, bounce: 1.4, gain: l.gain * 1.15, heat: 0.9, metal: 0.7 }),
melodic: (l) => Object.assign(l, { iri: 0.8, spike: 0.15, throb: 0.7, bounce: 0.6, stiff: 55, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1 }),
colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, bounce: 0.8, stiff: 70, metal: 0.15 }),
dnb: (l) => Object.assign(l, { ripple: 1.6 }),
"170": (l) => Object.assign(l, { ripple: 1.5 }),
halftime: (l) => Object.assign(l, { flow: l.flow * 0.7, gain: l.gain * 1.15, stiff: 70 }),
+2 -2
View File
@@ -3,7 +3,7 @@
import type { Features } from "./features";
const KEYS: (keyof Features)[] = ["sub", "low", "mid", "high", "kick", "snare", "wobble", "drop"];
const KEYS: (keyof Features)[] = ["sub", "low", "mid", "high", "bass", "growl", "kick", "snare", "wobble", "drop"];
/// Onsets counted since the overlay opened; also on `window.audioDebug` for scripts.
const counts = { kicks: 0, snares: 0, frames: 0 };
@@ -13,7 +13,7 @@ export function audioDebug(): (f: Features) => void {
let lastAge = 99, lastSnare = 0;
const canvas = document.createElement("canvas");
canvas.className = "audio-debug";
const w = (canvas.width = 240), h = (canvas.height = 124);
const w = (canvas.width = 300), h = (canvas.height = 124);
document.body.append(canvas);
const ctx = canvas.getContext("2d")!;
ctx.font = "10px system-ui, sans-serif";
+48 -11
View File
@@ -25,6 +25,12 @@ export interface Features {
snareHit: number;
/// How much the mids move (wobble bass, growls), 0..1.
wobble: number;
/// The bass line's own envelope, 0..1 against the last moment: follows every
/// "womb" of a riddim wobble up and down.
bass: number;
/// How open the wobble's filter is, 0..1: low for a dark "oo", high for a bright
/// "wah" (spectral centroid of the bass and mids against its recent range).
growl: number;
/// 0..1, high while the low end sits well above its recent average.
drop: number;
}
@@ -46,6 +52,15 @@ const SNARE_DECAY = 0.12; // s
const PROMINENCE_MEMORY = 6; // s; a hit is weighed against the strongest ones this recent
const WOBBLE_SMOOTH = 0.12; // s
const BASS_BAND = [40, 400]; // Hz; body of a wobble bass
const BASS_FOLLOW = 14; // dB/s; its range forgets this fast, so each wub spans it
const BASS_MIN_RANGE = 6; // dB
const BASS_ATTACK = 0.015; // s
const BASS_RELEASE = 0.06; // s
const GROWL_BAND = [100, 3000]; // Hz
const GROWL_FOLLOW = 0.6; // octaves/s
const GROWL_MIN_RANGE = 1.2; // octaves
const GROWL_SMOOTH = 0.05; // s
const DROP_SHORT = 1; // s of low-end loudness …
const DROP_LONG = 12; // … against this much
const DROP_HEARD_DB = -70; // the low end counts as sounding above this
@@ -57,16 +72,21 @@ const DROP_RELEASE = 2; // s
const ease = (dt: number, tau: number) => 1 - Math.exp(-dt / tau);
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/// Maps a band's dB level into its recent range.
/// Maps a value (a band's dB level) into its recent range.
class Range {
private floor = -60;
private ceil = -30;
constructor(
private floor = -60,
private ceil = -30,
private ceilFall = CEIL_FALL,
private floorRise = FLOOR_RISE,
private minRange = MIN_RANGE,
) {}
update(db: number, dt: number): number {
this.ceil = db > this.ceil ? db : this.ceil - CEIL_FALL * dt;
this.floor = db < this.floor ? db : this.floor + FLOOR_RISE * dt;
const span = Math.max(MIN_RANGE, this.ceil - this.floor);
return clamp01((db - (this.ceil - span)) / span);
update(x: number, dt: number): number {
this.ceil = x > this.ceil ? x : this.ceil - this.ceilFall * dt;
this.floor = x < this.floor ? x : this.floor + this.floorRise * dt;
const span = Math.max(this.minRange, this.ceil - this.floor);
return clamp01((x - (this.ceil - span)) / span);
}
}
@@ -105,12 +125,14 @@ class Onset {
}
export class FeatureTracker {
readonly f: Features = { sub: 0, low: 0, mid: 0, high: 0, kick: 0, snare: 0, kickAge: 99, kickHit: 0, snareHit: 0, wobble: 0, drop: 0 };
readonly f: Features = { sub: 0, low: 0, mid: 0, high: 0, kick: 0, snare: 0, kickAge: 99, kickHit: 0, snareHit: 0, wobble: 0, bass: 0, growl: 0, drop: 0 };
private bins: Float32Array<ArrayBuffer> | null = null;
private prev: Float32Array<ArrayBuffer> | null = null;
private ranges = { sub: new Range(), low: new Range(), mid: new Range(), high: new Range() };
private kick = new Onset(KICK_DECAY);
private snare = new Onset(SNARE_DECAY);
private bassRange = new Range(-60, -30, BASS_FOLLOW, BASS_FOLLOW, BASS_MIN_RANGE);
private growlRange = new Range(7, 9, GROWL_FOLLOW, GROWL_FOLLOW, GROWL_MIN_RANGE);
private lastMid = 0;
private wobblePeak = 1;
private short = SILENCE_DB;
@@ -150,6 +172,20 @@ export class FeatureTracker {
const f = this.f;
for (const band of Object.keys(BANDS) as Band[]) f[band] = this.ranges[band].update(db[band], dt);
// The wobble: loudness of the bass body, and where its energy sits.
let bassPower = 0;
for (let i = bin(BASS_BAND[0]); i <= bin(BASS_BAND[1]); i++) bassPower += 10 ** (Math.max(SILENCE_DB, bins[i]) / 10);
const bassDb = Math.max(SILENCE_DB, 10 * Math.log10(bassPower / (bin(BASS_BAND[1]) - bin(BASS_BAND[0]) + 1)));
const bass = this.bassRange.update(bassDb, dt);
f.bass += (bass - f.bass) * ease(dt, bass > f.bass ? BASS_ATTACK : BASS_RELEASE);
let weight = 0, sum = 0;
for (let i = bin(GROWL_BAND[0]); i <= bin(GROWL_BAND[1]); i++) {
const p = 10 ** (Math.max(SILENCE_DB, bins[i]) / 10);
weight += p;
sum += p * Math.log2(i * hz);
}
if (weight > 0) f.growl += (this.growlRange.update(sum / weight, dt) - f.growl) * ease(dt, GROWL_SMOOTH);
this.kick.update((flux.sub + flux.low) / 2, now, dt);
this.snare.update((flux.mid + flux.high) / 2, now, dt);
f.kick = this.kick.env;
@@ -164,7 +200,8 @@ export class FeatureTracker {
/// One frame where there is no analyser: only the loudness from the waveform.
fromLoudness(level: number, dt: number): Features {
const f = this.f;
f.sub = f.low = f.mid = f.high = level;
f.sub = f.low = f.mid = f.high = f.bass = level;
f.growl = 0.3;
f.kick = f.snare = f.kickHit = f.snareHit = 0;
f.kickAge = 99;
this.track(level, level > 0 ? 20 * Math.log10(level) : SILENCE_DB, dt);
@@ -176,7 +213,7 @@ export class FeatureTracker {
const f = this.f;
const k = Math.exp(-dt / 0.35);
f.sub *= k; f.low *= k; f.mid *= k; f.high *= k;
f.kick *= k; f.snare *= k; f.wobble *= k; f.drop *= k;
f.kick *= k; f.snare *= k; f.wobble *= k; f.drop *= k; f.bass *= k; f.growl *= k;
f.kickAge += dt;
f.kickHit = f.snareHit = 0;
return f;
+3 -3
View File
@@ -257,9 +257,9 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
/* The liquid drop spills past the art slot; taps go through it to the card. */
.liquid-canvas {
position: absolute;
inset: -40%;
width: 180%;
height: 180%;
inset: -50%;
width: 200%;
height: 200%;
pointer-events: none;
}
.card.liquid .art { background-image: none !important; background-color: transparent; box-shadow: none; scale: 1; }