High Life Records

Build Guide — How this site was made

One seed, two senses.

Every sleeve on this site is drawn by a single generative algorithm, and every turntable loop is synthesized by a single audio engine — and for any one release, both are driven by the exact same seed. This page explains the whole chain: the math behind the cover art, the scheduling behind the sound, and the three passes that got it here.

Concept & creative direction

The brief for this slot in the collection was explicit: fill the portfolio's music-and-sound gap with generative covers and a playable turntable. The creative risk was doing both without either one feeling bolted on — a lot of "generative art" sites treat sound and image as separate demos. The unifying idea here is a single per-release seed string (its catalog number, e.g. HLR-003-HARBOUR-LIGHTS) that drives both outputs from the same rhythmic source material, so the sleeve you see and the loop you hear are visibly and audibly the same idea in two media.

  • 70s sleeve energy, not pastiche. Burnt orange, cream, deep brown and mustard — four colors, procedurally recombined per release rather than hand-picked per sleeve, so the crate reads as one label's house style, the way real 1970s Ghanaian pressings shared a printer and a palette.
  • The rhythm is the content, not the decoration. Rather than invent an abstract generative pattern, the cover's central ring of dots and the audio's bell part both trace one of three real 12-pulse West African bell/timeline patterns — the "standard bell pattern" family found across highlife, adowa and related repertoires — rotated per seed.
  • Opt-in sound, always. The collection's rule (§5 of the shared foundation) is that sound is never automatic. Here that's structural: the AudioContext is not even constructed until a visitor presses a sleeve's play button, which is also the gesture browsers require to unlock audio in the first place — the constraint and the policy line up for free.

Toolchain

