Library / Tutorial

Vinyl deck,
step by step.

A turntable that really plays in the browser: a concept image sliced into layers, one motor model that drives both the platter and the needle on the audio thread, a tonearm on a cue lever, scratching, a pitch fader, 33/45, VU meters and a printed label. Ten stages, each a live page you can use.

10Stages
1Speed model
0Libraries

This is the whole build of Vinyl deck, in the order it was written. It is the first screen of a small record label's website, Slow Burn Records, and its hero is a turntable you can really use. Press START/STOP and the platter spins up; press it again and the music winds down with it. Hold the record and it stops; drag it and it scratches. Lift the arm and drop it anywhere on the side, or press a track and the arm finds the groove. The fader bends the pitch ±8 %, the switch changes 33 to 45, the meters read the music, and the record's label is printed with the track that is playing.

There are ten stages. Each one is a real page; press Run it here under a stage to load it in place, or Open ↗ to have it in a tab. The code under each heading is only what that stage adds. There is no library and no build step: index.html, style.css and one small script per job, each a global the next one reads (geometry.js, motor.js, audio.js with worklet.js, deck.js, label.js, app.js), then data.js, sections.js and sections.css for the page below the deck. The stage pages share the pictures and the music one folder up, so their paths start ../../img/, ../../art/ and ../../audio/; in the finished source those folders sit beside the page.

The turntable and the sleeve are pictures: the deck's layers are sliced from a concept image generated with Google Gemini. The music (Side A, eight short pieces) is original, made for this demo. The label, its artists and its records are fictional. Everything that moves, and every sound the needle makes, is code.

1 · The layers: one image, sliced

The page started as one approved concept image, 2000 × 1244 px: a dark hero, a turntable, a sleeve with a record half out of it and a Now-playing panel. It was cut into layers once, offline, and the page puts them back together. The deck plate is one picture with the moving parts taken out. The platter (the record and the strobe dots on its rim) is its own layer, and so are the label, the spindle, the tonearm and its bearing, and the controls. Two more layers are the light on the platter, a colour-dodge and a multiply, and they never turn. That is why the strobe dots can slide under a red lamp and a gold glint that stay where they are.

index.html — the deck, layer by layer (the controls follow)
<section class="deck" id="deck" aria-label="Turntable — Side A">
  <img class="deck-img" src="../../img/deck.webp" alt="" width="1478" height="1130" draggable="false">
  <img class="arm-base under" alt="" draggable="false" hidden>   <!-- the concept arm's bearing assembly (app.js sets the picture) -->
  <!-- the platter: the strobe ring and the label turn; the light on them does not -->
  <div class="ring" id="ring" aria-hidden="true"><img src="../../img/ring.webp" alt="" draggable="false"></div>
  <img class="ring-light dodge" src="../../img/ring-dodge.webp" alt="" aria-hidden="true" draggable="false">
  <img class="ring-light mult" src="../../img/ring-mult.webp" alt="" aria-hidden="true" draggable="false">
  <div class="label" id="label" aria-hidden="true">
    <img class="label-print" src="../../img/label.webp" alt="" draggable="false">
  </div>
  <img class="spindle" src="../../img/spindle.webp" alt="" aria-hidden="true" draggable="false">

Every layer is placed in the concept's own pixels, so the numbers can be read straight off the image. The unit is --m, one concept pixel. On a desktop it is the window's width over 2000 (up to 1600 px), or less if the screen is short, so the whole deck always fits the first screen.

style.css — the tokens, and --m
:root {
  /* primitive — sampled from the concept */
  --cream-page: #EFE6D5; --cream-card: #F8F2E4; --ink-hero: #232323; --ink-900: #1E1E1E; --ink-600: #5E5A52;
  --hero-text: #F1E7D2; --nav-text: #CFC8BA; --nav-on: #F4EEE2;
  --np-text: #E2E0DC; --np-muted: #9C9A95; --np-row-on: #474644; --np-row-edge: rgb(255 255 255 / .07);
  --led-on: #6CFF7E; --needle: #1B1714; --scale-ink: #3B3936;
  --focus: #F5B54A;
  /* semantic */
  --page: var(--cream-page); --card: var(--cream-card); --on-card: var(--ink-900); --on-card-muted: var(--ink-600);
  --serif: 'Newsreader', 'Times New Roman', serif;
  --sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
  /* one concept px: the concept fills the width (up to 1600 px) unless the screen is short — then the whole deck
     (concept y 0 … 1090, with a little air) still fits the first screen */
  --m: min(calc(min(100vw, 1600px) / 2000), calc((100svh - 14px) / 1098));
}

Inside the deck, --m is worked out again from the deck's own width, with container units. The deck is 985 concept px wide, so 100cqw / 985 is one concept pixel whatever size the deck is drawn at. That lets the phone layout move the whole block without touching a single coordinate inside it.

style.css — the deck block
.deck {
  position: absolute; left: calc(305 * var(--m)); top: calc(337 * var(--m)); width: calc(985 * var(--m)); aspect-ratio: 985 / 753;
  container-type: inline-size; isolation: isolate; touch-action: pan-y; user-select: none; -webkit-user-select: none;
}
.deck > * { --m: calc(100cqw / 985); position: absolute; }
.deck-img { inset: 0; width: 100%; height: 100%; pointer-events: none; }

The platter and the label are placed by their centre and radius. The photo looks down on the deck at a slight angle, so the round record is a little squashed: the scale property holds that squash (0.966 for the ring), apart from the transform that will turn it in stage 2. A turn inside a squash looks like a real turning record; a squash inside a turn would wobble.

style.css — the platter, the label and the light on them
.ring, .label {
  left: calc((var(--cx) - var(--r) - 305) * var(--m)); top: calc((var(--cy) - var(--r) - 337) * var(--m));
  width: calc(2 * var(--r) * var(--m)); height: calc(2 * var(--r) * var(--m));
  scale: 1 var(--k); will-change: transform; pointer-events: none;     /* app.js writes transform: rotate(); the squash stays outside it */
}
.ring img, .label img { position: absolute; inset: 0; width: 100%; height: 100%; }
.ring { --cx: 697.3; --cy: 705.5; --r: 360; --k: .966; }
.label { --cx: 697.5; --cy: 702; --r: 113; --k: .977; }
.ring-light { left: calc((336.667 - 305) * var(--m)); top: calc((357.333 - 337) * var(--m)); width: calc(720.667 * var(--m)); height: calc(696 * var(--m)); pointer-events: none; }
.ring-light.dodge { mix-blend-mode: color-dodge; }
.ring-light.mult { mix-blend-mode: multiply; }

The same numbers live in geometry.js for the scripts: the deck's box, the spindle, the record's playable radius, and the arm. There are two arms. The concept's own arm is the default; ?arm=ours puts a chrome S-arm in its place. Each has its bearing P, its length from the bearing to the stylus, the angle its picture was drawn at, the radius where it parks, and how far its shadow falls.

geometry.js — the deck in concept px
const DECK_BOX = { x: 305, y: 337, w: 985, h: 753 };     // the deck block on the concept
const SPINDLE = { x: 697.5, y: 702 };
const RECORD_R = 322;
const ARMS = {
  // rest: the stylus radius where the arm parks. The concept's arm parks just past the vinyl, over the platter's rim —
  // any further out and its headshell would sit on the VU meters
  concept: { P: { x: 1130.8, y: 482.5 }, L: 499.62, THETA0: 124.752, rest: 334, shadow: { low: [5, 8], high: [11, 17] } },   // tools/arm_geo.json
  ours: { P: { x: 1127.95, y: 523.16 }, L: 482.1, THETA0: -177.457, rest: 372, shadow: { low: [-6, 10], high: [-11, 19] } },
};
const ARM_NAME = new URLSearchParams(location.search).get('arm') === 'ours' ? 'ours' : 'concept';
const ARM = ARMS[ARM_NAME];
const BEARING = ARM.P, ARM_L = ARM.L, THETA0 = ARM.THETA0;    // THETA0 in degrees
app.js — the arm's pictures
(function mountArm() {
  const concept = G.ARM_NAME === 'concept';
  deck.dataset.arm = G.ARM_NAME;
  armImg.src = concept ? '../../img/carm.webp' : '../../img/arm.webp';
  const base = $(concept ? '.arm-base.under' : '.arm-base.over');
  base.src = concept ? '../../img/carm-base.webp' : '../../img/arm-base.webp';
  base.hidden = false;
  [armImg, base].forEach((img) => img.decode().catch(() => {}));   // decoded before the first lift
})();
style.css — the concept arm turns about its bearing
.deck[data-arm="concept"] .arm {
  left: calc((828.333 - 305) * var(--m)); top: calc((377.667 - 337) * var(--m)); width: calc(390.667 * var(--m)); height: calc(551.333 * var(--m));
  transform-origin: calc(302.467 * var(--m)) calc(104.833 * var(--m));
}

On a phone, or a tablet held upright, the blocks stack: the hero, then the deck at full width, then the panel. --m becomes 1 px outside the blocks, and inside them it still comes from their own width.

