Skip to content

When a DJ is mixing tunes they always sounds better when they are in key – That can mean exactly the same key or a complimentary key that works in harmony. This can also be used to increase (or decrease) the energy in a set. After beat-matching it’s probably the next most important technique that a DJ needs to learn. There is one tool that’s pretty much universally used for this: The Camelot DJ Wheel, and here it is:

Most DJ software these days will key up your tracks when you import them, and when you buy them from Beatport or whatever, they will usually supply the key as well. All you need to do is just refer to this when choosing your next tune… But there are options: you don’t always have to choose the same or adjacent key to the current one.

So the other day I thought I would make a little web app version of this so that I could have it on my phone while I mix and instantly see exactly what keys are available to me for the next tue.

Keep it simple!

This is actually a really basic app: no login, backend or database – just some JavaScript calculations and CSS to lay it out nicely. And even the calculations aren’t that tricky a key is just a (number, letter) pair, and “what mixes” falls straight out of doing sums on that pair: If you’ve picked 3A, you can see 2A or 4A or 4B. Basically addition and subtraction.

function getCompatibleKeys(num, letter) {
  const results = [];
  const push = (n, l, relation, tier) => {
  results.push({ id: keyId(mod12(n), l), relation, tier });
};

  // safe: relative major/minor, and the perfect-fifth neighbours either side
  push(num, otherLetter(letter), 'Relative', 1);
  push(num + 1, letter, 'Neighbour +1', 1);
  push(num - 1, letter, 'Neighbour -1', 1);

  // advanced, behind a toggle: energy moves and the diagonal mood shift
  push(num + 2, letter, 'Energy Lift (+2)', 2);
  push(num - 2, letter, 'Energy Drop (-2)', 2);
  push(num + 7, letter, 'Semitone Up (+7)', 2); // classic peak-time trick
  push(num - 7, letter, 'Semitone Down (-7)', 2);
  push(num + 1, otherLetter(letter), 'Diagonal +1', 2);
  push(num - 1, otherLetter(letter), 'Diagonal -1', 2);

  return results;
}

Everything past this basic stuff (±2 for an energy lift, ±7 for a semitone shift, the diagonal move) sits behind an “advanced moves” toggle so the obvious choices don’t get buried under the clever ones. The whole compatibility engine is about thirty lines.

Design and UI

The interesting bit wasn’t the logic, though, it was making it look and work nicely. The first POC was just a flat list — tap a key, get a result. It worked, but it didn’t feel like *the* wheel every DJ already knows. So the real version renders the full 24-key wheel at its actual angles and then parks the centre of that circle off-screen to the left, so only a curved sliver of it — your current key plus a couple of neighbours either side, fading and shrinking the further round the curve — actually shows on screen. Swipe it (or use the up/down arrows) and it spins with real momentum, then settles with an actual spring simulation rather than a canned CSS easing curve, which is what gives it that slight overshoot-and-bounce-back on release instead of a dead stop:

function animateRotationTo(target, initialVelocity = 0) {
  let pos = state.rotation, vel = initialVelocity;
  const stiffness = 180, damping = 16;
  let lastTime = performance.now();

  function step(now) {
    const dt = Math.min((now - lastTime) / 1000, 0.032);
    lastTime = now;
    const force = -stiffness * (pos - target) - damping * vel;
    vel += force * dt;
    pos += vel * dt;
    state.rotation = pos;
    renderWheelPositions();

    if (Math.abs(pos - target) > 0.15 || Math.abs(vel) > 3) {
      requestAnimationFrame(step);
    } else {
      state.rotation = target;
      renderWheelPositions();
    }
  }
  requestAnimationFrame(step);
}

It’s a proper mass-spring-damper running every frame rather than an easing function — pull towards the target, subtract a bit of velocity for damping, integrate, repeat until it’s basically stopped. That’s the whole “rubber band” feel in about ten lines.

Gotcha!

Worth pointing out for any future readers… The same drag handling almost shipped a bug where tapping a key just silently didn’t work with no errors in the console. Turned out to be setPointerCapture being called the instant you touched the wheel “just in case” it became a swipe, which quietly breaks Chromium’s native click dispatch on whatever button was underneath your finger. Fixed by only grabbing pointer capture once real movement is detected.

Finishing touches

Once the wheel felt good, there were just a few more little tweaks to make it either look nice, or be a bit more usable from a DJ perspective, e.g. most DJ software doesn’t write “A minor” or “C major” — major keys just get the bare note name (C), minor keys get a trailing m (Am) — so the label table got rewritten to match this, with a toggle to flip which label (Camelot code or note notation) is displayed in a slightly larger font.

And here it is! Wheely Good DJ.

There’s also a github repo if you want to have a look at the code.

Ideas for the future

Next up is something a bit trickier, but which I’d like to do as a little project to myself more than anything else: work out the current key from the microphone instead of selecting it on the UI. Something like this:

  • Listen
  • Run an FFT
  • Fold the spectrum into a 12-bin chromagram
  • Correlate it against the profile of all 24 keys
  • Pick the winner — and a library like `essentia.js` gets you there fast with a key-detector that’s actually tuned for electronic music rather than orchestral recordings.

The main problem is the microphone itself and how well a phone would be able to pick up the music. Also a browser’s default echo-cancellation, noise-suppression and auto-gain settings all mangle the exact frequency content the algorithm needs — every one of these has to be explicitly switched off in the mic constraints. It might not work too well in a club, but for a bedroom DJ it might be acceptable.