From 856ccb3b9ad00e041d7c92557ba1385fa5ed461c Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 1 Nov 2026 14:15:26 +0100 Subject: [PATCH 1/3] Replace loudness pulse with onset tracking and add liquid artwork visuals The bass meter sat at full scale through every dubstep drop, so the glow froze. features.ts now tracks band levels against their recent range, kick and snare onsets from spectral flux, wobble in the mids, and drops, and the pulse follows the kicks. The playing card's artwork becomes a WebGL2 liquid drop that moves with those features; tempo, tags and key set its character. Falls back to the plain artwork without WebGL2, with reduced motion, or with visuals set to simple (v). ?debug=audio shows what the tracker hears. --- web/index.html | 6 + web/src/blob/blob.ts | 259 ++++++++++++++++++++++++++++++++++++++++ web/src/blob/mesh.ts | 46 +++++++ web/src/blob/shaders.ts | 164 +++++++++++++++++++++++++ web/src/blob/style.ts | 61 ++++++++++ web/src/debug.ts | 26 ++++ web/src/features.ts | 182 ++++++++++++++++++++++++++++ web/src/main.ts | 20 +++- web/src/player.ts | 4 +- web/src/style.css | 25 +++- web/src/visual.ts | 151 +++++++++++++++++------ 11 files changed, 901 insertions(+), 43 deletions(-) create mode 100644 web/src/blob/blob.ts create mode 100644 web/src/blob/mesh.ts create mode 100644 web/src/blob/shaders.ts create mode 100644 web/src/blob/style.ts create mode 100644 web/src/debug.ts create mode 100644 web/src/features.ts diff --git a/web/index.html b/web/index.html index 3381dc7..4c9f159 100644 --- a/web/index.html +++ b/web/index.html @@ -88,6 +88,11 @@ +
+ visuals + + +
@@ -148,6 +153,7 @@
r
artist radio
x
not for me
s
share
+
v
liquid / simple visuals
m
mute
?
this help
diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts new file mode 100644 index 0000000..5a45886 --- /dev/null +++ b/web/src/blob/blob.ts @@ -0,0 +1,259 @@ +// The liquid artwork drop: one WebGL2 canvas, moved into the playing card's art slot. +// Browsers allow only a handful of WebGL contexts, so there is one for the whole +// page; cards around it show their artwork as a plain image. + +import type { Features } from "../features"; +import { icosphere } from "./mesh"; +import { FRAGMENT, VERTEX } from "./shaders"; +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.5; +const DIST = SPILL / Math.tan(FOV / 2); +const MORPH_IN = 0.7; // s from flat artwork to liquid +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 +/// Quality steps: icosphere subdivisions and resolution scale. +const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]]; + +const UNIFORMS = [ + "uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", + "uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uPointer", + "uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat", +] as const; + +export class LiquidBlob { + readonly canvas = document.createElement("canvas"); + /// Called once if the GPU drops the context; the page then falls back to plain art. + onLost: (() => void) | null = null; + private gl: WebGL2RenderingContext; + private u = {} as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>; + private vao: WebGLVertexArrayObject; + private buffers: WebGLBuffer[] = []; + private count = 0; + private tex: WebGLTexture; + private quality: number; + private look: Look | null = null; + private accent: [number, number, number] = [1, 0.3, 0.43]; + private ready = false; + private gen = 0; + private morph = 0; + private time = 0; + private pointer = [0, 0, 1, 0]; + private pointerGoal = [0, 0, 1, 0]; + private slow = 0; + + /// Null where WebGL2 is not available. + static create(touch: boolean): LiquidBlob | null { + try { + return new LiquidBlob(touch); + } catch (err) { + console.warn("liquid visuals off:", (err as Error).message); + return null; + } + } + + private constructor(private touch: boolean) { + const gl = this.canvas.getContext("webgl2", { antialias: true, premultipliedAlpha: true }); + if (!gl) throw new Error("no WebGL2"); + this.gl = gl; + this.canvas.className = "liquid-canvas"; + this.canvas.addEventListener("webglcontextlost", () => { + this.ready = false; + this.onLost?.(); + }); + const prog = gl.createProgram(); + gl.attachShader(prog, this.shader(gl.VERTEX_SHADER, VERTEX)); + gl.attachShader(prog, this.shader(gl.FRAGMENT_SHADER, FRAGMENT)); + gl.bindAttribLocation(prog, 0, "position"); + gl.linkProgram(prog); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "link failed"); + gl.useProgram(prog); + for (const name of UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name); + this.vao = gl.createVertexArray(); + this.quality = touch ? 1 : 0; + this.buildMesh(); + this.tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.uniform1i(this.u.uArt, 0); + gl.uniformMatrix4fv(this.u.uProj, false, perspective(FOV, 1, 0.1, 20)); + gl.uniform1f(this.u.uDist, DIST); + gl.enable(gl.DEPTH_TEST); + gl.clearColor(0, 0, 0, 0); + } + + private shader(type: number, src: string): WebGLShader { + const gl = this.gl; + const s = gl.createShader(type)!; + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || "compile failed"); + return s; + } + + private buildMesh(): void { + const gl = this.gl; + const mesh = icosphere(QUALITY[this.quality][0]); + for (const b of this.buffers) gl.deleteBuffer(b); + gl.bindVertexArray(this.vao); + const pos = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, pos); + gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); + const idx = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idx); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices, gl.STATIC_DRAW); + this.buffers = [pos, idx]; + this.count = mesh.indices.length; + } + + /// Show a new track in `slot`. Resolves once its artwork is on the GPU, which is + /// when the slot's own image can be hidden. + async mount(slot: HTMLElement, look: Look, art: string | null, initial: string): Promise { + if (this.canvas.parentElement !== slot) slot.append(this.canvas); + const gen = ++this.gen; + this.look = look; + this.ready = false; + this.morph = 0; + this.slow = 0; + const source = await artwork(art, initial).catch(() => artwork(null, initial)); + if (gen !== this.gen || this.gl.isContextLost()) return false; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + gl.generateMipmap(gl.TEXTURE_2D); + this.ready = true; + return true; + } + + /// Rim colour, as CSS `hsl()` from color.ts or any `#rrggbb`. + setAccent(css: string | null): void { + this.accent = parseColor(css) ?? [1, 0.3, 0.43]; + } + + /// Pointer over the slot: x, y from the centre in units of the art's radius + /// (y up); `on` false when it left or was lifted. + setPointer(x: number, y: number, on: boolean): void { + const r = Math.hypot(x, y); + const [dx, dy, dz] = r < 1 ? [x, y, Math.sqrt(1 - r * r)] : [x / r, y / r, 0]; + this.pointerGoal = [dx, dy, dz, on && r < 1.4 ? 1 : 0]; + } + + /// Draw one frame. Returns true while it still moves without music (morphing in, + /// settling, following the pointer). + frame(f: Features, dt: number, playing: boolean): boolean { + const look = this.look; + if (!look || !this.ready) return false; + const gl = this.gl; + this.watch(dt); + const scale = Math.min(devicePixelRatio || 1, this.touch ? 1 : 1.5) * QUALITY[this.quality][1]; + const w = Math.round(this.canvas.clientWidth * scale), h = Math.round(this.canvas.clientHeight * scale); + if (!w || !h) return false; + if (this.canvas.width !== w || this.canvas.height !== h) { + this.canvas.width = w; + this.canvas.height = h; + } + gl.viewport(0, 0, w, h); + + this.morph = Math.min(1, this.morph + dt / MORPH_IN); + const morph = 1 - (1 - this.morph) ** 3; + // The liquid flows with the tempo, faster when the music moves more. + this.time += dt * look.flow * (playing ? 0.6 + 0.8 * f.wobble + 0.6 * f.drop : 0.25); + const k = 1 - Math.exp(-dt / 0.15); + for (let i = 0; i < 4; i++) this.pointer[i] += (this.pointerGoal[i] - this.pointer[i]) * k; + + const u = this.u; + gl.uniform1f(u.uTime, this.time); + gl.uniform1f(u.uMorph, morph); + gl.uniform1f(u.uLevel, (f.sub + f.low) / 2); + gl.uniform1f(u.uWobble, f.wobble); + gl.uniform1f(u.uKick, f.kick); + gl.uniform1f(u.uKickAge, f.kickAge); + gl.uniform1f(u.uSnare, f.snare); + gl.uniform1f(u.uDrop, f.drop); + 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); + gl.uniform1f(u.uIri, look.iri); + gl.uniform1f(u.uMetal, look.metal); + gl.uniform1f(u.uHeat, look.heat); + gl.uniform1f(u.uHue, look.hue); + gl.uniform3fv(u.uAccent, this.accent); + gl.uniform4fv(u.uPointer, this.pointer); + + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.bindVertexArray(this.vao); + gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0); + return this.morph < 1 || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; + } + + /// Step the quality down when frames keep coming in slow. + private watch(dt: number): void { + this.slow = dt > SLOW_FRAME ? this.slow + dt : Math.max(0, this.slow - dt); + if (this.slow < SLOW_FOR || this.quality >= QUALITY.length - 1) return; + this.slow = 0; + const before = QUALITY[this.quality][0]; + this.quality++; + if (QUALITY[this.quality][0] !== before) this.buildMesh(); + } +} + +/// The artwork cropped square, or a gradient with the artist's initial. +async function artwork(src: string | null, initial: string): Promise { + const c = document.createElement("canvas"); + c.width = c.height = TEXTURE_SIZE; + const ctx = c.getContext("2d")!; + if (src) { + const img = new Image(); + img.src = src; + await img.decode(); + const side = Math.min(img.naturalWidth, img.naturalHeight); + ctx.drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2, side, side, 0, 0, TEXTURE_SIZE, TEXTURE_SIZE); + } else { + const g = ctx.createLinearGradient(0, 0, TEXTURE_SIZE, TEXTURE_SIZE); + g.addColorStop(0, "#2a2a2e"); + g.addColorStop(1, "#111"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE); + ctx.fillStyle = "#ffffff66"; + ctx.font = `700 ${TEXTURE_SIZE * 0.4}px system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(initial, TEXTURE_SIZE / 2, TEXTURE_SIZE / 2); + } + return c; +} + +function perspective(fov: number, aspect: number, near: number, far: number): Float32Array { + const f = 1 / Math.tan(fov / 2); + const nf = 1 / (near - far); + return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0]); +} + +/// `hsl(h s% l%)` (as color.ts makes them) or `#rrggbb`, as 0..1 RGB. +function parseColor(css: string | null): [number, number, number] | null { + if (!css) return null; + const hex = css.match(/^#([0-9a-f]{6})$/i); + if (hex) { + const n = parseInt(hex[1], 16); + return [(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; + } + const m = css.match(/hsl\(\s*([\d.]+)\s+([\d.]+)%\s+([\d.]+)%/); + if (!m) return null; + const h = +m[1] / 360, s = +m[2] / 100, l = +m[3] / 100; + const a = s * Math.min(l, 1 - l); + const ch = (n: number) => { + const k = (n + h * 12) % 12; + return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + }; + return [ch(0), ch(8), ch(4)]; +} diff --git a/web/src/blob/mesh.ts b/web/src/blob/mesh.ts new file mode 100644 index 0000000..256a61e --- /dev/null +++ b/web/src/blob/mesh.ts @@ -0,0 +1,46 @@ +// Unit icosphere: an icosahedron with each triangle split into four, `levels` times. +// Level 5 has 10 242 vertices, which still fits 16-bit indices. + +export interface Mesh { + positions: Float32Array; + indices: Uint16Array; +} + +export function icosphere(levels: number): Mesh { + const t = (1 + Math.sqrt(5)) / 2; + const verts: number[][] = [ + [-1, t, 0], [1, t, 0], [-1, -t, 0], [1, -t, 0], + [0, -1, t], [0, 1, t], [0, -1, -t], [0, 1, -t], + [t, 0, -1], [t, 0, 1], [-t, 0, -1], [-t, 0, 1], + ].map(unit); + let faces = [ + [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], + [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], + [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], + [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + ]; + for (let l = 0; l < levels; l++) { + // Each edge is split once, shared by the two faces on it. + const mids = new Map(); + const mid = (a: number, b: number) => { + const key = a < b ? `${a},${b}` : `${b},${a}`; + let i = mids.get(key); + if (i === undefined) { + const [p, q] = [verts[a], verts[b]]; + i = verts.push(unit([p[0] + q[0], p[1] + q[1], p[2] + q[2]])) - 1; + mids.set(key, i); + } + return i; + }; + faces = faces.flatMap(([a, b, c]) => { + const ab = mid(a, b), bc = mid(b, c), ca = mid(c, a); + return [[a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]]; + }); + } + return { positions: new Float32Array(verts.flat()), indices: new Uint16Array(faces.flat()) }; +} + +function unit([x, y, z]: number[]): number[] { + const l = Math.hypot(x, y, z); + return [x / l, y / l, z / l]; +} diff --git a/web/src/blob/shaders.ts b/web/src/blob/shaders.ts new file mode 100644 index 0000000..bf89b8f --- /dev/null +++ b/web/src/blob/shaders.ts @@ -0,0 +1,164 @@ +// GLSL for the liquid artwork drop. The vertex shader moves a unit sphere with +// noise driven by the music; the fragment shader shows the artwork through the +// surface, with a rim in the card's accent colour. + +// 3D simplex noise by Ian McEwan and Stefan Gustavson (Ashima Arts), MIT licence: +// https://github.com/ashima/webgl-noise +const NOISE = /* glsl */ ` +vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } +vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } +vec4 permute(vec4 x) { return mod289(((x * 34.0) + 10.0) * x); } +vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } + +float snoise(vec3 v) { + const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0); + const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); + vec3 i = floor(v + dot(v, C.yyy)); + vec3 x0 = v - i + dot(i, C.xxx); + vec3 g = step(x0.yzx, x0.xyz); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + C.xxx; + vec3 x2 = x0 - i2 + C.yyy; + vec3 x3 = x0 - D.yyy; + i = mod289(i); + vec4 p = permute(permute(permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + float n_ = 0.142857142857; + vec3 ns = n_ * D.wyz - D.xzx; + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3))); + p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; + vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + m = m * m; + return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); +} +`; + +export const VERTEX = /* glsl */ `#version 300 es +precision highp float; + +in vec3 position; + +uniform mat4 uProj; +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, uKick, uKickAge, uSnare, uDrop; +// Character of the track, see style.ts. +uniform float uGain, uSpike, uStep, uTwist, uRipple, uShock; +uniform vec4 uPointer; // direction on the sphere, strength + +out vec3 vPos; // view space +out vec3 vNormal; // view space +out vec2 vFlat; // front projection of the resting sphere, for the artwork + +${NOISE} + +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 lobes = snoise(q * 1.1 + vec3(0.0, t * 0.35, t * 0.2)) * (0.05 + 0.12 * uLevel + 0.12 * uDrop); + // 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 ripples = snoise(q * 3.2 + vec3(t * 1.3, -t, 0.0)) * (0.012 + 0.07 * uWobble) * 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.06 + 0.35 * uSnare + 0.15 * uDrop) * uSpike; + // After each kick a ring runs from the front of the drop round to the back. + float angle = acos(clamp(p.z, -1.0, 1.0)); + float ring = exp(-pow((angle - uKickAge * 6.0) * 2.5, 2.0)) * exp(-uKickAge * 3.0) * 0.14 * uShock; + float swell = uKick * 0.05; + float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0)); + return clamp((lobes + ripples + spikes + ring + swell) * uGain + dent, -0.3, 0.42); +} + +vec3 shape(vec3 p) { + vec3 s = p * (1.0 + disp(p) * uMorph); + s.z *= mix(0.04, 1.0, uMorph); + return s; +} + +void main() { + vec3 p = position; + // Normal from two displaced neighbours along the surface. + vec3 t1 = normalize(cross(p, abs(p.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0))); + vec3 t2 = cross(p, t1); + const float e = 0.012; + vec3 s = shape(p); + vec3 n = normalize(cross(shape(normalize(p + t1 * e)) - s, shape(normalize(p + t2 * e)) - s)); + if (dot(n, p) < 0.0) n = -n; + vPos = s - vec3(0.0, 0.0, uDist); + vNormal = n; + vFlat = p.xy; + gl_Position = uProj * vec4(vPos, 1.0); +} +`; + +export const FRAGMENT = /* glsl */ `#version 300 es +precision highp float; + +in vec3 vPos; +in vec3 vNormal; +in vec2 vFlat; + +uniform sampler2D uArt; +uniform vec3 uAccent; +uniform float uTime, uMorph, uKick, uDrop; +uniform float uHue; // rim hue offset, radians +uniform float uIri; // how much the rim hue drifts +uniform float uMetal; // sharper, brighter highlights +uniform float uHeat; // pulls the rim towards orange + +out vec4 outColor; + +vec3 hueRotate(vec3 c, float a) { + const vec3 k = vec3(0.57735); + float ca = cos(a); + return c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca); +} + +void main() { + vec3 n = normalize(vNormal); + vec3 v = normalize(-vPos); + float fres = pow(1.0 - max(dot(n, v), 0.0), 3.0); + // 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; + 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); + + vec3 light = normalize(vec3(-0.5, 0.7, 0.6)); + float spec = pow(max(dot(n, normalize(light + v)), 0.0), mix(40.0, 140.0, uMetal)) * mix(0.35, 0.8, uMetal); + vec3 rim = hueRotate(uAccent, uHue + uIri * (fres * 3.0 + uTime * 0.3)); + 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 += spec * uMorph; + outColor = vec4(col, 1.0); +} +`; diff --git a/web/src/blob/style.ts b/web/src/blob/style.ts new file mode 100644 index 0000000..b49563c --- /dev/null +++ b/web/src/blob/style.ts @@ -0,0 +1,61 @@ +// The character of the drop for one track, from its tempo, tags and key. Each +// field is a shader uniform; tune here. + +import type { Track } from "../api"; + +export interface Look { + /// Flow speed of the noise. + flow: number; + /// Overall size of the movement. + gain: number; + /// Spikes on the snare. + spike: number; + /// 0 smooth, 1 stepped (stiff, quantised) lobes. + step: number; + twist: number; + /// Fast ripples from the wobble. + ripple: number; + /// Ring that runs over the surface on each kick. + shock: number; + /// Rim hue drift. + iri: number; + metal: number; + heat: number; + /// Rim hue offset from the key, radians. + hue: number; +} + +const BASE: Look = { flow: 1, gain: 1, spike: 0.5, step: 0, 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, { step: 0.8, metal: 0.9, shock: 1.4, spike: 0.6, ripple: 0.7 }), + tearout: (l) => Object.assign(l, { spike: 1.4, gain: l.gain * 1.25, heat: 0.7 }), + deathstep: (l) => Object.assign(l, { spike: 1.5, gain: l.gain * 1.25, heat: 0.9, metal: 0.7 }), + melodic: (l) => Object.assign(l, { iri: 0.8, spike: 0.15, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1 }), + colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, 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 }), + wonk: (l) => Object.assign(l, { twist: 0.9 }), +}; + +export function lookFor(t: Track): Look { + // About one noise cycle per bar. + const l: Look = { ...BASE, flow: ((t.bpm || 140) / 240) * 0.9 }; + for (const tag of t.tags ?? []) TAGS[tag]?.(l); + l.hue = keyHue(t.key); + return l; +} + +const PITCH: Record = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; + +/// Keys a fifth apart get neighbouring hues: up to ±26° around the accent colour. +function keyHue(key: string | null): number { + const m = key?.trim().match(/^([a-g])\s*([#♯b♭]?)\s*(.*)$/i); + if (!m) return 0; + let pc = PITCH[m[1].toLowerCase()] + (/[#♯]/.test(m[2]) ? 1 : /[b♭]/.test(m[2]) ? -1 : 0); + // A minor key sits with its relative major. + if (/^(m(?!aj)|min)/i.test(m[3])) pc += 3; + const fifths = (((pc * 7) % 12) + 12) % 12; + return (fifths / 12 - 0.5) * 0.9; +} diff --git a/web/src/debug.ts b/web/src/debug.ts new file mode 100644 index 0000000..7d975c4 --- /dev/null +++ b/web/src/debug.ts @@ -0,0 +1,26 @@ +// `?debug=audio`: live bars of what features.ts hears, for tuning its constants by +// ear. Loaded only when asked for. + +import type { Features } from "./features"; + +const KEYS: (keyof Features)[] = ["sub", "low", "mid", "high", "kick", "snare", "wobble", "drop"]; + +export function audioDebug(): (f: Features) => void { + const canvas = document.createElement("canvas"); + canvas.className = "audio-debug"; + const w = (canvas.width = 240), h = (canvas.height = 110); + document.body.append(canvas); + const ctx = canvas.getContext("2d")!; + ctx.font = "10px system-ui, sans-serif"; + const bar = w / KEYS.length; + return (f) => { + ctx.clearRect(0, 0, w, h); + KEYS.forEach((k, i) => { + const v = Math.min(1, Math.max(0, f[k])); + ctx.fillStyle = k === "kick" || k === "snare" ? "#ff4d6d" : k === "drop" ? "#ffb02e" : "#7fd4ff"; + ctx.fillRect(i * bar + 3, h - 14 - v * (h - 20), bar - 6, v * (h - 20)); + ctx.fillStyle = "#fff9"; + ctx.fillText(k, i * bar + 3, h - 3); + }); + }; +} diff --git a/web/src/features.ts b/web/src/features.ts new file mode 100644 index 0000000..04678a3 --- /dev/null +++ b/web/src/features.ts @@ -0,0 +1,182 @@ +// What the music is doing right now, in a form visuals can move to. +// +// Loudness alone is useless for bass music: in a dubstep or riddim drop the sub is +// loud and steady, so a loudness meter sits at its maximum and nothing moves. The +// movement is in the changes, so besides slow band levels this tracks onsets (kick, +// snare), the wobble of the mids, and drops (the low end jumping well above where +// it has been). +// +// All constants that shape the feel are here, in one place, for tuning by ear. + +export interface Features { + /// Band levels, 0..1, against the recent range of each band. For slow breathing. + sub: number; + low: number; + mid: number; + high: number; + /// Onset envelopes: 1 on a hit, then decaying. + kick: number; + snare: number; + /// Seconds since the last kick; large when there was none. + kickAge: number; + /// How much the mids move (wobble bass, growls), 0..1. + wobble: number; + /// 0..1, high while the low end sits well above its recent average. + drop: number; +} + +const BANDS = { sub: [30, 90], low: [90, 250], mid: [250, 2000], high: [2000, 10000] } as const; +type Band = keyof typeof BANDS; + +const SILENCE_DB = -110; +const CEIL_FALL = 4; // dB/s; a band's ceiling follows its peaks down this slowly +const FLOOR_RISE = 3; // dB/s; and its floor follows the quiet parts up +const MIN_RANGE = 14; // dB; below this a band counts as flat, not as full scale + +const ONSET_WINDOW = 0.75; // s of flux history the threshold adapts to +const ONSET_SIGMA = 1.5; // a hit is this many deviations above the mean flux +const ONSET_MIN_FLUX = 1.2; // dB of mean rise per bin; quieter changes are noise +const ONSET_REFRACTORY = 0.11; // s +const KICK_DECAY = 0.16; // s +const SNARE_DECAY = 0.12; // s + +const WOBBLE_SMOOTH = 0.12; // s +const DROP_SHORT = 1; // s of low-end loudness … +const DROP_LONG = 12; // … against this much +const DROP_RISE_DB = 2.5; // dB above the long average where a drop starts +const DROP_FULL_DB = 5; // dB above the start for a full drop +const DROP_ATTACK = 0.25; // s +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. +class Range { + private floor = -60; + private ceil = -30; + + 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); + } +} + +/// Spectral flux against its own running mean and spread. +class Onset { + private mean = 0; + private variance = 1; + private last = -1; + env = 0; + age = 99; + + constructor(private decay: number) {} + + update(flux: number, now: number, dt: number): void { + const threshold = this.mean + ONSET_SIGMA * Math.sqrt(this.variance); + const hit = flux > threshold && flux > ONSET_MIN_FLUX && now - this.last > ONSET_REFRACTORY; + const a = ease(dt, ONSET_WINDOW); + const d = flux - this.mean; + this.mean += a * d; + this.variance += a * (d * d - this.variance); + this.age += dt; + if (hit) { + this.last = now; + this.env = 1; + this.age = 0; + } else { + this.env *= Math.exp(-dt / this.decay); + } + } +} + +export class FeatureTracker { + readonly f: Features = { sub: 0, low: 0, mid: 0, high: 0, kick: 0, snare: 0, kickAge: 99, wobble: 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 lastMid = 0; + private wobblePeak = 1; + private short = SILENCE_DB; + private long = SILENCE_DB; + + /// One frame from the analyser. + fromAnalyser(an: AnalyserNode, now: number, dt: number): Features { + const n = an.frequencyBinCount; + if (this.bins?.length !== n) { + this.bins = new Float32Array(n); + this.prev = new Float32Array(n).fill(SILENCE_DB); + } + const bins = this.bins, prev = this.prev!; + an.getFloatFrequencyData(bins); + const hz = an.context.sampleRate / an.fftSize; + const bin = (f: number) => Math.min(n - 1, Math.max(1, Math.round(f / hz))); + + const db: Record = { sub: 0, low: 0, mid: 0, high: 0 }; + const flux: Record = { sub: 0, low: 0, mid: 0, high: 0 }; + for (const band of Object.keys(BANDS) as Band[]) { + const [lo, hi] = BANDS[band]; + const a = bin(lo), b = Math.max(a, bin(hi)); + let power = 0, rise = 0; + for (let i = a; i <= b; i++) { + const v = Math.max(SILENCE_DB, bins[i]); + power += 10 ** (v / 10); + rise += Math.max(0, v - prev[i]); + prev[i] = v; + } + const count = b - a + 1; + db[band] = Math.max(SILENCE_DB, 10 * Math.log10(power / count)); + flux[band] = rise / count; + } + + const f = this.f; + for (const band of Object.keys(BANDS) as Band[]) f[band] = this.ranges[band].update(db[band], dt); + 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; + f.kickAge = this.kick.age; + f.snare = this.snare.env; + this.track(f.mid, Math.max(db.sub, db.low), dt); + return f; + } + + /// 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.kick = f.snare = 0; + f.kickAge = 99; + this.track(level, level > 0 ? 20 * Math.log10(level) : SILENCE_DB, dt); + return f; + } + + /// Nothing playing: let everything settle. + idle(dt: number): Features { + 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.kickAge += dt; + return f; + } + + /// Wobble from how fast the mids move, drop from the low end against its past. + private track(mid: number, lowDb: number, dt: number): void { + const f = this.f; + const speed = Math.abs(mid - this.lastMid) / Math.max(dt, 1 / 240); + this.lastMid = mid; + this.wobblePeak = Math.max(1, speed, this.wobblePeak * Math.exp(-dt / 4)); + f.wobble += (clamp01(speed / this.wobblePeak) - f.wobble) * ease(dt, WOBBLE_SMOOTH); + + // Start from the first sound heard, or every track would open with a drop. + if (this.long <= SILENCE_DB) this.short = this.long = lowDb; + this.short += (lowDb - this.short) * ease(dt, DROP_SHORT); + this.long += (lowDb - this.long) * ease(dt, DROP_LONG); + const target = clamp01((this.short - this.long - DROP_RISE_DB) / DROP_FULL_DB); + f.drop += (target - f.drop) * ease(dt, target > f.drop ? DROP_ATTACK : DROP_RELEASE); + } +} diff --git a/web/src/main.ts b/web/src/main.ts index 53eb321..ff0e9d2 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -5,7 +5,7 @@ import type { Filters, Mode, Pl8Status, Stats, Track, User } from "./api"; import { Card, type CardActions, cleanTitle } from "./cards"; import { Pager } from "./pager"; import { Player } from "./player"; -import { Visualizer } from "./visual"; +import { type VisualsSetting, Visualizer } from "./visual"; const PREFETCH_AHEAD = 4; // fetch more when this close to the end const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip @@ -36,7 +36,7 @@ const loginEl = $("login"); const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches; const player = new Player(); -const visual = new Visualizer(player); +const visual = new Visualizer(player, isTouch); const cards: Card[] = []; let active = -1; let loading = false; @@ -524,12 +524,25 @@ modeChip.addEventListener("click", () => setMode({ mode: "classic" })); filtersForm.addEventListener("change", (e) => { const input = e.target as HTMLInputElement; if (input.name === "mode") setMode({ mode: input.value as "classic" | "riddim" | "fresh" | "deep" }); + if (input.name === "visuals") setVisuals(input.value as VisualsSetting); }); + +function setVisuals(v: VisualsSetting): void { + visual.visuals = v; + for (const input of filtersForm.querySelectorAll("input[name=visuals]")) input.checked = input.value === v; +} +setVisuals(visual.visuals); showMode(); filtersBtn.addEventListener("click", () => togglePanel(filtersForm)); filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); }); -$("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); }); +$("filters-clear").addEventListener("click", () => { + filtersForm.reset(); + // reset() also clears the mode and visuals radios, which "clear" leaves alone. + showMode(); + setVisuals(visual.visuals); + applyFilters(); +}); showFilters(); // Keyboard -------------------------------------------------------------- @@ -559,6 +572,7 @@ document.addEventListener("keydown", (e) => { else if (k === "r" && card) actions.radio(card); else if (k === "x" && card) dislike(card); else if (k === "s" && card) share(card); + else if (k === "v") { setVisuals(visual.visuals === "liquid" ? "simple" : "liquid"); toast(`${visual.visuals} visuals`); } else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); } else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); } }); diff --git a/web/src/player.ts b/web/src/player.ts index 082542e..db38b3e 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -148,8 +148,8 @@ export class Player { return; } const analyser = ctx.createAnalyser(); - analyser.fftSize = 1024; - analyser.smoothingTimeConstant = 0.7; + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.2; // features.ts smooths per feature; this only takes the edge off the FFT analyser.connect(ctx.destination); for (const d of this.decks) { const gain = ctx.createGain(); diff --git a/web/src/style.css b/web/src/style.css index ba03e4d..cbdee5f 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -226,7 +226,8 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; position: relative; width: min(16rem, 42vh, 70vw); aspect-ratio: 1; - border-radius: 14px; + /* Round, so the static artwork matches the liquid drop (blob/) it turns into. */ + border-radius: 50%; background: #222 center / cover no-repeat; box-shadow: 0 20px 60px #000a, @@ -240,6 +241,28 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; } .initial { font-size: 4rem; font-weight: 700; color: #fff6; } +/* The liquid drop spills past the art slot; taps go through it to the card. */ +.liquid-canvas { + position: absolute; + inset: -25%; + width: 150%; + height: 150%; + pointer-events: none; +} +.card.liquid .art { background-image: none !important; background-color: transparent; box-shadow: none; scale: 1; } +.card.liquid .initial { display: none; } +.paused-badge, .spinner { z-index: 1; } + +.audio-debug { + position: fixed; + left: 0.7rem; + bottom: 0.7rem; + z-index: 30; + border-radius: var(--radius-s); + background: #000b; + pointer-events: none; +} + .card.paused .art { transform: scale(0.96); filter: brightness(0.7); } .paused-badge { position: absolute; diff --git a/web/src/visual.ts b/web/src/visual.ts index 1fc2d38..ce136a4 100644 --- a/web/src/visual.ts +++ b/web/src/visual.ts @@ -1,32 +1,103 @@ -// Per-frame visuals of the playing card: waveform progress, and a bass level that -// the stylesheet turns into a pulse of the artwork and a glow (`--energy`, 0..1). -// The level comes from the analyser where audio runs through Web Audio, otherwise -// from the track's waveform at the current position. +// Per-frame visuals of the playing card: waveform progress, the liquid artwork drop +// (blob/), and `--energy` (0..1) for the stylesheet's glow and pulse, which is all +// there is where the drop is off or WebGL2 is missing. +// +// What drives them comes from features.ts: from the analyser where audio runs through +// Web Audio, otherwise from the track's waveform at the current position. +import { artistImg } from "./api"; +import { LiquidBlob, SPILL } from "./blob/blob"; +import { lookFor } from "./blob/style"; import type { Card } from "./cards"; +import { accentFor } from "./color"; +import { type Features, FeatureTracker } from "./features"; import type { Player } from "./player"; +export type VisualsSetting = "liquid" | "simple"; +const VISUALS_KEY = "pl8inf.visuals"; + export class Visualizer { private raf = 0; - private bins: Uint8Array | null = null; - private level = 0; - private peak = 0.3; + private last = 0; private card: Card | null = null; + private features = new FeatureTracker(); + /// Undefined until first needed; null where WebGL2 failed. + private blob: LiquidBlob | null | undefined; private reduced = matchMedia("(prefers-reduced-motion: reduce)"); + private setting: VisualsSetting = localStorage.getItem(VISUALS_KEY) === "simple" ? "simple" : "liquid"; + private debug: ((f: Features) => void) | null = null; - constructor(private player: Player) { + constructor( + private player: Player, + private touch: boolean, + ) { document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start())); + this.reduced.addEventListener("change", () => this.attach(this.card, true)); + for (const ev of ["pointermove", "pointerdown"]) document.addEventListener(ev, (e) => this.pointer(e as PointerEvent, true), { passive: true }); + for (const ev of ["pointerup", "pointercancel", "pointerleave"]) document.addEventListener(ev, (e) => this.pointer(e as PointerEvent, false), { passive: true }); + if (new URLSearchParams(location.search).get("debug") === "audio") { + import("./debug").then((m) => { this.debug = m.audioDebug(); }); + } + } + + get visuals(): VisualsSetting { + return this.setting; + } + + set visuals(v: VisualsSetting) { + this.setting = v; + localStorage.setItem(VISUALS_KEY, v); + this.attach(this.card, true); + } + + /// Whether the liquid drop runs: wanted, motion allowed, and WebGL2 works. + private liquid(): LiquidBlob | null { + if (this.setting !== "liquid" || this.reduced.matches || this.blob === null) return null; + if (this.blob === undefined) { + this.blob = LiquidBlob.create(this.touch); + if (this.blob) { + this.blob.onLost = () => { + this.blob?.canvas.remove(); + this.blob = null; + this.card?.el?.classList.remove("liquid"); + }; + } + } + return this.blob; } /// Follow `card` from now on. - attach(card: Card | null): void { - if (this.card && this.card !== card) this.card.el?.style.setProperty("--energy", "0"); + attach(card: Card | null, again = false): void { + if (this.card === card && !again) return; + if (this.card && this.card !== card) { + this.card.el?.style.setProperty("--energy", "0"); + this.card.el?.classList.remove("liquid"); + } this.card = card; + const blob = this.liquid(); + const slot = card?.el?.querySelector(".art"); + if (!card || !slot || !blob) { + this.blob?.canvas.remove(); + card?.el?.classList.remove("liquid"); + } else { + const img = artistImg(card.track); + blob.setAccent(null); + if (img) accentFor(img).then((c) => { if (this.card === card) blob.setAccent(c); }); + blob.mount(slot, lookFor(card.track), img, (card.track.artist_name || "?")[0].toUpperCase()).then((ok) => { + if (ok && this.card === card) { + card.el?.classList.add("liquid"); + this.start(); + } + }); + } this.start(); } start(): void { - if (!this.raf && !document.hidden) this.raf = requestAnimationFrame(this.frame); + if (!this.raf && !document.hidden) { + this.last = 0; + this.raf = requestAnimationFrame(this.frame); + } } stop(): void { @@ -34,36 +105,42 @@ export class Visualizer { this.raf = 0; } - private frame = () => { + /// Pointer over the drop pushes a dent into it. + private pointer(e: PointerEvent, on: boolean): void { + const blob = this.blob; + if (!blob?.canvas.isConnected) return; + const r = blob.canvas.getBoundingClientRect(); + const radius = r.width / SPILL / 2; + blob.setPointer((e.clientX - r.left - r.width / 2) / radius, -(e.clientY - r.top - r.height / 2) / radius, on); + if (on) this.start(); + } + + private frame = (now: number) => { this.raf = 0; + const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 1 / 60; + this.last = now; const card = this.card; - const a = this.player.audio; if (!card) return; + const a = this.player.audio; const progress = a.duration ? a.currentTime / a.duration : 0; card.drawWave(progress); - if (!this.reduced.matches) { - const raw = a.paused ? 0 : this.bass(card, progress); - // Fast attack, slow release, against a slowly falling peak: kicks stand out - // the same whether a track is mastered loud or quiet. - this.level += (raw - this.level) * (raw > this.level ? 0.55 : 0.12); - this.peak = Math.max(this.peak * 0.997, this.level, 0.2); - const energy = Math.max(0, (this.level / this.peak - 0.35) / 0.65); - card.el?.style.setProperty("--energy", energy.toFixed(3)); - } - // Keep running while playing; one last frame after a pause lets the glow fade. - if (!a.paused || this.level > 0.01) this.raf = requestAnimationFrame(this.frame); - }; - - private bass(card: Card, progress: number): number { + const playing = !a.paused; const an = this.player.analyser; - if (!an || !this.player.mixing) return card.loudnessAt(progress) * 0.8; - if (this.bins?.length !== an.frequencyBinCount) this.bins = new Uint8Array(an.frequencyBinCount); - an.getByteFrequencyData(this.bins); - // Bins up to ~150 Hz: kick and sub. - const hz = an.context.sampleRate / an.fftSize; - const top = Math.max(2, Math.round(150 / hz)); - let sum = 0; - for (let i = 1; i <= top; i++) sum += this.bins[i]; - return sum / top / 255; - } + const mixed = !!an && this.player.mixing; + const f = !playing + ? this.features.idle(dt) + : mixed + ? this.features.fromAnalyser(an, now / 1000, dt) + : this.features.fromLoudness(card.loudnessAt(progress), dt); + this.debug?.(f); + let busy = playing; + if (!this.reduced.matches) { + // The kick drives the pulse; without an analyser there are no kicks, only loudness. + const energy = Math.min(1, mixed ? 0.65 * f.kick + 0.35 * f.low : 0.7 * f.low); + card.el?.style.setProperty("--energy", energy.toFixed(3)); + busy ||= energy > 0.01; + if (this.blob && card.el?.classList.contains("liquid")) busy = this.blob.frame(f, dt, playing) || busy; + } + if (busy) this.raf = requestAnimationFrame(this.frame); + }; } From e8c0f401288a9f3ac19a3303972d2b98a7adbea1 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 1 Nov 2026 14:32:51 +0100 Subject: [PATCH 2/3] Start drop detection from the first sound and count onsets in the debug overlay Previews often open inside a drop; the long loudness average now covers no more than what has been heard, so that is the baseline rather than a drop. --- web/src/debug.ts | 16 ++++++++++++++-- web/src/features.ts | 13 ++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/web/src/debug.ts b/web/src/debug.ts index 7d975c4..6af32d6 100644 --- a/web/src/debug.ts +++ b/web/src/debug.ts @@ -5,20 +5,32 @@ import type { Features } from "./features"; const KEYS: (keyof Features)[] = ["sub", "low", "mid", "high", "kick", "snare", "wobble", "drop"]; +/// Onsets counted since the overlay opened; also on `window.audioDebug` for scripts. +const counts = { kicks: 0, snares: 0, frames: 0 }; + export function audioDebug(): (f: Features) => void { + (window as unknown as { audioDebug: typeof counts }).audioDebug = counts; + let lastAge = 99, lastSnare = 0; const canvas = document.createElement("canvas"); canvas.className = "audio-debug"; - const w = (canvas.width = 240), h = (canvas.height = 110); + const w = (canvas.width = 240), h = (canvas.height = 124); document.body.append(canvas); const ctx = canvas.getContext("2d")!; ctx.font = "10px system-ui, sans-serif"; const bar = w / KEYS.length; return (f) => { + counts.frames++; + if (f.kickAge < lastAge) counts.kicks++; + if (f.snare > lastSnare + 0.5) counts.snares++; + lastAge = f.kickAge; + lastSnare = f.snare; ctx.clearRect(0, 0, w, h); + ctx.fillStyle = "#fff9"; + ctx.fillText(`kicks ${counts.kicks} · snares ${counts.snares}`, 4, 11); KEYS.forEach((k, i) => { const v = Math.min(1, Math.max(0, f[k])); ctx.fillStyle = k === "kick" || k === "snare" ? "#ff4d6d" : k === "drop" ? "#ffb02e" : "#7fd4ff"; - ctx.fillRect(i * bar + 3, h - 14 - v * (h - 20), bar - 6, v * (h - 20)); + ctx.fillRect(i * bar + 3, h - 14 - v * (h - 30), bar - 6, v * (h - 30)); ctx.fillStyle = "#fff9"; ctx.fillText(k, i * bar + 3, h - 3); }); diff --git a/web/src/features.ts b/web/src/features.ts index 04678a3..3df2ec9 100644 --- a/web/src/features.ts +++ b/web/src/features.ts @@ -43,6 +43,7 @@ const SNARE_DECAY = 0.12; // s const WOBBLE_SMOOTH = 0.12; // 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 const DROP_RISE_DB = 2.5; // dB above the long average where a drop starts const DROP_FULL_DB = 5; // dB above the start for a full drop const DROP_ATTACK = 0.25; // s @@ -103,6 +104,9 @@ export class FeatureTracker { private wobblePeak = 1; private short = SILENCE_DB; private long = SILENCE_DB; + /// Seconds of sound heard so far: the long average covers no more than that, so + /// music that starts in the middle of a drop is the baseline, not a drop. + private heard = 0; /// One frame from the analyser. fromAnalyser(an: AnalyserNode, now: number, dt: number): Features { @@ -172,10 +176,13 @@ export class FeatureTracker { this.wobblePeak = Math.max(1, speed, this.wobblePeak * Math.exp(-dt / 4)); f.wobble += (clamp01(speed / this.wobblePeak) - f.wobble) * ease(dt, WOBBLE_SMOOTH); - // Start from the first sound heard, or every track would open with a drop. - if (this.long <= SILENCE_DB) this.short = this.long = lowDb; + if (this.heard === 0) { + if (lowDb < DROP_HEARD_DB) return; + this.short = this.long = lowDb; + } + this.heard += dt; this.short += (lowDb - this.short) * ease(dt, DROP_SHORT); - this.long += (lowDb - this.long) * ease(dt, DROP_LONG); + this.long += (lowDb - this.long) * ease(dt, Math.min(DROP_LONG, DROP_SHORT + this.heard)); const target = clamp01((this.short - this.long - DROP_RISE_DB) / DROP_FULL_DB); f.drop += (target - f.drop) * ease(dt, target > f.drop ? DROP_ATTACK : DROP_RELEASE); } From 76cfbb12ac43215264b17751b0747969f6f3bd4b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 1 Nov 2026 14:32:51 +0100 Subject: [PATCH 3/3] Turn artist radio into a station sheet with reasons on each track The radio button (r) opens a sheet with the artist, their tags and their closest neighbours instead of switching the feed at once; tapping a neighbour walks the artist graph. Starting jumps to the radio's first track, the top-bar chip shows the station, each radio card says why it is there, and an end card offers the way back when the radio runs dry. GET /local/artist feeds the sheet and starts crawling a new artist's neighbourhood (at most every 10 minutes per artist). Artist-radio tracks carry why: seed, related, or via the neighbour that leads to them. --- README.md | 31 ++++++-- backend/src/crawler.rs | 14 +++- backend/src/db.rs | 30 ++++++++ backend/src/http/app.rs | 41 ++++++++++ backend/src/http/mod.rs | 1 + backend/src/http/tests.rs | 48 +++++++++++- backend/src/ranker.rs | 88 ++++++++++++++++------ web/index.html | 35 ++++++++- web/src/api.ts | 31 +++++++- web/src/cards.ts | 18 ++++- web/src/main.ts | 59 +++++++++++++-- web/src/station.ts | 155 ++++++++++++++++++++++++++++++++++++++ web/src/style.css | 145 ++++++++++++++++++++++++++++++++++- 13 files changed, 654 insertions(+), 42 deletions(-) create mode 100644 web/src/station.ts diff --git a/README.md b/README.md index d07712f..c6d9e61 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ visitor address reaches pl8list or a CDN. | drag waveform | drag waveform · ← → | seek | | follow button | f | follow the artist | | radio button | r | artist radio (more like them) | +| | v | liquid / simple visuals | | ✕ button | x | not for me (skip, show less) | | share button | s | share / copy link | | | m | mute | @@ -128,9 +129,14 @@ visitor address reaches pl8list or a CDN. Lock-screen, notification and headphone buttons control playback too (Media Session API). "likes" lists your likes newest first; ▶ plays one right away. The filters panel picks the -feed (for you, riddim, fresh, deep cuts); "radio" on a card starts an artist radio, and the chip -in the top bar goes back to your feed. Feed and filters (free only, BPM range) are remembered -per browser. The site can be installed to the home screen as an app. +feed (for you, riddim, fresh, deep cuts) and the visuals. "radio" on a card opens the radio +sheet: the artist, their tags and the artists next to them (tap one to walk over to theirs). +"start radio" jumps to the radio's first track; each radio track says why it is there, and the +chip in the top bar goes back to your feed. Feed and filters (free only, BPM range) are +remembered per browser. The site can be installed to the home screen as an app. + +Single-letter keys clash with vim-key browser extensions (Vimium and the like), which take +`r`, `x` and `f` before the page sees them; turn those off for the site. ## Front end @@ -145,8 +151,18 @@ per browser. The site can be installed to the home screen as an app. starts just before the current one ends. Both decks run through a Web Audio graph with an analyser. Not on iOS: its element volume is read-only and Web Audio stops with the screen locked, so there tracks switch with a cut and lock-screen playback keeps working. -- `visual.ts`: per frame, the waveform progress and a bass level (`--energy`) that pulses the - artwork and its glow. Without the analyser the level comes from the track's waveform. +- `features.ts`: what the music does, per frame: band levels against their recent range, + kick and snare onsets (spectral flux over an adaptive threshold), wobble in the mids, and + drops. A plain bass meter sits at full scale through a whole dubstep drop; these keep + moving. Without the analyser only loudness from the track's waveform is left. + `?debug=audio` shows them live, with onset counts. +- `blob/`: the liquid artwork. One WebGL2 canvas moves into the playing card: an icosphere + displaced by noise driven by those features (lobes, ripples, snare spikes, a ring after each + kick), showing the artwork through its surface. Tempo, tags and key set its character + (`blob/style.ts`). It steps its quality down on slow devices and falls back to the plain + artwork without WebGL2, with reduced motion, or with visuals set to simple. +- `visual.ts`: runs both per frame, plus `--energy` (kick-driven) for the CSS glow. +- `station.ts`: the radio sheet. - `color.ts`: each card's accent colour (`--card-accent`) from the artist image. - `cards.ts`, `api.ts`, `main.ts`: cards, server calls, and everything else. @@ -197,6 +213,11 @@ row. Weights live in a `Ranker`, which can also narrow the candidates and add a | `deep` | tracks with 15 saves or fewer; popularity counts against a track | | `artist&artist=` | artist radio: the artist and their neighbours in the artist graph (two hops) | +In an artist radio each track carries `why`: `seed`, `related` (a neighbour), or `via` with the +neighbour that leads to a second-hop artist. `/local/artist?handle=` returns an artist, their +top tags and closest neighbours for the radio sheet, and starts crawling the neighbourhood +(at most every 10 minutes per artist); `exploring` is true while there are fewer than three. + **Tags** (`backend/src/tags.rs`). pl8list has no genre field, so tags come from words in the title, description and artist name, and from the BPM: styles (`riddim`, `tearout`, `wonk`, `dnb`, …), versions (`vip`, `dub`, `dubpack`, `remix`, `flip`, `special`, `collab`, `wip` for v1/v2…) diff --git a/backend/src/crawler.rs b/backend/src/crawler.rs index 422d5b5..f3ded11 100644 --- a/backend/src/crawler.rs +++ b/backend/src/crawler.rs @@ -14,7 +14,7 @@ use std::{ cmp::Reverse, - collections::{BinaryHeap, HashSet}, + collections::{BinaryHeap, HashMap, HashSet}, sync::{Arc, Mutex}, time::Duration, }; @@ -50,6 +50,8 @@ const IDLE_DELAY: Duration = Duration::from_secs(20); // extra wait between requ const MIN_POOL: i64 = 500; // crawl at full pace until the catalogue has this many tracks const REFRESH_AFTER: f64 = 86400.0; // re-check an artist's newest page for uploads this often const RESEED_EVERY: Duration = Duration::from_secs(600); +/// An artist radio crawls its artist's neighbourhood at most this often. +const EXPLORE_EVERY: Duration = Duration::from_secs(600); #[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)] enum Job { @@ -73,6 +75,7 @@ pub struct Crawler { db: Db, queue: Mutex, wake: Notify, + explored: Mutex>, } fn s(v: &Value, k: &str) -> Option { @@ -86,6 +89,7 @@ impl Crawler { db, queue: Mutex::new(Queue { heap: BinaryHeap::new(), queued: HashSet::new(), seq: 0 }), wake: Notify::new(), + explored: Mutex::new(HashMap::new()), }) } @@ -150,6 +154,14 @@ impl Crawler { /// An artist radio was opened: crawl the artist, and the related pages of their /// best tracks that were not read yet, to fill in the artist graph around them. pub async fn explore_artist(&self, handle: String) { + { + let mut explored = self.explored.lock().unwrap_or_else(|e| e.into_inner()); + let now = Instant::now(); + explored.retain(|_, at| now.duration_since(*at) < EXPLORE_EVERY); + if explored.insert(handle.clone(), now).is_some() { + return; + } + } self.push(P_FEEDBACK, Job::Artist(handle.clone())); let h = handle.clone(); let tracks = self diff --git a/backend/src/db.rs b/backend/src/db.rs index edef170..09fb331 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -278,6 +278,36 @@ pub fn artist_neighbours(conn: &Connection, handle: &str) -> rusqlite::Result, + pub verified: bool, + pub tracks: i64, +} + +pub fn artist(conn: &Connection, handle: &str) -> rusqlite::Result> { + conn.prepare_cached( + "SELECT handle, artist_name, artist_img, artist_verified, COUNT(*) FROM tracks WHERE handle=? GROUP BY handle", + )? + .query_row([handle], |r| { + Ok(Artist { handle: r.get(0)?, name: r.get(1)?, img: r.get(2)?, verified: r.get(3)?, tracks: r.get(4)? }) + }) + .optional() +} + +/// An artist's most common style tags; tempo tags left out. +pub fn artist_tags(conn: &Connection, handle: &str, n: usize) -> rusqlite::Result> { + conn.prepare_cached( + "SELECT g.tag FROM track_tags g JOIN tracks t ON t.id = g.track_id \ + WHERE t.handle=? AND g.tag NOT GLOB '[0-9]*' GROUP BY g.tag ORDER BY COUNT(*) DESC, g.tag LIMIT ?", + )? + .query_map(params![handle, n as i64], |r| r.get(0))? + .collect() +} + /// How many tracks each artist has with `tag`. pub fn tag_counts(conn: &Connection, tag: &str) -> rusqlite::Result> { conn.prepare_cached( diff --git a/backend/src/http/app.rs b/backend/src/http/app.rs index 157c39e..57d9a4f 100644 --- a/backend/src/http/app.rs +++ b/backend/src/http/app.rs @@ -144,6 +144,47 @@ pub async fn event(State(app): St, Extension(current): Extension, J Ok((no_store(), Json(json!({ "ok": true }))).into_response()) } +/// How many neighbours the radio sheet shows, and how few count as still exploring. +const SHEET_NEIGHBOURS: usize = 12; +const FEW_NEIGHBOURS: usize = 3; + +/// An artist and the ones next to them in the artist graph, for the radio sheet. +/// Starts crawling the neighbourhood when it is new, so the sheet can poll while +/// `exploring` is true. +pub async fn artist(State(app): St, Extension(current): Extension, Query(q): Query>) -> Res { + let uid = match current.require() { + Ok(u) => u.id, + Err(r) => return Ok(r), + }; + let Some(handle) = q.get("handle").filter(|h| valid_handle(h)).cloned() else { + return Ok(StatusCode::BAD_REQUEST.into_response()); + }; + app.crawler.explore_artist(handle.clone()).await; + let body = app + .db + .call(move |c| { + let Some(artist) = db::artist(c, &handle)? else { return Ok(None) }; + let mut neighbours = vec![]; + for (h, weight) in ranker::scaled_neighbours(c, &handle)?.into_iter().take(SHEET_NEIGHBOURS) { + if let Some(a) = db::artist(c, &h)? { + neighbours.push(json!({ "handle": a.handle, "name": a.name, "img": a.img, "weight": weight })); + } + } + Ok(Some(json!({ + "followed": db::follows(c, uid)?.contains(&handle), + "tags": db::artist_tags(c, &handle, 4)?, + "exploring": neighbours.len() < FEW_NEIGHBOURS, + "artist": artist, + "neighbours": neighbours, + }))) + }) + .await?; + Ok(match body { + Some(b) => (no_store(), Json(b)).into_response(), + None => json_error(StatusCode::NOT_FOUND, "unknown artist"), + }) +} + /// Valid pl8list handles, as seen in every handle crawled so far. fn valid_handle(h: &str) -> bool { (1..=64).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) && h != "." && h != ".." diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs index c0d77ce..fe41516 100644 --- a/backend/src/http/mod.rs +++ b/backend/src/http/mod.rs @@ -316,6 +316,7 @@ pub fn router(app: Arc) -> Router { .route("/local/me", get(app::me)) .route("/local/feed", get(app::feed)) .route("/local/likes", get(app::likes)) + .route("/local/artist", get(app::artist)) .route("/local/event", post(app::event)) .route("/local/follow", post(app::follow)) .route("/account/export", get(account::export)) diff --git a/backend/src/http/tests.rs b/backend/src/http/tests.rs index e6c7a1c..2f0a690 100644 --- a/backend/src/http/tests.rs +++ b/backend/src/http/tests.rs @@ -1,6 +1,6 @@ //! End-to-end tests of the HTTP API against a fake pl8list. -use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use axum::{ Router, @@ -417,3 +417,49 @@ async fn feed_modes_filter_and_tag_tracks() { let res = get("/local/feed?mode=artist&artist=h").await.unwrap(); assert_eq!(json_body(res).await["tracks"].as_array().unwrap().len(), 1); } + +#[tokio::test] +async fn artist_sheet_and_radio_reasons() { + let (r, app, _) = setup("radio", "http://127.0.0.1:9").await; + let token = app + .db + .call(|c| { + let t = |id, handle: &str, title: &str| crate::db::Track { + id, + handle: handle.into(), + artist_name: handle.to_uppercase(), + slug: format!("s{id}"), + title: title.into(), + bpm: Some(140.0), + full_access: true, + ..Default::default() + }; + crate::db::upsert_tracks(c, &[t(1, "a", "Riddim one"), t(2, "b", "Two"), t(3, "c", "Three")], None)?; + crate::db::add_related(c, 1, &["b".into()])?; + crate::db::add_related(c, 2, &["c".into()])?; + crate::db::upsert_user(c, 5, "tester", None, None)?; + crate::db::create_session(c, 5) + }) + .await + .unwrap(); + let cookie = format!("__Host-pl8inf={token}"); + let get = |uri: &str| r.clone().oneshot(req("GET", uri, None, Some(&cookie), "203.0.113.1")); + + let res = get("/local/artist?handle=a").await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = json_body(res).await; + assert_eq!(body["artist"]["name"], "A"); + assert_eq!(body["tags"], serde_json::json!(["riddim"])); + assert_eq!(body["neighbours"][0]["handle"], "b"); + assert_eq!(body["exploring"], true); + assert_eq!(get("/local/artist?handle=..").await.unwrap().status(), StatusCode::BAD_REQUEST); + assert_eq!(get("/local/artist?handle=nobody").await.unwrap().status(), StatusCode::NOT_FOUND); + + let res = get("/local/feed?mode=artist&artist=a").await.unwrap(); + let body = json_body(res).await; + let why: HashMap = + body["tracks"].as_array().unwrap().iter().map(|t| (t["id"].as_i64().unwrap(), t["why"].clone())).collect(); + assert_eq!(why[&1], serde_json::json!({ "kind": "seed" })); + assert_eq!(why[&2], serde_json::json!({ "kind": "related" })); + assert_eq!(why[&3], serde_json::json!({ "kind": "via", "handle": "b", "name": "B" })); +} diff --git a/backend/src/ranker.rs b/backend/src/ranker.rs index 4c5d696..a226f8c 100644 --- a/backend/src/ranker.rs +++ b/backend/src/ranker.rs @@ -48,6 +48,10 @@ pub trait Ranker: Send + Sync { fn bonus(&self, _track: &Track) -> f64 { 0.0 } + /// Why a picked track is in this feed, sent along as `why`. + fn reason(&self, _track: &Track) -> Option { + None + } } /// The original mix. @@ -164,8 +168,7 @@ impl Ranker for Fresh { struct ArtistRadio { seed: String, - /// Neighbours, 0..1 by how closely they are linked. - near: HashMap, + near: HashMap, } const RADIO: Weights = Weights { artist: 0.5, related: 0.5, ..CLASSIC }; @@ -179,32 +182,57 @@ impl Ranker for ArtistRadio { } // The artist themselves now and then; mostly the ones around them. fn bonus(&self, t: &Track) -> f64 { - if t.handle == self.seed { 1.0 } else { 2.0 * self.near.get(&t.handle).copied().unwrap_or(0.0) } + if t.handle == self.seed { 1.0 } else { 2.0 * self.near.get(&t.handle).map_or(0.0, |n| n.score) } + } + fn reason(&self, t: &Track) -> Option { + if t.handle == self.seed { + return Some(json!({ "kind": "seed" })); + } + Some(match &self.near.get(&t.handle)?.via { + None => json!({ "kind": "related" }), + Some(via) => json!({ "kind": "via", "handle": via }), + }) } } -/// Neighbours of an artist up to two hops away, scored 0..1. A second-hop artist -/// scores at most 0.4 of the link that leads to it. -fn neighbourhood(conn: &Connection, seed: &str) -> rusqlite::Result> { - let scaled = |links: Vec<(String, i64)>| { - let max = links.iter().map(|l| l.1).max().unwrap_or(1).max(1) as f64; - links.into_iter().map(|(h, n)| (h, (n as f64 / max).sqrt())).collect::>() - }; - let first = scaled(crate::db::artist_neighbours(conn, seed)?); - let mut near: HashMap = first.iter().cloned().collect(); - let mut by_score = first; - by_score.sort_by(|a, b| b.1.total_cmp(&a.1)); - for (h, w) in by_score.into_iter().take(10) { - for (h2, w2) in scaled(crate::db::artist_neighbours(conn, &h)?) { - if h2 != seed { - let s = near.entry(h2).or_default(); - *s = s.max(0.4 * w * w2); +/// An artist near a radio's seed. +#[derive(Debug, PartialEq)] +pub struct Near { + /// 0..1 by how closely linked. + pub score: f64, + /// For a second hop: the seed's neighbour that leads here. + pub via: Option, +} + +/// Neighbours of an artist up to two hops away. A second-hop artist scores at most +/// 0.4 of the link that leads to it. +fn neighbourhood(conn: &Connection, seed: &str) -> rusqlite::Result> { + let first = scaled_neighbours(conn, seed)?; + let mut near: HashMap = first.iter().map(|(h, w)| (h.clone(), Near { score: *w, via: None })).collect(); + for (h, w) in first.into_iter().take(10) { + for (h2, w2) in scaled_neighbours(conn, &h)? { + if h2 == seed { + continue; + } + let score = 0.4 * w * w2; + let n = near.entry(h2).or_insert(Near { score: 0.0, via: Some(h.clone()) }); + if n.via.is_some() && score > n.score { + *n = Near { score, via: Some(h.clone()) }; } } } Ok(near) } +/// Direct neighbours of an artist, closest first, scored 0..1 against the closest. +pub fn scaled_neighbours(conn: &Connection, handle: &str) -> rusqlite::Result> { + let links = crate::db::artist_neighbours(conn, handle)?; + let max = links.iter().map(|l| l.1).max().unwrap_or(1).max(1) as f64; + let mut out: Vec<(String, f64)> = links.into_iter().map(|(h, n)| (h, (n as f64 / max).sqrt())).collect(); + out.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + Ok(out) +} + /// How strongly each artist is linked to `tag`, 0..1: their own tagged tracks (three /// or more count fully), or half of what the artists next to them in the graph have. pub fn tag_affinity(conn: &Connection, tag: &str) -> rusqlite::Result> { @@ -391,12 +419,27 @@ pub fn pick(conn: &Connection, user_id: i64, n: usize, f: Filters, ranker: &dyn let best = (0..scored.len()).max_by(|&a, &b| penalised(&scored[a]).total_cmp(&penalised(&scored[b]))).unwrap_or(0); let (s, r) = scored.remove(best); seen.push(r.handle.clone()); - out.push(serialize(&r, Some(s))); + let mut v = serialize(&r, Some(s)); + if let Some(why) = ranker.reason(&r) { + v["why"] = why; + } + out.push(v); } add_tags(conn, &mut out)?; + name_reasons(conn, &mut out)?; Ok(out) } +/// Artist names for reasons that point at another artist. +fn name_reasons(conn: &Connection, tracks: &mut [Value]) -> rusqlite::Result<()> { + for t in tracks { + if let Some(h) = t["why"]["handle"].as_str().map(String::from) { + t["why"]["name"] = json!(crate::db::artist(conn, &h)?.map_or(h, |a| a.name)); + } + } + Ok(()) +} + fn add_tags(conn: &Connection, tracks: &mut [Value]) -> rusqlite::Result<()> { for t in tracks { if let Some(id) = t["id"].as_i64() { @@ -509,8 +552,9 @@ mod tests { db::add_related(&mut c, 1, &["b".into()]).unwrap(); db::add_related(&mut c, 2, &["c".into()]).unwrap(); let near = neighbourhood(&c, "a").unwrap(); - assert_eq!(near["b"], 1.0); - assert!((near["c"] - 0.4).abs() < 1e-9); + assert_eq!(near["b"], Near { score: 1.0, via: None }); + assert!((near["c"].score - 0.4).abs() < 1e-9); + assert_eq!(near["c"].via.as_deref(), Some("b")); assert_eq!(ids(&c, Mode::Artist("a".into()), 10), [1, 2, 3]); } diff --git a/web/index.html b/web/index.html index 4c9f159..9c33e71 100644 --- a/web/index.html +++ b/web/index.html @@ -31,7 +31,9 @@