style.css — phones and upright tablets
@media (max-width: 760px), (orientation: portrait) and (max-width: 1100px) {
  :root { --m: 1px; }
  .stage { width: auto; padding: 10px; }
  .backdrop { display: none; }
  .hero { position: relative; height: auto; background: var(--ink-hero); border-radius: 14px; padding: 20px 16px 34vw; }
  .nav { position: static; display: flex; justify-content: center; gap: 15px; flex-wrap: wrap; }
  .nav a { position: static; font-size: 10.5px; padding: 3px 0 5px; }
  .nav a[aria-current="page"] { box-shadow: inset 0 -1px 0 var(--nav-on); }
  .headline { position: static; margin-top: 24px; font-size: clamp(24px, 7vw, 30px); line-height: 1.1; }
  .headline span { display: inline; height: auto; }
  .deck { position: relative; left: 0; top: 0; width: 100%; margin-top: -30vw; box-shadow: 0 18px 30px -12px rgb(40 26 12 / .55); }
  /* … the side below it, the text, the footer */
}
Stage 1 · the layers, put back together: nothing moves yetOpen ↗
The Slow Burn Records page at desktop size: the turntable, its sleeve and the empty Now-playing panel, assembled from layers, nothing moving

2 · The platter turns: one motor model

A real platter does not jump to speed. It spins up, and when you press STOP it brakes. Change 33 to 45 and the belt glides. All of that is one small model in motor.js: rate = motor × speed × pitch, where 1 means 33⅓ rpm. motor goes from 0 to 1 when the motor is on, speed is 1 or 45/33⅓, and pitch is 1 ± 8 %. START, STOP, the switch and the fader never set the rate. They only set goals, and the model moves towards them.

motor.js — the constants
const SPIN_UP = 0.6, BRAKE = 1.4;            // seconds from rest to 45 rpm speed and back
const MOTOR_K = 9;                           // 1/s: the approach once the slew limit lets go
const SPEED_TAU = 0.4;                       // s: 33 ↔ 45
const PITCH_TAU = 0.09;                      // s: the fader
const RPM_45 = 45 / (100 / 3);
const SNAP = 1e-5;                           // the motor locks onto its goal this close (too small to hear)

From rest to 45 rpm speed takes 0.6 s, and the brake takes 1.4 s from there. The motor is slew-limited (it can change only so fast) and then closes on its goal exponentially at 9 per second, so it lands softly. Speed and pitch are one-pole glides, 0.4 s and 0.09 s. Every update is the exact solution over its step, so the model gives the same curve when it is stepped every frame (1/120 s) and when it is stepped every sample (1/48000 s). That matters in stage 3, where it runs in both places.

motor.js — one step
step(dt) {
  if (this.calm) return this.rate();
  if (dt !== this.dt) {                    // the audio thread steps a fixed dt: no exp() per sample
    this.dt = dt;
    this.kMotor = 1 - Math.exp(-dt * MOTOR_K);
    this.kSpeed = 1 - Math.exp(-dt / SPEED_TAU);
    this.kPitch = 1 - Math.exp(-dt / PITCH_TAU);
  }
  const goal = this.on ? 1 : 0, diff = goal - this.motor;
  const slew = (RPM_45 / (this.on ? SPIN_UP : BRAKE)) * dt;
  const dv = Math.abs(diff) < SNAP ? diff : diff * this.kMotor;
  this.motor += Math.max(-slew, Math.min(slew, dv));
  this.speed += (this.speedGoal - this.speed) * this.kSpeed;
  this.pitch += (this.pitchGoal - this.pitch) * this.kPitch;
  return this.rate();
}

deck.js holds the physics and no DOM. 33⅓ rpm on screen is a blur, so the picture turns slower than a real deck: 10 turns a minute stand for 33⅓. Each frame the model is stepped, the visual speed is ω = OMEGA × rate, and the angle is ω added up over the frame's time.

deck.js — what 33⅓ looks like
const BASE_VISUAL_RPM = 10;                                  // what 33⅓ looks like on screen
const OMEGA = (2 * Math.PI * BASE_VISUAL_RPM) / 60;          // visual rad/s at rate 1
deck.js — START/STOP only set goals
// the motor's goals (stage 3 sends them to the audio thread's copy of the model too)
function drive(goals) { st.drive.set(goals); }
drive({ calm: reduce.matches });
reduce.addEventListener('change', () => drive({ calm: reduce.matches }));

// ------------------------------------------------------------------ actions
function setMotor(on) { st.motor = on; drive({ on }); }
function play() { setMotor(true); }
function stop() { setMotor(false); }
function toggle() { if (st.motor) stop(); else play(); }
deck.js — the platter, every frame
function spinPlatter(dt) {
  st.omega = OMEGA * st.drive.step(dt);
  st.angle += st.omega * dt;
}

app.js runs the clock and draws. The platter's angle goes to the ring and the label as a transform: rotate(), and only when the text of it changed. Each layer is promoted with will-change, so a turn is just the compositor moving a texture: no layout and no paint.

app.js — the platter's transform
// the platter: its angle (stage 4 adds a small shudder when the needle lands)
function platterTransform(now) {
  return `rotate(${((st.angle / RAD) % 360).toFixed(2)}deg)`;
}

function drawDeck(now) {
  const platter = platterTransform(now);
  if (platter !== view.platter) { ring.style.transform = platter; label.style.transform = platter; view.platter = platter; }
}

The status line reads "33⅓ rpm" when the platter holds its speed, and the live rpm while it ramps. Text that changes makes the browser lay the panel out again, so the live readout changes at most 4 times a second, like a real display.

app.js — the speed, in words
// the speed: nominal (and pitch) while the platter holds it, the actual rpm while it ramps or a hand holds it (4 Hz)
function speedWords(now) {
  if (!D.steady(STEADY)) {
    if (now - view.readoutAt < READOUT_MS) return view.rpm;     // text changes re-lay the panel out: not every frame
    view.readoutAt = now;
    return `${(Math.abs(D.rate()) * 100 / 3).toFixed(1)} rpm`;
  }
  const nominal = st.speed > 1 ? '45 rpm' : '33⅓ rpm';
  return st.pitch ? `${nominal} ${signedPercent(st.pitch * 100)}` : nominal;
}

The clock caps a frame's step at 50 ms, so a tab that comes back from the background does not leap. With prefers-reduced-motion the model has no ramps: calm sets every value to its goal at once.

app.js — the clock
let last = performance.now();
function tick(now) {
  const dt = Math.min(MAX_DT, Math.max(0, (now - last) / 1000)); last = now;
  D.advance(dt, now);
  draw(now);
  requestAnimationFrame(tick);
}
Stage 2 · press START/STOP; the platter spins up and brakesOpen ↗
The turntable a moment after START: the green LED on and the status line reading the live speed while the platter spins up

3 · The sound: a needle on the audio thread

A deck plays the music at the speed the record turns: slower, faster, stopped, backwards. So the music cannot be an <audio> element with a playback rate. It is read sample by sample, at a moving position, by an AudioWorklet: code that runs on the browser's audio thread. First the side has to be ready. It is decoded when the page loads, while it is idle, by an OfflineAudioContext (no user gesture needed for that), and copied into two arrays in slices, so no frame stalls.

audio.js — decode the side at idle
async function decodeSide() {
  const bytes = await fetch(url).then((r) => { if (!r.ok) throw new Error(`side-a: ${r.status}`); return r.arrayBuffer(); });
  const Offline = window.OfflineAudioContext || window.webkitOfflineAudioContext;
  const side = await new Offline(2, 1, FILE_RATE).decodeAudioData(bytes);
  const L = new Float32Array(side.length), R = new Float32Array(side.length);
  const right = side.numberOfChannels > 1 ? 1 : 0;
  for (let i = 0; i < side.length; i += COPY_SLICE) {
    const n = Math.min(COPY_SLICE, side.length - i);
    side.copyFromChannel(L.subarray(i, i + n), 0, i);
    side.copyFromChannel(R.subarray(i, i + n), right, i);
    await nextSlice();
  }
  return { L, R, sampleRate: side.sampleRate };
}

The first AudioContext on a page opens the audio device, and that blocks the main thread for about 200 ms (measured in Chrome on macOS). A muted media element that loads 10 ms of silence opens the device ahead of time, off the main thread, so the real context costs about 2 ms when you press START.

audio.js — open the device early
// a muted element loading 10 ms of silence: opens the audio output device off the main thread (see the header)
function warmOutput() {
  const n = 480, wav = new DataView(new ArrayBuffer(44 + n * 2));
  const text = (at, s) => { for (let i = 0; i < s.length; i++) wav.setUint8(at + i, s.charCodeAt(i)); };
  text(0, 'RIFF'); wav.setUint32(4, 36 + n * 2, true); text(8, 'WAVEfmt '); wav.setUint32(16, 16, true);
  wav.setUint16(20, 1, true); wav.setUint16(22, 1, true); wav.setUint32(24, FILE_RATE, true); wav.setUint32(28, FILE_RATE * 2, true);
  wav.setUint16(32, 2, true); wav.setUint16(34, 16, true); text(36, 'data'); wav.setUint32(40, n * 2, true);
  const el = new Audio();
  el.muted = true; el.preload = 'auto';
  el.src = URL.createObjectURL(new Blob([wav.buffer], { type: 'audio/wav' }));
  el.load();
}