Designed and engineered by Fable 5 (Anthropic's model) as designer-engineer, directed by Hannah Kwakye — her taste and final cut, Fable 5's hands. As with every site in the collection:

  • Static, hand-authored HTML, CSS and JavaScript. No framework, no bundler, no build step — what you're reading in the browser's dev tools is exactly what shipped.
  • Zero libraries. The Sleeve Engine is Canvas 2D; the turntable loop is the raw Web Audio API; the crate's drag-and-snap is Pointer Events plus native CSS scroll-snap; reveals use IntersectionObserver. One JavaScript file, roughly 480 lines, in three clearly commented sections.
  • Two self-hosted fonts as woff2: Abril Fatface (sleeve display) and variable-weight Hanken Grotesk (UI and body), both preloaded with font-display: swap. No request ever leaves the origin.
  • No canvas ever redraws itself in a loop. Covers are painted once; the spinning you see on the hero disc and the turntable platter is a CSS transform: rotate() animation on the already-rendered canvas element — cheap, GPU-composited, and trivially disabled for reduced motion.

The Sleeve Engine — the generative cover algorithm

Every album cover on this site — the six in the crate, the label mark spinning in the hero, and the small vinyl labels on the turntable — is painted by the same function, renderCover(), seeded differently per release. Nothing is random in the JavaScript Math.random() sense; everything comes from a seeded pseudo-random number generator so the exact same seed always produces the exact same sleeve.

Step 1 — turn a string into a number, then a stream of numbers

A catalog string like HLR-004-SIX-STRINGS is hashed to a 32-bit integer with a small cyrb-style string hash, then fed into mulberry32, a compact, fast, public-domain PRNG well suited to this kind of one-off generative art:

function hashSeed(str) {
  let h = 1779033703 ^ str.length;
  for (let i = 0; i < str.length; i++) {
    h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
    h = (h << 13) | (h >>> 19);
  }
  h = Math.imul(h ^ (h >>> 16), 2246822507);
  return (h ^= h >>> 16) >>> 0;
}

function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

Calling mulberry32(hashSeed(seedString)) once returns a function you can call repeatedly for a reproducible stream of numbers between 0 and 1 — the same string always produces the same stream, which is what makes the same catalog number always draw the same sleeve, forever, with no image file stored anywhere.

Step 2 — a highlife bell pattern, shared with the audio engine

Before anything is drawn, the algorithm picks one of three canonical 12-pulse bell/timeline patterns — the family of "standard bell patterns" you'll hear from an agogo or gankogui across highlife, adowa and related West African dance-band repertoires — and rotates its starting point using its own seeded draw:

const BELL_PATTERNS = [
  [1,0,0,1,0,0,1,0,1,0,0,1],
  [1,0,1,0,0,1,0,1,0,0,1,0],
  [1,0,0,1,0,1,0,0,1,0,1,0],
];

function getBellPattern(seedStr) {
  const rng = mulberry32(hashSeed(seedStr + ':bell'));
  const base = BELL_PATTERNS[Math.floor(rng() * BELL_PATTERNS.length)];
  const rot = Math.floor(rng() * base.length);
  return base.slice(rot).concat(base.slice(0, rot));
}

This function is called from both renderCover() and the audio engine's play(), with the same seed string, so the ring of dots you see on a sleeve is the literal same 12-step array that triggers the bell hits you hear when that release plays — the visual rhythm and the audible rhythm are one data structure, not two designers' approximations of each other.

Step 3 — layer the sleeve

With the palette and bell pattern seeded, renderCover() paints six layers onto a square canvas (device-pixel-ratio capped at 2, so it stays sharp without over-rendering):

  • Gradient sweep — a diagonal linear gradient between two of the four rotated palette colors, standing in for the printed sleeve stock.
  • Sunburst rays — 8, 12, 16 or 24 rays (seeded), rotated to a random start angle, alternating the two accent colors and two stroke weights — a nod to the radiating label-sticker starbursts common on 1970s pressings.
  • Groove rings — three to six faint concentric circles, echoing the sleeve's own record inside it.
  • The bell ring — the shared 12-step pattern from Step 2, rendered as a ring of struck dots at the pattern's active steps, each with a seeded radius and a thin mustard halo.
  • The center label — a filled disc carrying the release's initials in Abril Fatface and its catalog number set in an arc along the lower rim, exactly where a pressed 45 carries its paper label.
  • Print grain — roughly 160 one-pixel speckles at low, seeded opacity, so the flat gradient reads as ink on stock rather than a screen gradient — the difference between a sleeve you'd frame and one you'd swipe past.

The same function, called at a smaller size with a :label salt on the seed, produces the tighter label-only artwork you see spinning on the turntable's platter — related to its sleeve by palette and bell pattern, but distinct enough to read as the disc inside the jacket.

Try it — the seed lab

Type any text and press generate — the same renderCover() function used across the whole site will paint whatever seed you give it. Try the six catalog numbers from the crate, or your own name.

No canvas is ever stored — this redraws live, client-side, from the text above.

The turntable — Web Audio synthesis

Pressing a sleeve's "Play on the turntable" button calls Engine.play(release), which builds a short highlife-flavored loop entirely from oscillators and a noise buffer — there are no audio files anywhere in this repository. The engine is a small step-sequencer using the classic look-ahead scheduling pattern (scheduling notes a little ahead of real time on a timer, rather than relying on setTimeout directly, which drifts):

timer = setInterval(() => {
  while (nextTime < ctx.currentTime + scheduleAhead) {
    const s = stepIdx % steps;
    if (bell[s]) pluck(1500 + (s % 2 ? 70 : 0), nextTime, 0.09, 'square', 0.05, 4200);
    if (s === 0 || s === 6) pluck(rootFreq / 2, nextTime, 0.42, 'sine', 0.3);
    if (arp[s]) pluck(rootFreq * chordRatios[...], nextTime, 0.22, 'triangle', 0.13, 2600);
    if (s % 2 === 1) shakerHit(nextTime, 0.045);
    nextTime += stepDur;
    stepIdx++;
  }
}, 25);

Four voices per step, each a plain oscillator or noise burst shaped with a fast exponential envelope to sound plucked rather than sustained:

  • Bell — a short square-wave blip at the same 12-step pattern drawn on the sleeve. This is the one part of the mix that is identical data between eye and ear.
  • Bass — a low sine an octave below the release's root frequency, plucked on the two strongest beats of the bar, standing in for the upright or electric bass a highlife rhythm section leans on.
  • Guitar — a triangle wave through a lowpass filter, arpeggiating a highlife-flavored major chord (root, major third, fifth, and — the genre's signature color — the major sixth) across a seeded on/off pattern, echoing the interlocking guitar lines of palm-wine and guitar-band highlife.
  • Shaker — a short burst from a one-shot white-noise buffer through a bandpass filter, landing on the off-beats for the "chick" that holds the groove together.

Each release supplies its own tempo (beats per minute) and rootFreq (the key the guitar and bass arpeggiate around), so the six loops are recognizably different songs, not the same loop pitched differently. The step duration divides each beat into three60 / tempo / 3 — giving the loop a 12/8, triplet-driven swing rather than a straight 4/4 grid, which is closer to how a highlife rhythm section actually subdivides time.

A single master GainNode sits between every voice and the speakers. The volume slider ramps its gain smoothly with setTargetAtTime (no audible zipper noise); the Stop button clears the scheduling timer and ramps the same gain down to silence — the "clear stop control" the brief calls for is one function, not a fade left to chance.

Accessibility & motion

  • Semantic landmarks throughout, one <h1>, a skip-to-content link, and visible :focus-visible rings on every interactive element, including the crate's drag surface and the turntable's Stop button.
  • The turntable's status line is an aria-live="polite" region, so screen reader users hear "Now playing: Harbour Lights by The Sea Never Dry Orchestra" the moment playback starts, without needing to see the platter spin.
  • Under prefers-reduced-motion: reduce, the hero disc stops rotating, the platter never spins, and the tonearm snaps rather than eases — but the audio engine is untouched, since sound is a separate, always-opt-in layer from motion. Pressing play still plays; it just doesn't spin anything while it does.
  • Every canvas caps its device-pixel-ratio at 2 and paints once — there is no per-frame redraw loop anywhere on the site to pause or throttle.

Iteration log

Pass 1 — Design critique

  • Shot mobile/tablet/desktop captures of all three routes and read them closely. The hero, crate and roster held up well across breakpoints — kept the generative disc large and above the fold on mobile rather than shrinking it into an afterthought.
  • Caught a real CSS bug this way: the turntable's physical deck panel and the article-hero's subtitle paragraph both used the class .deck. Because every page shares one stylesheet, the turntable's dark rounded-panel styling was bleeding onto the /guide and /process subtitles, making them nearly unreadable. Renamed the turntable's wrapper to .tt-deck / .tt-deck-plate — a reminder to keep utility-ish class names scoped even in a single stylesheet.
  • A first full-page screenshot showed the crate, turntable, roster and story sections rendering blank. Traced it to the reveal-on-scroll system: those sections only populate once IntersectionObserver actually fires as a visitor scrolls, and a full-page capture never scrolls. Confirmed the real content is fine via scrolled viewport captures — a screenshot-timing artifact, not a site bug.
  • Rebalanced contrast: burnt orange is used only on large display type and iconography (~3:1 against cream), never on small body copy; mustard and cream carry body text on the dark panels instead.

Pass 2 — Elevation

  • Deepened the signature interaction: wired getBellPattern() so the exact same seeded 12-step array drives both the cover's ring of dots and the audio engine's bell voice — the change that makes "one seed, two senses" literally true rather than aspirational copy.
  • Found and fixed a genuine interaction bug while scripting click tests against the crate: the drag-to-scroll handler called setPointerCapture() on every pointerdown, which retargets the browser's subsequent click event to the capturing element — silently swallowing every "Play on the turntable" press the moment a visitor's pointer landed inside the crate. Instrumenting click/pointerdown listeners showed the event arriving at crate-track instead of the button. Rewrote the handler to only arm dragging — and only call setPointerCapture — once movement crosses a 6px threshold, so a plain click or tap reaches the button while drag-scrolling still works exactly as before.
  • Added the seed lab to the guide page, reusing the live renderCover() function, so the algorithm is demonstrable, not just described.
  • Verified reduced motion end-to-end: hero disc and platter stop spinning, reveals resolve instantly, and — because motion and sound are independent opt-ins — a release still plays correctly on request in that mode.

Pass 3 — Ship quality

  • Scripted the full play → stop lifecycle (button press, now-playing status text, the aria-live announcement, the Stop button's disabled state) end to end to confirm the pointer-capture fix held and playback starts and stops cleanly.
  • Confirmed zero console errors across mobile/tablet/desktop captures for /, /guide, and /process via tools/shot.js.
  • Verified every internal link, the demo form's honeypot and success state, and the guide/process cross-links resolve correctly across all three routes.
  • Confirmed both self-hosted fonts clear the 10KB sanity floor (Abril Fatface ≈13KB, variable Hanken Grotesk ≈34KB) and that no request leaves the origin.

Deploy pipeline

Static output, deployed on Netlify straight from this repository: netlify.toml sets publish = "." and adds long-cache headers for /assets/* plus baseline security headers (frame options, content-type sniffing protection, referrer policy) for every route. The demo form posts to Netlify Forms with a honeypot field; there is no server code anywhere in this site. Read the reasoning behind the brand and format in the design process, or go back and flip the crate.