Pad instrument

The playable sampler: eight keyboard-mapped pads, a transport with BPM and pattern presets, a step grid, and beat-synced glows on one audio engine.

Show code
pad-instrument.tsx
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
  type SVGProps,
} from "react";
import { animate } from "animejs";
import { EASE_OUT, prefersReducedMotion } from "@/lib/openkit/anime/tokens";
import { cn } from "@/lib/utils";
import type { AudioEngine } from "@/lib/openkit/audio-engine";
import type { Pad, Pattern } from "@/lib/openkit/sampler-types";
import { usePlayhead } from "@/hooks/openkit/use-audio-engine";
import { StepGrid } from "@/components/openkit/step-grid";
import { SCROLL_FADE_CLASS, useScrollFade } from "@/hooks/use-scroll-fade";
import { PADS, PATTERN_PRESETS, presetToPattern, STEP_COUNT } from "./patterns";

const MIN_BPM = 60;
const MAX_BPM = 180;

export interface PadKit {
  title: string;
  maker: string;
  /** Up to eight pads; falls back to the default home-row layout. */
  pads?: Pad[];
}

// The audition surface: tap a pad to hear a one-shot and run the kit through a
// pattern to hear it in a beat. The sequencer is a full grid (StepGrid); this
// owns the live sequence and pushes it to the engine.
export function PadInstrument({
  kit,
  engine,
  defaultPreset = PATTERN_PRESETS[0].id,
  accent = "var(--primary)",
}: {
  kit: PadKit;
  /** From useAudioEngine(). Pass null to render without sound. */
  engine: AudioEngine | null;
  /** Preset id to start on, or "" for an empty bar. */
  defaultPreset?: string;
  /** Fill for pads, steps and the play button. Defaults to the theme's primary. */
  accent?: string;
}) {
  const pads = useMemo(
    () => (kit.pads?.length ? kit.pads.slice(0, PADS.length) : PADS),
    [kit],
  );

  const [pattern, setPattern] = useState<Pattern>(() => {
    const preset = PATTERN_PRESETS.find((p) => p.id === defaultPreset);
    return preset ? presetToPattern(preset) : {};
  });
  const [presetId, setPresetId] = useState(defaultPreset);
  const [variants, setVariants] = useState<Record<string, number>>({});
  const [bpm, setBpm] = useState(90);
  const [playing, setPlaying] = useState(false);
  const [flash, setFlash] = useState<Record<string, number>>({});

  const playStep = usePlayhead(engine);
  const padStrip = useScrollFade<HTMLDivElement>();

  // Push the live sequence to the engine whenever it changes. setSequence does
  // not create an AudioContext, so this is safe before any gesture.
  useEffect(() => {
    if (!engine) return;
    engine.setSequence({
      bpm,
      stepCount: STEP_COUNT,
      tracks: pads.map((p) => ({
        type: p.type,
        variant: variants[p.id] ?? 1,
        steps: pattern[p.id] ?? [],
      })),
    });
  }, [engine, bpm, pads, pattern, variants]);

  // Stop the sequence when the instrument unmounts.
  useEffect(() => () => engine?.stop(), [engine]);

  const fire = useCallback(
    (pad: Pad) => {
      engine?.trigger(pad.type, variants[pad.id] ?? 1);
      setFlash((f) => ({ ...f, [pad.id]: Date.now() }));
    },
    [engine, variants],
  );

  const togglePlay = useCallback(() => {
    if (!engine) return;
    if (engine.isPlaying()) {
      engine.stop();
      setPlaying(false);
    } else {
      engine.play();
      setPlaying(true);
    }
  }, [engine]);

  // Keyboard-mapped pads, scoped to the instrument: keys play only while focus
  // is inside it, so the host page keeps its own shortcuts and Space-to-scroll.
  const byKey = useMemo(() => new Map(pads.map((p) => [p.key, p])), [pads]);
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;
    const target = e.target as HTMLElement;
    if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return;
    if ((e.key === " " || e.code === "Space") && target === e.currentTarget) {
      e.preventDefault();
      togglePlay();
      return;
    }
    const pad = byKey.get(e.key.toUpperCase());
    if (pad) {
      e.preventDefault();
      fire(pad);
    }
  };

  function toggleStep(padId: string, step: number) {
    setPattern((prev) => {
      const active = prev[padId] ?? [];
      const next = active.includes(step)
        ? active.filter((s) => s !== step)
        : [...active, step].sort((a, b) => a - b);
      return { ...prev, [padId]: next };
    });
    setPresetId("");
  }

  function applyPreset(id: string) {
    const preset = PATTERN_PRESETS.find((p) => p.id === id);
    if (!preset) return;
    setPattern(presetToPattern(preset));
    setPresetId(id);
  }

  function swap(padId: string) {
    setVariants((v) => ({ ...v, [padId]: ((v[padId] ?? 1) + 1) % 3 }));
  }

  return (
    <div
      role="group"
      aria-label={`${kit.title} by ${kit.maker}. Keys ${pads.map((p) => p.key).join(" ")} play pads; Space plays the pattern.`}
      tabIndex={-1}
      onKeyDown={onKeyDown}
      className="flex min-w-0 flex-col gap-5 outline-none"
    >
      <Transport
        playing={playing}
        onToggle={togglePlay}
        disabled={!engine}
        bpm={bpm}
        onBpm={setBpm}
        accent={accent}
        presetId={presetId}
        onPreset={applyPreset}
      />

      <div
        ref={padStrip}
        className={cn("flex gap-2 overflow-x-auto px-px py-1 [scrollbar-width:none]", SCROLL_FADE_CLASS)}
      >
        {pads.map((pad) => {
          const steps = pattern[pad.id] ?? [];
          const onBeat = playStep >= 0 && steps.includes(playStep);
          return (
            <PadCell
              key={pad.id}
              pad={pad}
              accent={accent}
              variant={variants[pad.id] ?? 1}
              flashedAt={flash[pad.id]}
              onBeat={onBeat}
              onFire={() => fire(pad)}
              onSwap={() => swap(pad.id)}
            />
          );
        })}
      </div>

      <StepGrid
        pads={pads}
        pattern={pattern}
        stepCount={STEP_COUNT}
        playStep={playStep}
        accent={accent}
        flash={flash}
        onToggleStep={toggleStep}
      />
    </div>
  );
}