Sound may only start from a user gesture, so the context is made on START's pointerdown and the motor starts on its click: two short tasks instead of one long one.

app.js — the press opens the sound
// the sound opens on the press, the motor starts on the click: two short tasks instead of one long one
startStop.addEventListener('pointerdown', () => audio.start());
startStop.addEventListener('click', () => D.toggle());
audio.js — start()
start() {
  if (this.starting) { if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); return this.starting; }
  const AC = window.AudioContext || window.webkitAudioContext;
  if (!AC) return Promise.resolve(false);
  this.ctx = new AC({ latencyHint: 'interactive' });
  this.starting = this.build().catch((err) => { console.warn('DV41 audio unavailable:', err); return false; });
  return this.starting;
},
app.js — decode while idle
// the side is decoded while the page is idle, so the first press only opens the sound
const whenIdle = window.requestIdleCallback || ((fn) => setTimeout(fn, 300));
addEventListener('load', () => whenIdle(() => audio.prepare(), { timeout: 2000 }));

The worklet gets motor.js first. Modules added to one AudioWorklet share its global scope, so the needle can make its own copy of the same speed model. The decoded arrays are transferred, not copied: 2 × 27 MB change hands for free.

audio.js — the graph
if (ctx.audioWorklet && window.AudioWorkletNode) {
  await ctx.audioWorklet.addModule('motor.js');           // shares the audio thread's global scope with worklet.js
  await ctx.audioWorklet.addModule('worklet.js');
  this.node = new AudioWorkletNode(ctx, 'deck', { numberOfInputs: 0, outputChannelCount: [2] });
  this.node.port.onmessage = (e) => onReport(e.data.pos, e.data.rate);
  this.node.port.postMessage(load, [load.L.buffer, load.R.buffer]);
  this.send = (m) => this.node.port.postMessage(m);
} else {
  // no AudioWorklet: the same DeckCore on the main thread, in a ScriptProcessor
}

Now the key idea. The page never sends the audio thread a rate. It sends the same goals it gave its own model, and the audio thread glides to them sample by sample. A fader drag or a 33 → 45 change bends the pitch smoothly, with no zipper noise from a rate that jumps 60 times a second, and the picture and the sound follow the same curve.

deck.js — the goals go to both copies
// the motor's goals go to both copies of the model at once
function drive(goals) { st.drive.set(goals); audio.motor(goals); }
audio.onReady = () => audio.motor(st.drive.snapshot());

In the worklet the read position is a fraction and only ever moves by the current speed, so it stays continuous through everything. Between samples it reads with a cubic (Catmull-Rom) curve. When the needle runs fast it averages up to 6 reads across the step: a cheap low-pass that follows the speed, so a fast scratch does not fizz.

worklet.js — the needle, every sample
render(outL, outR, n) {
  const ratio = this.srcRate / this.sr;
  for (let s = 0; s < n; s++) {
    this.rate = this.hand ? this.rate + (this.handRate - this.rate) * this.kHand : this.motor.step(this.dt);
    this.seat = this.down ? Math.min(1, this.seat + this.seatIn) : Math.max(0, this.seat - this.seatOut);
    if (this.liftClick > 0 && --this.liftClick === 0) this.click = 0.35;
    const step = this.rate * ratio, speed = Math.abs(this.rate);
    let l = 0, r = 0;
    if (this.L && this.seat > 0) {
      // averaging a few reads across the step when the needle runs fast: a low-pass that follows the speed
      const taps = Math.abs(step) > 1.5 ? Math.min(6, Math.ceil(Math.abs(step))) : 1;
      for (let t = 0; t < taps; t++) { const p = this.pos + step * (t / taps); l += this.read(this.L, p); r += this.read(this.R, p); }
      l /= taps; r /= taps;
      // … the program's edges, the surface, the seating
    }
    // … the needle's own sounds, then on to the next sample
  }
}
worklet.js — reading between samples
read(buf, p) {
  const i = Math.floor(p);
  if (i < 1 || i >= this.len - 2) return 0;
  const f = p - i, a = buf[i - 1], b = buf[i], c = buf[i + 1], d = buf[i + 2];
  return b + 0.5 * f * (c - a + f * (2 * a - 5 * b + 4 * c - d + f * (3 * (b - c) + d - a)));
}

Where is the needle on the record? A record holds a program, a stretch of the side, laid along the groove: 1.8 s of lead-in spiral from the very edge, the music from 96.2 % of the radius in to 40 %, 2.6 s of run-out, then the locked groove. rAt turns a time into a radius, tAt a radius into a time.

geometry.js — the groove: time ↔ radius
const LEAD = 1.8, RUNOUT = 2.6;                           // seconds of lead-in and run-out spiral
const R_LEAD = 0.985 * RECORD_R;                          // where the lead-in starts
const R_OUT = 0.962 * RECORD_R;                           // the first groove of music
const R_IN = 0.40 * RECORD_R;                             // the last groove of music
const R_LOCK = 0.36 * RECORD_R;                           // the locked groove

// ------------------------------------------------------------------ the groove of one program: time ↔ radius
// t is always seconds on the SIDE (the audio file); the program's own clock starts at `start`.
function groove(start, end) {
  const len = end - start;
  return {
    start, end, len,
    rAt(t) {
      const u = t - start;
      if (u < -LEAD) return R_LEAD;
      if (u < 0) return R_OUT + (R_LEAD - R_OUT) * (-u / LEAD);
      if (u <= len) return R_OUT - (R_OUT - R_IN) * (u / len);
      if (u <= len + RUNOUT) return R_IN - (R_IN - R_LOCK) * ((u - len) / RUNOUT);
      return R_LOCK;
    },
    // tAt(r): the same, read backwards
  };
}

A radius gives the arm's angle by the law of cosines: the triangle of the bearing, the spindle and the stylus has all three sides known.

geometry.js — the arm's angle for a radius
const D = Math.hypot(BEARING.x - SPINDLE.x, BEARING.y - SPINDLE.y);
const DIR_PS = Math.atan2(SPINDLE.y - BEARING.y, SPINDLE.x - BEARING.x);
const phiAt = (r) => {
  const c = (D * D + ARM_L * ARM_L - r * r) / (2 * D * ARM_L);
  return DIR_PS - Math.acos(Math.min(1, Math.max(-1, c)));
};
const stylus = (phi) => ({ x: BEARING.x + ARM_L * Math.cos(phi), y: BEARING.y + ARM_L * Math.sin(phi) });
const rOf = (phi) => { const s = stylus(phi); return Math.hypot(s.x - SPINDLE.x, s.y - SPINDLE.y); };

With the needle down, its time moves at the platter's rate, so the arm creeps in as the side plays. Every 50 ms the audio thread reports where it really is; the picture is pulled towards that at 6 per second, and jumps only if it is more than half a second off. For 180 ms after a drop it is left alone, while the needle seats.

deck.js — the needle follows the groove
function followGroove(dt, now, rate) {
  st.t += rate * dt;
  const heard = audio.pos();
  if (heard !== null && now > st.syncHold && st.t < st.groove.end) {
    if (Math.abs(heard - st.t) > AUDIO_SYNC.jump) st.t = heard;
    else st.t += (heard - st.t) * Math.min(1, dt * AUDIO_SYNC.pull);
  }
  st.t = Math.max(st.t, st.groove.start - G.LEAD);
  const lockAt = st.groove.end + G.RUNOUT;
  if (st.t >= lockAt) {
    st.t = lockAt;
    if (Math.abs(rate) > 0.2) st.locked += dt;
    if (st.locked > LOCKED_GIVE_UP) { lift(); stop(); }   // (stage 4 sends the arm home)
  }
  st.phi = G.phiAt(st.groove.rAt(st.t));
}

In this stage START drops the needle straight onto the lead-in, and the arm jumps there. Stage 4 puts it on a cue lever.

Stage 3 · START plays the side at the platter's speedOpen ↗
The turntable playing: the arm in the groove near the edge of the record, the LED on

4 · The tonearm, on a cue lever

