diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts index c47d07e..0e6a1c7 100644 --- a/web/src/blob/blob.ts +++ b/web/src/blob/blob.ts @@ -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); diff --git a/web/src/blob/shaders.ts b/web/src/blob/shaders.ts index dc6e84f..079d90a 100644 --- a/web/src/blob/shaders.ts +++ b/web/src/blob/shaders.ts @@ -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); } diff --git a/web/src/blob/style.ts b/web/src/blob/style.ts index 19065fb..84da170 100644 --- a/web/src/blob/style.ts +++ b/web/src/blob/style.ts @@ -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 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 }), diff --git a/web/src/debug.ts b/web/src/debug.ts index 6af32d6..3894e91 100644 --- a/web/src/debug.ts +++ b/web/src/debug.ts @@ -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"; diff --git a/web/src/features.ts b/web/src/features.ts index 33401e7..28028e9 100644 --- a/web/src/features.ts +++ b/web/src/features.ts @@ -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 | null = null; private prev: Float32Array | 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; diff --git a/web/src/style.css b/web/src/style.css index f47a37d..4e795da 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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; }