function Transport({
  playing,
  onToggle,
  disabled,
  bpm,
  onBpm,
  accent,
  presetId,
  onPreset,
}: {
  playing: boolean;
  onToggle: () => void;
  disabled: boolean;
  bpm: number;
  onBpm: (bpm: number) => void;
  accent: string;
  presetId: string;
  onPreset: (id: string) => void;
}) {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <button
        type="button"
        onClick={onToggle}
        disabled={disabled}
        aria-label={playing ? "Stop" : "Play kit"}
        className="flex size-11 shrink-0 items-center justify-center rounded-full text-primary-foreground outline-none transition-[filter,scale] duration-150 ease-out hover:brightness-110 active:scale-[0.96] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50"
        style={{ background: accent }}
      >
        {playing ? <StopIcon width={18} height={18} /> : <PlayIcon width={18} height={18} className="ml-0.5" />}
      </button>

      <Stepper
        label="BPM"
        value={bpm}
        onChange={(d) => onBpm(Math.min(MAX_BPM, Math.max(MIN_BPM, bpm + d)))}
      />

      <div className="ml-auto flex items-center gap-1">
        {PATTERN_PRESETS.map((p) => {
          const on = presetId === p.id;
          return (
            <button
              key={p.id}
              type="button"
              onClick={() => onPreset(p.id)}
              aria-pressed={on}
              className={cn(
                "relative inline-flex h-auto items-center gap-1.5 rounded-lg px-2.5 py-2 text-xs font-medium leading-none outline-none",
                "after:absolute after:-inset-x-1 after:-inset-y-1.5 after:content-['']",
                "transition-[scale,background-color,color] duration-150 ease-out active:scale-[0.96]",
                "focus-visible:ring-2 focus-visible:ring-ring",
                on
                  ? "bg-primary/15 text-primary hover:bg-primary/25"
                  : "text-muted-foreground hover:bg-primary/10 hover:text-primary",
              )}
            >
              {p.name}
            </button>
          );
        })}
      </div>
    </div>
  );
}