A real arm is never dragged across a record. A cue lever raises it, you swing it, the lever lowers it. So every move here has three parts: raise (the needle lifts, the music fades out), swing (a spring on the arm's angle) and lower (the needle lands). START cues the arm to where it hovers, or to the lead-in; the rest post parks it.

deck.js — the lever, the swing, the drop
const ARM_SPRING = { k: 30, damping: 1.05 };                 // the arm swinging to a cue point
const CUE_LEVER = { rise: 0.25, lower: 0.25, settle: 0.2, bounce: 0.14 };   // s, s, s, height of the settle
const DROP = { cue: 0.75, hand: 0.6 };                       // needle-drop strength: the lever / a hand
deck.js — a move: up, across, down
// the cue lever: raise (the needle lifts first), lower (the needle lands when it is down)
function raise() {
  lift();
  if (st.lever.phase === 'up' || st.lever.phase === 'rising') return;
  st.lever.phase = 'rising'; st.lever.from = st.lever.h; st.lever.u = 0;
}
function lower() { st.lever.phase = 'lowering'; st.lever.from = Math.max(0, st.lever.h); st.lever.u = 0; }

// a move: up, across, down. `then` is 'drop' (at side time t) or 'rest' (on the rest post)
function plan(phi, then, t = 0) {
  st.move = { phi, then, t, stage: 'raise' };
  st.untouched = false;
  raise();                                                  // the needle comes up at once
  if (reduce.matches) {                                     // no animation: the arm is simply there
    lift(); st.phi = phi; st.phiV = 0; st.lever.h = 0; st.lever.phase = 'down'; st.move = null;
    if (then === 'drop') drop(t, DROP.cue);
  }
}
function cue(t) { plan(G.phiAt(st.groove.rAt(t)), 'drop', t); }
function toRest() {
  if (st.lever.phase === 'down' && !st.down && Math.abs(st.phi - G.PHI_REST) < 1e-3) return;   // already home
  plan(G.PHI_REST, 'rest');
}
// the arm goes home and, like an auto-stop deck, the motor brakes to a stop
function park() { toRest(); stop(); }

The lever rises over 0.25 s on an ease-in-out and falls over 0.25 s on an ease-out. Then it settles: over 0.2 s the height dips once below zero (by about 8 % of the lever's travel), so the shadow comes a touch closer as the stylus seats. When it is down, the needle lands. The swing is a spring with stiffness 30 and a damping ratio of 1.05: just past critical, so the arm arrives without a bounce.

deck.js — the lever's height, and the landing
// the lever's height over time: an eased rise, an ease-out fall, then a small settle as the stylus seats
const easeInOut = (u) => (u < 0.5 ? 4 * u * u * u : 1 - (-2 * u + 2) ** 3 / 2);
const easeOut = (u) => 1 - (1 - u) ** 3;
function stepLever(dt) {
  const L = st.lever, c = CUE_LEVER;
  if (L.phase === 'rising') {
    L.u = Math.min(1, L.u + dt / c.rise); L.h = L.from + (1 - L.from) * easeInOut(L.u);
    if (L.u >= 1) L.phase = 'up';
  } else if (L.phase === 'lowering') {
    L.u = Math.min(1, L.u + dt / c.lower); L.h = L.from * (1 - easeOut(L.u));
    if (L.u >= 1) { L.phase = 'settling'; L.u = 0; landed(L.from); }
  } else if (L.phase === 'settling') {
    L.u = Math.min(1, L.u + dt / c.settle);
    L.h = -c.bounce * Math.sin(Math.PI * L.u) * (1 - L.u);   // the shadow dips a touch closer, once
    if (L.u >= 1) { L.phase = 'down'; L.h = 0; }
  }
}
// the lever is down: the needle lands if the move was a drop, and the record shudders
function landed(height) {
  const m = st.move;
  if (m && m.then === 'drop') {
    drop(m.t, m.vel || DROP.cue);
    st.landed = performance.now(); st.landing = Math.min(1, height * (m.vel || DROP.cue) / DROP.cue);
  }
  st.move = null;
}
deck.js — the swing
function stepMove(dt) {
  const m = st.move;
  if (m.stage === 'raise') { raise(); if (st.lever.phase === 'up') m.stage = 'swing'; return; }
  if (m.stage === 'swing') {
    const { k, damping } = ARM_SPRING, c = 2 * Math.sqrt(k) * damping;
    st.phiV += (k * (m.phi - st.phi) - c * st.phiV) * dt;
    st.phi += st.phiV * dt;
    if (Math.abs(m.phi - st.phi) < 0.0015 && Math.abs(st.phiV) < 0.01) { st.phi = m.phi; st.phiV = 0; m.stage = 'lower'; lower(); }
  }
}

A hand on the arm raises the lever at the touch; the arm follows the hand while it is held and is lowered where it is let go. Let go off the vinyl or over the rest post and the deck does what an auto-stop deck does: the arm goes home and the motor brakes. The platter never cares where the arm is, so it keeps turning while the arm is in the air.

deck.js — a hand on the arm
// a hand on the arm: the lever rises at the touch, the arm follows the hand while held, lowers where it is let go
function grabArm(p, id) {
  wake(); raise(); st.untouched = false; st.move = null; st.phiV = 0;
  st.armHeld = { off: p ? angleAt(p, G.P) - st.phi : 0, id };
}
function moveArm(p) { setArmAngle(angleAt(p, G.P) - st.armHeld.off); }
function setArmAngle(phi) { st.phi = Math.max(G.PHI_REST, Math.min(G.PHI_MAX, phi)); }
function releaseArm() {
  st.armHeld = null;
  const r = G.rOf(st.phi);
  if (r <= G.R_LEAD + 6 && r >= G.R_LOCK - 4) {              // over the vinyl: lower it here, the needle lands
    st.move = { phi: st.phi, then: 'drop', t: st.groove.tAt(Math.min(r, G.R_LEAD)), stage: 'lower', vel: DROP.hand };
    if (reduce.matches) { st.move = null; st.lever.h = 0; st.lever.phase = 'down'; drop(st.groove.tAt(Math.min(r, G.R_LEAD)), DROP.hand); }
    else lower();
  } else park();                                             // on the rest post, or off the vinyl: home, motor off
}

Which press is on the arm? Near the stylus, or near the line from the bearing to it. For a finger the targets grow to at least 48 and 44 CSS px across.

deck.js — is the press on the arm?
// is a concept-px point on the arm (the headshell, or near the tube)? `pxToConcept` > 0 for a finger: the targets
// grow to at least 48 / 44 CSS px across
function onArm(p, pxToConcept = 0) {
  const s = G.stylus(st.phi);
  if (Math.hypot(p.x - s.x, p.y - s.y) < Math.max(44, 24 * pxToConcept)) return true;
  const dx = s.x - G.P.x, dy = s.y - G.P.y;
  const k = Math.max(0, Math.min(1, ((p.x - G.P.x) * dx + (p.y - G.P.y) * dy) / (dx * dx + dy * dy)));
  return k > 0.15 && Math.hypot(p.x - (G.P.x + dx * k), p.y - (G.P.y + dy * k)) < Math.max(26, 22 * pxToConcept);
}

The needle's sound is in the worklet. A down message jumps the read position (the needle was silent in the air) and starts a thump. Then the music seats: it swells in over 0.32 s while a low-pass opens from 2 kHz to 20 kHz. A lift fades the music out over 0.12 s and then clicks.

worklet.js — the needle lands, the needle lifts
case 'down':                                    // the needle lands: jump there (it was silent), thump
  this.pos = m.pos * this.srcRate; this.down = true; this.seat = 0; this.liftClick = 0;
  this.thump = Math.max(this.thump, m.vel); this.thumpT = 0; this.click = 0.5 * m.vel;
  this.lastRev = Math.floor((this.pos - this.lockAt) / (1.8 * this.srcRate));
  break;
case 'up': if (this.down) { this.down = false; this.liftClick = Math.round(SEAT_OUT * this.sr); } break;
worklet.js — seating
// seating: the music swells in behind a low-pass that opens; the surface comes in first
const music = smoothstep(this.seat), grit = smoothstep(Math.min(1, this.seat * (this.down ? 4 : 1)));
if (music < 1) {
  const a = 1 - Math.exp(-2 * Math.PI * SEAT_CUTOFF * Math.pow(10, music) / this.sr);
  this.lpL += (yl - this.lpL) * a; this.lpR += (yr - this.lpR) * a;
} else { this.lpL = yl; this.lpR = yr; }
l = this.lpL * music + (surf + pop * (1 - this.popPan)) * grit;
r = this.lpR * music + (surf + pop * (1 + this.popPan)) * grit;
worklet.js — the thump and the tick
// the needle's own sounds: the thump of the arm's weight (two low sines), the tick of the stylus
if (this.thump > 1e-4 || this.click > 1e-4) {
  const t = this.thumpT / this.sr;
  const th = this.thump * (Math.sin(2 * Math.PI * 52 * t) * Math.exp(-t / 0.075) * 0.55 + Math.sin(2 * Math.PI * 104 * t) * Math.exp(-t / 0.03) * 0.25);
  const ck = this.click * this.noise(); this.click *= 0.82;
  l += th + ck; r += th + ck;
  this.thumpT++; if (t > 0.4) this.thump = 0;
}

On screen the arm grows 2.5 % as it rises, because it is closer to you. Its shadow moves further off and gets softer. A blur filter animated every frame would be slow, so there are two shadow pictures, blurred in advance, near and far, and the lever cross-fades them by opacity. Each one keeps an opacity of at least 0.002, never 0, so the browser keeps both drawn and the first lift does not stutter.

app.js — the arm's numbers
const LIFT_SCALE = 0.025;                         // the arm grows this much at the top of the lever's travel
const SHADOW_ALPHA = { low: 0.45, high: 0.32, idle: 0.002 };   // idle: never fully 0, so both stay rastered
const WOBBLE = { ms: 320, hz: 8.5, decay: 0.09, px: 1.3, scale: 0.004 };   // the record's shudder when the needle lands
const ARM_KEY_STEP = 0.6 * RAD;
app.js — the arm and its shadows
// the arm: turned about its bearing, a little larger as the lever raises it; its shadow further off and softer
let d = st.phi / RAD - G.THETA0; d = ((d + 180) % 360 + 360) % 360 - 180;
const h = st.lever.h, up = Math.max(0, h);
const turn = `rotate(${d.toFixed(3)}deg) scale(${(1 + LIFT_SCALE * up).toFixed(4)})`;
if (turn !== view.arm) { arm.style.transform = turn; view.arm = turn; }
const { low, high } = G.ARM_SHADOW;
const ox = cqw(low[0] + (high[0] - low[0]) * h).toFixed(3), oy = cqw(low[1] + (high[1] - low[1]) * h).toFixed(3);
const cast = `translate(${ox}cqw, ${oy}cqw) ${turn}`;
const fade = [Math.max(SHADOW_ALPHA.idle, SHADOW_ALPHA.low * (1 - up)).toFixed(3), Math.max(SHADOW_ALPHA.idle, SHADOW_ALPHA.high * up).toFixed(3)];
for (let i = 0; i < 2; i++) {
  if (cast !== view.shadow[i]) { shadows[i].style.transform = cast; view.shadow[i] = cast; }
  if (fade[i] !== view.fade[i]) { shadows[i].style.opacity = fade[i]; view.fade[i] = fade[i]; }
}
style.css — a shadow picture turns with the arm
.deck[data-arm="concept"] .arm-shadow {
  left: calc((815 - 305) * var(--m)); top: calc((364.333 - 337) * var(--m)); width: calc(417.333 * var(--m)); height: calc(578 * var(--m));
  transform-origin: calc(315.8 * var(--m)) calc(118.167 * var(--m));
}

When the needle lands, the record shudders for 320 ms: a damped 8.5 Hz wobble of 1.3 concept px towards the needle, and a 0.4 % breath in size.

app.js — the landing shudder
// the platter: its angle, and for a moment after a needle drop a small damped shudder towards the needle
function platterTransform(now) {
  const rot = `rotate(${((st.angle / RAD) % 360).toFixed(2)}deg)`;
  const t = now - st.landed;
  if (!st.landed || t > WOBBLE.ms) return rot;
  const w = st.landing * Math.exp(-t / 1000 / WOBBLE.decay) * Math.sin(2 * Math.PI * WOBBLE.hz * t / 1000);
  const s = G.stylus(st.phi), dx = s.x - G.S.x, dy = s.y - G.S.y, n = Math.hypot(dx, dy) || 1;
  return `translate(${cqw(dx / n * WOBBLE.px * w).toFixed(4)}cqw, ${cqw(dy / n * WOBBLE.px * w).toFixed(4)}cqw) scale(${(1 + WOBBLE.scale * w).toFixed(5)}) ${rot}`;
}
app.js — a hand on the arm
deck.addEventListener('pointerdown', (e) => {
  if (e.button !== 0 || e.target.closest('#startstop, #fader, .speed')) return;
  const p = toConcept(e);
  const finger = e.pointerType === 'touch' ? G.DECK_BOX.w / deck.getBoundingClientRect().width : 0;
  if (D.onArm(p, finger)) { D.grabArm(p, e.pointerId); deck.classList.add('grab-arm'); }
  else return;
  deck.setPointerCapture(e.pointerId);
  e.preventDefault();
});
deck.addEventListener('pointermove', (e) => {
  if (st.armHeld && e.pointerId === st.armHeld.id) D.moveArm(toConcept(e));
  else if (!e.buttons) {
    const cursor = D.onArm(toConcept(e)) ? 'grab' : '';
    if (cursor !== hoverCursor) { deck.style.cursor = cursor; hoverCursor = cursor; }
  }
});
const release = (e) => {
  if (st.armHeld && e.pointerId === st.armHeld.id) { D.releaseArm(); deck.classList.remove('grab-arm'); }
};
deck.addEventListener('pointerup', release);
deck.addEventListener('pointercancel', release);

The arm is a slider for the keyboard: Enter or Space raises it (and lowers it again), the arrow keys swing it while it is up, Escape sends it home. Its value is the time under the needle, in seconds.

app.js — the arm by keyboard
// the arm by keyboard: Enter/Space raises it (and lowers it again), ←/→ swing it while it is up, Esc sends it home
arm.addEventListener('keydown', (e) => {
  const held = st.armHeld && st.armHeld.id === 'key';
  if (e.key === 'Enter' || e.key === ' ') { if (held) D.releaseArm(); else D.grabArm(null, 'key'); }
  else if (held && (e.key === 'ArrowLeft' || e.key === 'ArrowDown')) D.setArmAngle(st.phi + ARM_KEY_STEP);
  else if (held && (e.key === 'ArrowRight' || e.key === 'ArrowUp')) D.setArmAngle(st.phi - ARM_KEY_STEP);
  else if (e.key === 'Escape') { st.armHeld = null; D.park(); }
  else return;
  e.preventDefault(); e.stopPropagation();
});
Stage 4 · lift the arm by its headshell and set it down anywhereOpen ↗
A hand holding the tonearm up over the middle of the record, the arm a little larger with its shadow further off

5 · Now playing: the tracks, and cueing one

The side holds eight tracks, each with its start time and length (catalog.js). Which one is playing is just the last one that started before the needle's time.

deck.js — the track at a time
// the track (catalog id) at side time t on the record that is on
function trackAt(t) {
  for (let i = st.tracks.length - 1; i >= 0; i--) if (t >= st.tracks[i].t0) return st.tracks[i].id;
  return st.tracks[0].id;
}

The list lights the track under the needle. With the needle up it lights the track under the hovering arm, so you can see where it will land; before anything has moved it lights row 2, as the concept did.

deck.js — the lit track
// the track the Now-playing list lights: under the needle, under the hovering arm, or the concept's row 2 at first
// (null: none)
function litTrack() {
  if (st.down || armOverRecord()) return trackAt(Math.max(st.groove.start, Math.min(st.groove.end - 0.01, armTime())));
  return st.untouched && st.record === C.SIDE_A ? st.tracks[1].id : null;
}

Every way of choosing a track ends in the same place: a cue of the arm. ← goes to the start of the track, or to the previous one if you are in its first 2 seconds, like a CD player.

deck.js — seek and step
function seek(t) { wake(); setMotor(true); cue(Math.max(st.groove.start - G.LEAD, Math.min(st.groove.end, t))); }
function step(dir) {
  const t = st.down ? st.t : (st.untouched ? st.groove.start : armTime());
  let i = st.tracks.findIndex((k) => k.id === trackAt(Math.max(st.groove.start, t)));
  if (dir > 0) { if (i >= st.tracks.length - 1) return; i++; }
  else if (t - st.tracks[i].t0 < 2) i = Math.max(0, i - 1);
  seek(st.tracks[i].t0);
}
app.js — the rows
function listTracks() {
  trackList.replaceChildren(...st.tracks.map((k, i) => {
    const li = document.createElement('li'), b = document.createElement('button');
    b.type = 'button'; b.dataset.track = k.id;
    b.innerHTML = '<span class="no"></span><span class="ti"></span><span class="du"></span>';
    b.children[0].textContent = i + 1; b.children[1].textContent = k.title; b.children[2].textContent = C.duration(k.len);
    li.append(b); return li;
  }));
  rows = [...trackList.querySelectorAll('button')];
  view.track = undefined;
}
app.js — a row cues its track
function cueTrack(id) {
  const k = C.track(id);
  if (!k) return;
  D.seek(k.t0);
}
app.js — what the status line says
function nowPlaying(track) {
  if (st.untouched && !st.down) return 'Press START/STOP';
  if (!st.down) return 'Needle up';
  if (st.t < st.groove.start) return 'Lead-in';
  if (st.t > st.groove.end) return 'Run-out';
  const k = C.track(track);
  return `${k.title} · ${elapsed(st.t - k.t0)}`;
}

The rows' aria-current changes only when the lit track changes, and the words only when they differ. Nothing on the page is written every frame unless it moved.

app.js — the panel, each frame
const track = D.litTrack();
if (track !== view.track) {
  rows.forEach((b) => b.setAttribute('aria-current', String(track !== null && +b.dataset.track === track)));
  view.track = track;
}
const now = nowPlaying(track);
if (now !== view.now) { npNow.textContent = now; view.now = now; }
app.js — Space, ← and →
// keys: Space start/stop, ← → tracks (not while typing)
document.addEventListener('keydown', (e) => {
  if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || e.target.closest('input, textarea, select, dialog')) return;
  const tag = e.target.tagName;
  if (e.key === ' ' && tag !== 'BUTTON' && tag !== 'A') { e.preventDefault(); D.toggle(); }
  else if (e.key === 'ArrowRight') { e.preventDefault(); D.step(1); }
  else if (e.key === 'ArrowLeft') { e.preventDefault(); D.step(-1); }
});
style.css — the rows
.tracks button {
  display: grid; grid-template-columns: calc(28 * var(--m)) 1fr auto; align-items: center; width: 100%; height: calc(34.4 * var(--m));
  padding: 0 calc(15 * var(--m)) 0 calc(13 * var(--m)); border: 0; border-radius: calc(4 * var(--m)); background: transparent; cursor: pointer; text-align: left;
  font-size: calc(15.6 * var(--m)); color: var(--np-text); letter-spacing: .005em; transition: background .25s;
}
.tracks button:hover { background: rgb(255 255 255 / .04); }
.tracks .no { font-size: calc(14.5 * var(--m)); color: #CFCDC8; }
.tracks .ti { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tracks .du { font-size: calc(12.6 * var(--m)); color: var(--np-muted); font-variant-numeric: tabular-nums; }
.tracks button[aria-current="true"] { background: var(--np-row-on); box-shadow: inset 0 1px 0 var(--np-row-edge), 0 1px 2px rgb(0 0 0 / .35); color: #F2F1EE; }
.tracks button[aria-current="true"] .du { color: #E6E4E0; }
Stage 5 · press a track to cue itOpen ↗
The Now-playing list filled with eight tracks, Amber Hour lit, the status line reading Amber Hour and its time

6 · A hand on the record: hold and scratch

A hand on a spinning record does not stop it dead. It grips with a little slip. Here the record's angle closes on the hand's with a time constant of 1/20 s, so a still hand stops a record at 33⅓ in about 150 ms (measured: 155 to 159 ms), the pitch sliding down with it. After that the record keeps the hand's movement 1 : 1, so a scratch moves the music exactly as far as the hand.

deck.js — the grip
const HAND_GRIP = 20;                                        // 1/s: how fast the record takes the hand's speed

The grip starts where the record would coast to under a still hand, so its speed does not jump at the touch. The angle round the spindle is unwrapped across ±180°, so a hand can go round and round.

deck.js — grab, move, let go
// the grip starts where the record would coast to under a still hand, so its speed does not jump at the touch
function grabRecord(p, id) {
  wake();
  st.hand = { last: angleAt(p, G.S), target: st.angle + st.omega / HAND_GRIP, id };
}
function moveRecord(p) {
  const a = angleAt(p, G.S);
  st.hand.target += wrapPi(a - st.hand.last); st.hand.last = a;
}
function releaseRecord() {
  st.hand = null;
  st.drive.takeOver(st.omega / OMEGA);
  audio.release();
}

While a hand holds it, the platter's speed is measured from how far it actually turned, and that speed goes to the audio thread, only when it changes. When the hand lets go, the motor takes over from the speed the record has: in START it spins back up on the spin-up curve, in STOP it stays where it is.

deck.js — the motor, or a hand
function spinPlatter(dt) {
  if (st.hand) {
    // exact over the step: the angle closes on the hand's with time constant 1/HAND_GRIP
    const k = 1 - Math.exp(-dt * HAND_GRIP), before = st.angle;
    st.angle += (st.hand.target - st.angle) * k;
    st.omega = dt > 0 ? (st.angle - before) / dt : 0;
    audio.hand(st.omega / OMEGA);
  } else {
    st.omega = OMEGA * st.drive.step(dt);
    st.angle += st.omega * dt;
  }
}
motor.js — taking over from a hand
// a hand let go of a record turning at `rate`: the motor carries on from there
takeOver(rate) {
  const k = this.speed * this.pitch;
  this.motor = k > 0 ? rate / k : 0;
  if (this.calm) this.motor = this.on ? 1 : 0;
}

On the audio thread a hand's speed is glided over about 7 ms, which is enough to keep a quick scratch from clicking.

worklet.js — the speed: a hand, or the motor
this.rate = this.hand ? this.rate + (this.handRate - this.rate) * this.kHand : this.motor.step(this.dt);
audio.js — the hand's speed, only when it changes
hand(v) {
  if (!this.ready || v === this.sentHand) return;
  this.sentHand = v; this.send({ type: 'hand', v });
},
release() { this.sentHand = NaN; this.post({ type: 'release' }); },
app.js — a press on the vinyl grips it
deck.addEventListener('pointerdown', (e) => {
  if (e.button !== 0 || e.target.closest('#startstop, #fader, .speed')) return;
  const p = toConcept(e);
  const finger = e.pointerType === 'touch' ? G.DECK_BOX.w / deck.getBoundingClientRect().width : 0;
  if (D.onArm(p, finger)) { D.grabArm(p, e.pointerId); deck.classList.add('grab-arm'); }
  else if (D.onRecord(p)) D.grabRecord(p, e.pointerId);
  else return;
  deck.setPointerCapture(e.pointerId);
  e.preventDefault();
});
style.css — the record's hit area
.record-hit { left: calc((697.5 - 322 - 305) * var(--m)); top: calc((702 - 322 - 337) * var(--m)); width: calc(644 * var(--m)); height: calc(644 * var(--m)); border-radius: 50%; cursor: grab; touch-action: none; }
.record-hit:active { cursor: grabbing; }
Stage 6 · hold the record, or drag it to scratchOpen ↗
The record held and pulled back by a hand, the status line reading the record's live speed

7 · The pitch fader and 33 / 45

Both only change the model's goals, on both copies at once. The fader rounds to 0.1 % and has a detent: anything within 0.25 % of the centre is 0.

deck.js — speed and pitch are goals
function setSpeed(rpm) { st.speed = rpm === 45 ? RPM_45 : 1; drive({ speed: st.speed }); }
function setPitch(percent) {
  let v = Math.max(-PITCH_RANGE, Math.min(PITCH_RANGE, Math.round(percent * 10) / 10));
  if (Math.abs(v) < 0.25) v = 0;                             // a detent at the centre
  if (v / 100 !== st.pitch) { st.pitch = v / 100; drive({ pitch: 1 + st.pitch }); }
  return v;
}

The fader cap travels 95 concept px from the centre to ±8 %, up for faster, like the printed scale beside it. The 33 and 45 buttons are a radio group; the toggle between them flips its picture.

app.js — the switch and the fader
function setSpeed(rpm) {
  D.setSpeed(rpm);
  speedBox.dataset.rpm = rpm;
  speedButtons.forEach((b) => b.setAttribute('aria-checked', String(+b.dataset.rpm === rpm)));
}
function setPitch(percent) {
  const v = D.setPitch(percent);
  fader.style.transform = `translateY(${cqw((-v / D.PITCH_RANGE) * FADER_TRAVEL)}cqw)`;   // + is up, like the scale
  fader.setAttribute('aria-valuenow', v);
  fader.setAttribute('aria-valuetext', `${v > 0 ? '+' : ''}${v.toFixed(1)} %`);
}

The fader is a slider: drag it, use ↑ and ↓ (0.5 %), Page Up and Page Down (2 %), Home or 0 to centre it, or double-click it. The status line from stage 2 now reads "45 rpm" or "33⅓ rpm +3.2%" once the belt has glided there, and the live rpm while it glides.

app.js — the fader by hand and by key
// pitch fader ±8 %: drag, ↑/↓ PgUp/PgDn, Home/0, double-click = 0
let faderDrag = null;
function grabFader(e) {
  faderDrag = { id: e.pointerId, y: toConcept(e).y, v: st.pitch * 100 };
  fader.setPointerCapture(e.pointerId); e.preventDefault(); e.stopPropagation();
}
fader.addEventListener('pointerdown', grabFader);
fader.addEventListener('pointermove', (e) => {
  if (faderDrag && e.pointerId === faderDrag.id) setPitch(faderDrag.v - (toConcept(e).y - faderDrag.y) / FADER_TRAVEL * D.PITCH_RANGE);
});
fader.addEventListener('pointerup', () => { faderDrag = null; });
fader.addEventListener('dblclick', () => setPitch(0));
fader.addEventListener('keydown', (e) => {
  const k = { ArrowUp: 0.5, ArrowDown: -0.5, PageUp: 2, PageDown: -2 }[e.key];
  if (k !== undefined) { setPitch(st.pitch * 100 + k); e.preventDefault(); e.stopPropagation(); }
  if (e.key === 'Home' || e.key === '0') { setPitch(0); e.preventDefault(); }
});
Stage 7 · 33 / 45 and the pitch faderOpen ↗
The turntable at 45 rpm with the pitch fader pushed up: the speed switch flipped, the status line reading 45 rpm +2.7 %

8 · VU meters from the real levels

The meters read what is actually playing. The worklet's output is split into its two channels and each goes to an AnalyserNode with no smoothing of its own.

audio.js — two analysers
const split = ctx.createChannelSplitter(2);
this.node.connect(split);
for (let c = 0; c < 2; c++) {
  const an = ctx.createAnalyser(); an.fftSize = ANALYSER_SIZE; an.smoothingTimeConstant = 0;
  split.connect(an, c); this.analysers.push(an);
}

Each frame levels() reads 1024 samples per channel into one buffer made once, and returns their RMS in one reused array. Nothing is allocated per frame.

audio.js — the levels
levels() {
  if (!this.ready) { this.level[0] = this.level[1] = 0; return this.level; }
  for (let c = 0; c < 2; c++) {
    this.analysers[c].getFloatTimeDomainData(this.wave);
    let s = 0;
    for (let i = 0; i < ANALYSER_SIZE; i++) s += this.wave[i] * this.wave[i];
    this.level[c] = Math.sqrt(s / ANALYSER_SIZE);
  }
  return this.level;
},

A VU meter has ballistics: the needle is heavy, so it rises and falls with some lag and a little overshoot. The level is smoothed over 75 ms, mapped to an angle from rest (−132.6°) towards full (−77.6°, an RMS of 0.16), stopped at −46°, and the needle swings there on a spring (stiffness 420, damping ratio 0.62). When the needle lands, the meters flick.

deck.js — the meter's numbers
const VU = { ref: 0.16, max: 1.6, smooth: 0.075, rest: -132.6, full: -77.6, stop: -46, k: 420, damping: 0.62 };
deck.js — the needles' swing
function swingMeters(dt) {
  const lv = audio.levels();
  for (let c = 0; c < 2; c++) {
    const amp = Math.min(VU.max, lv[c] / VU.ref);
    st.vu[c] += (amp - st.vu[c]) * (1 - Math.exp(-dt / VU.smooth));
    const goal = Math.min(VU.stop, VU.rest + (VU.full - VU.rest) * st.vu[c]);
    if (reduce.matches) { st.vuDeg[c] = goal; continue; }
    const d = 2 * Math.sqrt(VU.k) * VU.damping;
    st.vuV[c] += (VU.k * (goal - st.vuDeg[c]) - d * st.vuV[c]) * dt;
    st.vuDeg[c] += st.vuV[c] * dt;
  }
}

The needles are HTML elements, not SVG lines. A transform on an SVG element makes the browser lay the whole SVG out again, every frame (measured: one layout per frame); an HTML element with a transform is only composited.

app.js — the needles
for (let c = 0; c < 2; c++) {
  const n = `rotate(${st.vuDeg[c].toFixed(2)}deg)`;
  if (n !== view.needle[c]) { needles[c].style.transform = n; view.needle[c] = n; }
}
style.css — a needle, turned about its pivot
/* VU needles: HTML, not SVG — a transform on an SVG element relays the SVG out every frame */
.vu-window { top: calc((963 - 337) * var(--m)); width: calc(118 * var(--m)); height: calc(56 * var(--m)); overflow: hidden; pointer-events: none; }
.vu-window.l { left: calc((984 - 305) * var(--m)); } .vu-window.r { left: calc((1118 - 305) * var(--m)); }
.vu-needle {   /* the needle line (1059.7 → 1100.7, y 1031.2, 1.5 wide, round caps) about its pivot 16 px to its left */
  position: absolute; left: calc(74.95 * var(--m)); top: calc(67.45 * var(--m)); width: calc(42.5 * var(--m)); height: calc(1.5 * var(--m));
  border-radius: calc(.75 * var(--m)); background: var(--needle); transform-origin: calc(-15.25 * var(--m)) 50%; transform: rotate(-132.6deg);
}
Stage 8 · the meters read the musicOpen ↗
The two VU meters with their needles up while the side plays

9 · The printed label, and other records

The label in the concept says SLOW BURN. Now it is printed with the track that is playing: the title large, the artist in capitals, the side position ("A3") and the speed. It is drawn into a canvas inside the turning label layer, so the type turns with the record. There are two canvases, so a new print can cross-fade over the old one.

index.html — the label layer
<div class="label" id="label" aria-hidden="true">
  <img class="label-print" src="../../img/label.webp" alt="" draggable="false">
  <canvas class="label-art"></canvas><canvas class="label-art"></canvas>   <!-- the playing track, printed (label.js) -->
</div>
app.js — the lit track goes to the label
const k = track === null ? null : C.track(track);
window.DeckLabel.show({ track: k, release: st.record, artist: k ? C.artistName(k.id) : '', position: k ? `A${st.tracks.indexOf(k) + 1}` : '' });

Printing takes a few milliseconds and a texture upload, so it never happens inside a frame. A track has to stay lit for 250 ms (an arm swinging across the record passes several), then it is printed in an idle moment, and the newest request wins.

label.js — show()
async function show({ track, release, position, artist }) {
  const key = track ? `${release.id}|${track.id}` : `${release.id}|-`;
  if (key === shown) return;
  shown = key;
  const first = pending === null;
  pending = { track, release, position, artist };
  if (!first) return;                                        // a print is already queued: it will take the latest
  await paperReady;
  await new Promise((r) => setTimeout(r, SETTLE_MS));
  await new Promise(whenIdle);
  const job = pending; pending = null;
  if (!job.track) {                                          // no track: the record's own label (the concept's print)
    prints.forEach((c) => c.classList.remove('on'));
    original.classList.remove('off');
    return;
  }
  const back = prints[1 - front];
  fitCanvas(back);
  paint(back, job);
  back.classList.add('on'); prints[front].classList.remove('on');
  original.classList.add('off');
  front = 1 - front;
  el.dataset.title = job.track.title; el.dataset.artist = job.artist;      // what the label reads (for the tests)
}

The canvas is as big as the label is on this screen, in device pixels, between 256 and 640 px. On a phone that is the smallest size, 256 px: about a sixth of the pixels of a 640 px print to upload.

label.js — as many pixels as it shows
function fitCanvas(canvas) {
  size = Math.max(MIN_SIZE, Math.min(MAX_SIZE, Math.ceil(el.offsetWidth * devicePixelRatio)));
  k = size / (2 * LR);
  if (canvas.width !== size) { canvas.width = size; canvas.height = size; }
}

The paper is the concept's label with its type taken off. A release's own colours are laid over it with the color blend mode, which takes their hue and keeps the paper's light and grain. The type is laid out where the original's stood, and each line shrinks to fit its width.

label.js — the print
function paint(canvas, { track, release, position, artist }) {
  const g = canvas.getContext('2d');
  g.clearRect(0, 0, size, size);
  g.drawImage(paper, 0, 0, size, size);
  if (release && release.label) {                     // a release's own colours, the paper's light kept
    const grad = g.createLinearGradient(size * 0.15, size * 0.1, size * 0.85, size * 0.9);
    release.label.forEach((c, i, all) => grad.addColorStop(i / Math.max(1, all.length - 1), c));
    g.save(); g.globalCompositeOperation = 'color'; g.fillStyle = grad;
    g.beginPath(); g.arc(size / 2, size / 2, (LR - 14) * k, 0, 2 * Math.PI); g.fill(); g.restore();
  }
  g.textAlign = 'center'; g.textBaseline = 'middle';
  g.shadowColor = 'rgb(0 0 0 / .28)'; g.shadowBlur = 1.2 * k; g.shadowOffsetY = 0.3 * k;
  const title = track.title.toUpperCase();
  text(g, title, 0, -50, fit(g, title, 15, 150, true, 0.6), { bold: true, track: 0.6 });
  const who = artist.toUpperCase();
  text(g, who, 0, -36, fit(g, who, 6.4, 120, false, 0.9), { track: 0.9 });
  g.textAlign = 'center';
  text(g, position, -66, -4, 7.5, { bold: true, track: 0.6 });
  text(g, '33⅓ RPM', -66, 7, 6, { track: 0.5 });
  text(g, 'STEREO', 0, 46, 6.5, { bold: true, track: 0.8 });
  text(g, `SLOW BURN RECORDS · ${release && release.title !== 'Side A' ? release.title.toUpperCase() : 'SB-041'}`, 0, 56, 5.3, { track: 0.5 });
  text(g, '© 2026 SLOW BURN', 0, 65, 5.6, { track: 0.5 });
}
style.css — the cross-fade
/* the label print: the concept's own until a track is lit, then the track's (label.js), cross-faded */
.label .label-print { transition: opacity .2s; }
.label .label-print.off { opacity: 0; }
.label canvas { opacity: .002; transition: opacity .2s; }   /* never 0: kept rastered for the first cross-fade */
.label canvas.on { opacity: 1; }

The label also releases records cut from the side: two EPs and a single, each with its own tracks and label colours. Putting one on lifts the needle and sends the arm home, then lays that record's program along the groove: its tracks spread over the whole playable band, as a real pressing would. The platter keeps doing whatever it was doing.

catalog.js — a release
{ id: 'late-light', cat: 'SB-038', title: 'Late Light', kind: 'EP', format: '12″ · 180 g', price: 26, tracks: [0, 1, 2],
  note: 'Three warm pieces for the hour after work: a slow house groove, a swung beat, a room full of bells.',
  palette: { ground: '#F1E7D2', a: '#E8492C', b: '#141414', c: '#2F8F8A' }, label: ['#F06A3B', '#F4B860', '#2B2622'] },
deck.js — another record
// put another record on: the needle lifts, the arm goes home, the platter keeps whatever it was doing
function load(release) { if (release !== st.record) { toRest(); setRecord(release); } }
app.js — a track from another record puts that record on first
function cueTrack(id) {
  const k = C.track(id);
  if (!k) return;
  if (!st.tracks.includes(k)) putOn(C.releaseOf(id), false);
  D.seek(k.t0);
}
function putOn(release, cueFirst = true) {
  if (!release) return;
  D.load(release);
  listTracks();
  arm.setAttribute('aria-valuemax', Math.round(st.groove.len));
  if (cueFirst) D.seek(st.tracks[0].t0);
}
Stage 9 · the label prints the track; another record goes onOpen ↗
The record's label printed in teal with Slow Burn by Odile Brask after the Night Ferry EP was put on; its three tracks in the list

10 · The page around it: sections, nav and touch

The last stage is the finished page. Below the deck are the label's artists, its releases, a store with a bag, and an About section with a newsletter field (kept in the browser only: it is a demo). They are rendered from data.js.

data.js — the label, for the sections
window.SLOWBURN = {
  tracks, artists, releases, sideSeconds: 143.0,
  track: byId(tracks), artist: byId(artists), release: byId(releases),
  releaseOf: (trackId) => releases.find((r) => r.trackIds.includes(String(trackId))),
  shop: { currency: 'USD', shipping: 6, freeFrom: 60 },      // one flat rate, free from a threshold
  colours: { INK, VERMILION, TEAL, CREAM, AMBER },
};

The deck keeps its own list in catalog.js, with its own track ids. Neither file has to follow the other's scheme: sections.js matches a track by its title.

sections.js — one side, two lists, matched by title
const deckTracks = () => window.Catalog?.TRACKS || [];
const toDeck = (id) => { const t = S.track(id), k = t && deckTracks().find((x) => x.title === t.title); return k ? k.id : id; };
const fromDeck = (id) => {
  if (id === null || id === undefined || id === -1) return null;
  const k = deckTracks().find((x) => String(x.id) === String(id));
  return k ? (S.tracks.find((t) => t.title === k.title)?.id ?? null) : (S.track(id)?.id ?? null);
};

The sections never poll the deck. The deck tells them, with three events: deck:track, deck:state and deck:record. A listener might do some work, so the events are sent from a task after the frame, never inside it.

app.js — events leave the frame
// events leave the frame: they are sent from a task after it, so a listener's work never lengthens a frame
const outbox = new Map();
function emit(name, detail) {
  if (!outbox.size) setTimeout(() => { for (const [n, d] of outbox) document.dispatchEvent(new CustomEvent(n, { detail: d })); outbox.clear(); });
  outbox.set(name, detail);
}
sections.js — listening
document.addEventListener('deck:record', (e) => { told.record = e.detail.releaseId; syncDeck(); });
document.addEventListener('deck:state', (e) => { told.motor = !!e.detail.playing; told.down = !!e.detail.needleDown; syncDeck(); });
document.addEventListener('deck:track', (e) => { told.track = e.detail.trackId; syncDeck(); });

A release's "Put on the deck" button calls __deck.loadRelease(id), an artist's play button calls __deck.cue(trackId), and the nav scrolls smoothly to each section with a bar of ink under the current one.

The last part is touch. Every control is a 44 px target on a phone: the picture keeps its size and the box grows around it, with padding pulled back by a negative margin.

style.css — 44 px touch targets
@media (pointer: coarse) {
  .startstop, .speed button {
    box-sizing: content-box; --px: max(0px, (46px - var(--w)) / 2); --py: max(0px, (46px - var(--h)) / 2);
    padding: var(--py) var(--px); margin: calc(-1 * var(--py)) calc(-1 * var(--px));
  }
  /* 33 grows to the left and 45 to the right. The grown boxes still overlap their neighbours (START ↔ 33 are closer than
     44 px on a phone): app.js gives a touch to the control whose picture centre is nearest, so no area is shared */
  .speed .speed-33 { padding: var(--py) 0 var(--py) calc(2 * var(--px)); margin: calc(-1 * var(--py)) 0 calc(-1 * var(--py)) calc(-2 * var(--px)); }
  .speed .speed-45 { padding: var(--py) calc(2 * var(--px)) var(--py) 0; margin: calc(-1 * var(--py)) calc(-2 * var(--px)) calc(-1 * var(--py)) 0; }
  .fader::before { content: ''; position: absolute; left: 50%; top: 50%; width: max(100%, 46px); height: max(100%, 46px); transform: translate(-50%, -50%); }
}

But on a phone START and 33 are about 27 px apart, so their grown boxes overlap, and the browser would give a touch to whichever box is on top. So a touch on any of them goes to the control whose picture centre is nearest the finger. Each control owns its own cell, and no area is shared.

app.js — a touch goes to the nearest control
const touchControls = [
  { el: startStop, centre: startStop.querySelector('img'), press: () => audio.start(), tap: () => D.toggle() },
  ...speedButtons.map((b) => ({ el: b, centre: b, tap: () => setSpeed(+b.dataset.rpm) })),
  { el: speedToggle, centre: speedToggle.querySelector('img'), tap: () => setSpeed(st.speed > 1 ? 33 : 45) },
  { el: fader, centre: fader.querySelector('img'), drag: grabFader },
];
const contentCentre = (el) => {                  // the centre of the content box (the grown padding left out)
  const r = el.getBoundingClientRect(), c = getComputedStyle(el);
  const l = r.left + parseFloat(c.paddingLeft) + parseFloat(c.borderLeftWidth), t = r.top + parseFloat(c.paddingTop) + parseFloat(c.borderTopWidth);
  const w = r.width - parseFloat(c.paddingLeft) - parseFloat(c.paddingRight), h = r.height - parseFloat(c.paddingTop) - parseFloat(c.paddingBottom);
  return { x: l + w / 2, y: t + h / 2 };
};
let routedTap = null;
deck.addEventListener('pointerdown', (e) => {
  if (e.pointerType === 'mouse' || !e.target.closest('#startstop, #fader, .speed button')) return;
  let best = null, bestD = Infinity;
  for (const k of touchControls) {
    const c = contentCentre(k.centre), d = Math.hypot(e.clientX - c.x, e.clientY - c.y);
    if (d < bestD) { best = k; bestD = d; }
  }
  e.stopPropagation();                           // the controls' own pointerdown handlers do not run: this decides
  if (best.drag) { routedTap = null; best.drag(e); return; }
  routedTap = best;
  if (best.press) best.press();
  if (best.el === startStop) startStop.classList.add('pressed');
}, { capture: true });
Stage 10 · the finished page: press START, then scrollOpen ↗
The finished page: the turntable playing Amber Hour, the list lit, the meters up
Stage 10 · on a phone, 390 px wideOpen ↗
The finished page on a phone: the hero stacked over the turntable, the label printed Late Light, and the Now-playing list below it with 44 px rows

Make it yours

  • Your own deck is a set of layers and a few numbers. Cut your picture into the plate, the platter, the label, the arm and the controls; measure the spindle, the record's radius and the arm's bearing and length in the picture's own pixels, and put them in geometry.js and style.css.
  • Your own music is audio/side-a.* and the track list in catalog.js (each track's start time and length; SIDE_SECONDS for the whole side). The releases are runs of those tracks.
  • The feel is a handful of numbers: the spin-up and the brake (SPIN_UP 0.6 s, BRAKE 1.4 s), the belt and the fader (SPEED_TAU 0.4 s, PITCH_TAU 0.09 s), the hand's grip (HAND_GRIP 20), the cue lever and the arm's spring, the meters' spring, and how fast 33⅓ looks (BASE_VISUAL_RPM 10).
  • The other arm: ?arm=ours swaps the concept's arm for a chrome S-arm; each arm is one entry in ARMS with its own pictures.
  • Accessibility is already there: START/STOP is a toggle button with aria-pressed, 33/45 is a radio group, the fader and the arm are sliders with keys, the tracks are buttons, the status line is a polite live region, Space and the arrow keys work on the page, every control is a 44 px target on touch, and with prefers-reduced-motion there are no ramps, swings, wobbles or meter ballistics (the fades in the sound stay).

The source is also on GitHub, MIT licensed: github.com/UlviShabanbayli/domvybe-vinyl-deck.

The one idea worth keeping

Run one speed model in two places. The same motor.js is stepped every frame for the picture and every sample on the audio thread for the sound, and the page only ever sends it goals. So the platter, the label, the meters and the music cannot drift apart, and every change of speed glides, with no zipper.