function Stepper({
  label,
  value,
  onChange,
}: {
  label: string;
  value: number;
  onChange: (delta: number) => void;
}) {
  return (
    <div className="flex h-10 items-center rounded-lg bg-background ring-1 ring-border">
      <StepButton ariaLabel={`Decrease ${label}`} onClick={() => onChange(-1)}>
        &minus;
      </StepButton>
      <span className="flex min-w-[58px] items-center justify-center gap-1 text-sm tabular-nums text-foreground">
        {value}
        <span className="text-sm text-muted-foreground">{label}</span>
      </span>
      <StepButton ariaLabel={`Increase ${label}`} onClick={() => onChange(1)}>
        +
      </StepButton>
    </div>
  );
}

function StepButton({
  children,
  ariaLabel,
  onClick,
}: {
  children: ReactNode;
  ariaLabel: string;
  onClick: () => void;
}) {
  return (
    <button
      type="button"
      aria-label={ariaLabel}
      onClick={onClick}
      className="flex size-10 items-center justify-center rounded-lg text-base text-muted-foreground outline-none transition-colors duration-150 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
    >
      {children}
    </button>
  );
}

// One-shot flash overlay when a pad is hand-fired. Mounts fresh per hit (keyed
// on the fire timestamp) so anime.js can run the fade from scratch each time.
function PadFlash({ accent }: { accent: string }) {
  const ref = useRef<HTMLSpanElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion()) return;
    animate(el, { opacity: [0.55, 0], duration: 300, ease: EASE_OUT });
  }, []);
  return (
    <span
      ref={ref}
      aria-hidden
      className="pointer-events-none absolute inset-0 rounded-xl"
      style={{ background: accent, opacity: 0 }}
    />
  );
}

function PadCell({
  pad,
  accent,
  variant,
  flashedAt,
  onBeat,
  onFire,
  onSwap,
}: {
  pad: Pad;
  accent: string;
  variant: number;
  flashedAt?: number;
  onBeat: boolean;
  onFire: () => void;
  onSwap: () => void;
}) {
  return (
    <div className="flex w-[84px] shrink-0 flex-col gap-2">
      <button
        type="button"
        onClick={onFire}
        aria-label={`${pad.name} (${pad.key})`}
        className="relative flex aspect-square flex-col items-center justify-center gap-1.5 rounded-xl bg-background outline-none ring-1 ring-border transition-[background-color,scale] duration-150 ease-out hover:bg-muted active:scale-[0.96] focus-visible:ring-2 focus-visible:ring-ring"
      >
        {/* Beat glow: lit when the playhead lands on this pad's active step. A
            CSS transition keeps it interruptible; on is snappy, off decays. */}
        <span
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-xl transition-opacity ease-out"
          style={{
            background: accent,
            opacity: onBeat ? 0.5 : 0,
            transitionDuration: onBeat ? "50ms" : "180ms",
          }}
        />
        {/* Hand-fire flash, keyed on timestamp so each hit re-triggers. */}
        {flashedAt ? <PadFlash key={`flash-${flashedAt}`} accent={accent} /> : null}

        <span
          aria-hidden
          className="relative flex size-7 items-center justify-center rounded-md bg-muted text-sm text-foreground ring-1 ring-border"
        >
          {pad.key}
        </span>
        <span
          aria-hidden
          className="relative w-full min-w-0 truncate px-2 text-center text-xs text-foreground"
          title={pad.name}
        >
          {pad.name}
        </span>
      </button>

      <button
        type="button"
        onClick={onSwap}
        aria-label={`Swap ${pad.name} sound, variant ${variant + 1} of 3`}
        className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-muted text-xs tabular-nums text-muted-foreground outline-none transition-colors duration-150 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
      >
        <ShuffleIcon width={13} height={13} />
        {variant + 1}/3
      </button>
    </div>
  );
}

function PlayIcon(props: SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="1.45 -0.05 24.1 24.1" aria-hidden {...props}>
      <path
        fill="currentColor"
        d="M21.409 9.353a2.998 2.998 0 0 1 0 5.294L8.597 21.614C6.534 22.737 4 21.277 4 18.968V5.033c0-2.31 2.534-3.769 4.597-2.648z"
      />
    </svg>
  );
}

function StopIcon(props: SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="1.16 1.16 21.69 21.69" aria-hidden {...props}>
      <path
        fill="currentColor"
        d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21z"
      />
    </svg>
  );
}

function ShuffleIcon(props: SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="-0.05 -0.06 24.1 24.1" aria-hidden {...props}>
      <path
        fill="currentColor"
        d="M21.924 6.617a1 1 0 0 0-.217-.324l-3-3a1 1 0 1 0-1.414 1.414L18.586 6h-3.321a5 5 0 0 0-4.288 2.428l-3.67 6.115A3 3 0 0 1 4.736 16H3a1 1 0 1 0 0 2h1.735a5 5 0 0 0 4.288-2.428l3.67-6.115A3 3 0 0 1 15.264 8h3.32l-1.292 1.293a1 1 0 0 0 1.414 1.414l3-3A1 1 0 0 0 22 7m-.076-.383a1 1 0 0 1 .076.38z"
      />
      <path
        fill="currentColor"
        d="m21.706 17.708l-2.999 3a1 1 0 0 1-1.414-1.415L18.586 18h-3.321a5 5 0 0 1-4.288-2.428l-3.67-6.115A3 3 0 0 0 4.736 8H3a1 1 0 0 1 0-2h1.735a5 5 0 0 1 4.288 2.428l3.67 6.115A3 3 0 0 0 15.264 16h3.32l-1.292-1.293a1 1 0 0 1 1.414-1.414l3 3c.195.194.292.45.293.704V17a1 1 0 0 1-.294.708z"
      />
    </svg>
  );
}
patterns.ts
import type { Pad, Pattern, PatternPreset } from "@/lib/openkit/sampler-types";

// The eight pads every kit exposes, on the home-row keys so a kit plays from the
// keyboard without reaching. Pads without a sample fall back to the synth
// voice, so the layout never changes shape.
export const PADS: Pad[] = [
  { id: "kick", name: "Kick", key: "A", type: "kick" },
  { id: "snare", name: "Snare", key: "S", type: "snare" },
  { id: "hat", name: "Hat", key: "D", type: "hat" },
  { id: "openhat", name: "Open Hat", key: "F", type: "openhat" },
  { id: "clap", name: "Clap", key: "G", type: "clap" },
  { id: "rim", name: "Rim", key: "H", type: "rim" },
  { id: "tom", name: "Tom", key: "J", type: "tom" },
  { id: "perc", name: "Perc", key: "K", type: "perc" },
];

export const STEP_COUNT = 16;

// Patterns are stored as the active step indices per pad, which reads far more
// clearly than a boolean[16]. Three presets so "change the pattern" is one tap,
// then editable per step.
export const PATTERN_PRESETS: PatternPreset[] = [
  {
    id: "boom-bap",
    name: "Boom bap",
    steps: {
      kick: [0, 6, 10],
      snare: [4, 12],
      hat: [0, 2, 4, 6, 8, 10, 12, 14],
      openhat: [14],
      perc: [7],
    },
  },
  {
    id: "trap",
    name: "Trap",
    steps: {
      kick: [0, 3, 8, 11],
      snare: [8],
      clap: [8],
      hat: [0, 2, 4, 6, 8, 10, 11, 12, 13, 14],
      openhat: [15],
    },
  },
  {
    id: "broken",
    name: "Broken",
    steps: {
      kick: [0, 10],
      snare: [4, 12],
      rim: [2, 7, 9],
      hat: [1, 3, 5, 7, 9, 11, 13, 15],
      tom: [14],
    },
  },
];

export function presetToPattern(preset: PatternPreset): Pattern {
  // Return a deep copy so editing the live pattern never mutates the preset.
  const out: Pattern = {};
  for (const pad of PADS) out[pad.id] = [...(preset.steps[pad.id] ?? [])];
  return out;
}