← code

euclidean rhythm

Bjorklund's algorithm distributes N hits as evenly as possible across M steps — a natural sibling to the drum machine, generated rather than hand-programmed.

low
rot 0
mid
rot 0
high
rot 0
presets

Low is E(3,8), the Cuban tresillo. Mid is E(5,8), its complement — together they’re the two onset-classes you get by splitting 8 steps at 3 and 5. High is a busier E(7,16) layer for texture.

Bjorklund's algorithm answers one question: given k hits and n steps, how do you space the hits out as evenly as possible? It works by repeatedly folding a set of "hit" groups and a set of "rest" groups onto each other — the same kind of remainder-splitting step Euclid's GCD algorithm uses, which is where the name comes from. Godfried Toussaint's well-known paper on the subject pointed out that a striking number of traditional rhythms from around the world are exactly this: E(3,8) is the Cuban tresillo, E(5,8) is the cinquillo, and a rotation of E(5,16) is the standard bossa nova clave — all three ship as presets below.

Each of the three tracks here is independent: its own hit count, its own step count (2 to 16), and its own rotation. They all play against the same underlying clock, though, so tracks with different step counts drift in and out of phase with each other instead of looping in lockstep — a short pattern finishes its cycle and restarts several times while a longer one is still working through its first pass. That's the generative payoff of building rhythms this way instead of hand-drawing every step on a 16-step grid: change one number and the whole texture shifts.

The evenness itself was checked before any of this was wired up: for every step count from 2 to 32 and every hit count in between, the gaps between consecutive hits (wrapping around the end) take at most two distinct lengths, differing by exactly one step. That's the actual mathematical definition of "as even as possible," and it held for every case tried, not just the presets shown here.

view source
import { useEffect, useRef, useState } from 'react';

interface TrackConfig {
  pulses: number;
  steps: number;
  rotation: number;
}

const VOICES = [
  { id: 'low', label: 'low' },
  { id: 'mid', label: 'mid' },
  { id: 'high', label: 'high' },
] as const;

type VoiceId = (typeof VOICES)[number]['id'];

interface Preset {
  id: string;
  label: string;
  tracks: TrackConfig[];
  blurb: string;
}

const PRESETS: Preset[] = [
  {
    id: 'tresillo',
    label: 'tresillo trio',
    tracks: [
      { pulses: 3, steps: 8, rotation: 0 },
      { pulses: 5, steps: 8, rotation: 0 },
      { pulses: 7, steps: 16, rotation: 0 },
    ],
    blurb:
      'Low is E(3,8), the Cuban tresillo. Mid is E(5,8), its complement — together they’re the two onset-classes you get by splitting 8 steps at 3 and 5. High is a busier E(7,16) layer for texture.',
  },
  {
    id: 'bossa',
    label: 'bossa nova',
    tracks: [
      { pulses: 3, steps: 8, rotation: 0 },
      { pulses: 5, steps: 16, rotation: 6 },
      { pulses: 9, steps: 16, rotation: 0 },
    ],
    blurb:
      'Mid, rotated by 6, is E(5,16) turned into the standard bossa nova clave. Low keeps the tresillo underneath it, high fills in a dense E(9,16) layer.',
  },
  {
    id: 'sparse',
    label: 'sparse',
    tracks: [
      { pulses: 2, steps: 9, rotation: 0 },
      { pulses: 3, steps: 11, rotation: 2 },
      { pulses: 4, steps: 13, rotation: 5 },
    ],
    blurb:
      'Three odd, mutually prime-ish step counts with just a few pulses each — the tracks take a long time to realign, so the combination barely repeats.',
  },
];

// Bjorklund's algorithm: start with `pulses` groups of [1] and `steps - pulses`
// groups of [0], then repeatedly fold the smaller set of groups onto the front
// of the larger one until at most one group of [0]s is left over. Verified
// standalone against known reference patterns (Cuban tresillo E(3,8), cinquillo
// E(5,8), the bossa nova clave as a rotation of E(5,16), etc.) and against the
// general "onset gaps take at most two values, differing by 1" evenness
// property across a full sweep of step counts before being ported in here.
function euclideanRhythm(pulses: number, steps: number): boolean[] {
  if (steps <= 0) return [];
  const k = Math.max(0, Math.min(pulses, steps));
  if (k === 0) return new Array(steps).fill(false);
  if (k === steps) return new Array(steps).fill(true);

  let a: number[][] = Array.from({ length: k }, () => [1]);
  let b: number[][] = Array.from({ length: steps - k }, () => [0]);

  while (b.length > 1) {
    const m = Math.min(a.length, b.length);
    const newA: number[][] = [];
    for (let i = 0; i < m; i++) newA.push([...a[i], ...b[i]]);
    const remainder = a.length > m ? a.slice(m) : b.slice(m);
    a = newA;
    b = remainder;
  }

  return [...a, ...b].flat().map((v) => v === 1);
}

function rotateLeft<T>(pattern: T[], amount: number): T[] {
  const n = pattern.length;
  if (n === 0) return pattern;
  const r = ((amount % n) + n) % n;
  return pattern.map((_, i) => pattern[(i + r) % n]);
}

function patternFor(track: TrackConfig): boolean[] {
  return rotateLeft(euclideanRhythm(track.pulses, track.steps), track.rotation);
}

function clampTrack(
  track: TrackConfig,
  patch: Partial<TrackConfig>,
): TrackConfig {
  const steps = Math.max(2, Math.min(16, patch.steps ?? track.steps));
  const pulses = Math.max(0, Math.min(steps, patch.pulses ?? track.pulses));
  const rotationRaw = patch.rotation ?? track.rotation;
  const rotation = ((rotationRaw % steps) + steps) % steps;
  return { steps, pulses, rotation };
}

function noiseBuffer(ctx: AudioContext, duration: number): AudioBuffer {
  const buffer = ctx.createBuffer(1, ctx.sampleRate * duration, ctx.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  return buffer;
}

function playLow(ctx: AudioContext, out: AudioNode, time: number) {
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = 'sine';
  osc.frequency.setValueAtTime(160, time);
  osc.frequency.exponentialRampToValueAtTime(55, time + 0.12);
  gain.gain.setValueAtTime(0.9, time);
  gain.gain.exponentialRampToValueAtTime(0.001, time + 0.2);
  osc.connect(gain).connect(out);
  osc.start(time);
  osc.stop(time + 0.2);
}

function playMid(ctx: AudioContext, out: AudioNode, time: number) {
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = 'triangle';
  osc.frequency.setValueAtTime(520, time);
  gain.gain.setValueAtTime(0.5, time);
  gain.gain.exponentialRampToValueAtTime(0.001, time + 0.09);
  osc.connect(gain).connect(out);
  osc.start(time);
  osc.stop(time + 0.09);
}

function playHigh(ctx: AudioContext, out: AudioNode, time: number) {
  const noise = ctx.createBufferSource();
  noise.buffer = noiseBuffer(ctx, 0.05);
  const filter = ctx.createBiquadFilter();
  filter.type = 'highpass';
  filter.frequency.value = 6000;
  const gain = ctx.createGain();
  gain.gain.setValueAtTime(0.5, time);
  gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
  noise.connect(filter).connect(gain).connect(out);
  noise.start(time);
  noise.stop(time + 0.05);
}

const VOICE_FNS: Record<
  VoiceId,
  (ctx: AudioContext, out: AudioNode, time: number) => void
> = {
  low: playLow,
  mid: playMid,
  high: playHigh,
};

const SCHEDULE_AHEAD = 0.1; // seconds
const LOOKAHEAD_MS = 25;

export default function EuclideanRhythm() {
  const [tracks, setTracks] = useState<TrackConfig[]>(PRESETS[0].tracks);
  const [presetId, setPresetId] = useState<string>(PRESETS[0].id);
  const [playing, setPlaying] = useState(false);
  const [bpm, setBpm] = useState(120);
  const [currentStep, setCurrentStep] = useState(-1);

  const tracksRef = useRef(tracks);
  const bpmRef = useRef(bpm);
  const audioCtxRef = useRef<AudioContext | null>(null);
  const masterGainRef = useRef<GainNode | null>(null);
  const timerRef = useRef<number | null>(null);
  const nextStepTimeRef = useRef(0);
  const globalStepRef = useRef(0);

  useEffect(() => {
    tracksRef.current = tracks;
  }, [tracks]);
  useEffect(() => {
    bpmRef.current = bpm;
  }, [bpm]);

  useEffect(() => {
    return () => {
      if (timerRef.current) clearInterval(timerRef.current);
      audioCtxRef.current?.close();
    };
  }, []);

  const secondsPerStep = () => 60 / bpmRef.current / 4;

  const scheduler = () => {
    const ctx = audioCtxRef.current;
    const out = masterGainRef.current;
    if (!ctx || !out) return;
    while (nextStepTimeRef.current < ctx.currentTime + SCHEDULE_AHEAD) {
      const g = globalStepRef.current;
      const time = nextStepTimeRef.current;
      VOICES.forEach((voice, i) => {
        const track = tracksRef.current[i];
        const pattern = patternFor(track);
        if (pattern[g % track.steps]) VOICE_FNS[voice.id](ctx, out, time);
      });
      const delay = Math.max(0, (time - ctx.currentTime) * 1000);
      setTimeout(() => setCurrentStep(g), delay);
      nextStepTimeRef.current += secondsPerStep();
      globalStepRef.current = g + 1;
    }
  };

  const start = () => {
    if (!audioCtxRef.current) {
      const ctx = new AudioContext();
      const gain = ctx.createGain();
      gain.gain.value = 0.8;
      gain.connect(ctx.destination);
      audioCtxRef.current = ctx;
      masterGainRef.current = gain;
    }
    const ctx = audioCtxRef.current;
    if (ctx.state === 'suspended') ctx.resume();
    globalStepRef.current = 0;
    nextStepTimeRef.current = ctx.currentTime + 0.05;
    setPlaying(true);
    timerRef.current = window.setInterval(scheduler, LOOKAHEAD_MS);
  };

  const stop = () => {
    setPlaying(false);
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
    setCurrentStep(-1);
  };

  const updateTrack = (index: number, patch: Partial<TrackConfig>) => {
    setPresetId('');
    setTracks((prev) =>
      prev.map((t, i) => (i === index ? clampTrack(t, patch) : t)),
    );
  };

  const loadPreset = (preset: Preset) => {
    setPresetId(preset.id);
    setTracks(preset.tracks.map((t) => clampTrack(t, {})));
  };

  const randomize = () => {
    setPresetId('');
    setTracks((prev) =>
      prev.map(() => {
        const steps = 4 + Math.floor(Math.random() * 13); // 4..16
        const pulses = 1 + Math.floor(Math.random() * (steps - 1)); // 1..steps-1
        const rotation = Math.floor(Math.random() * steps);
        return { steps, pulses, rotation };
      }),
    );
  };

  const activePreset = PRESETS.find((p) => p.id === presetId);

  return (
    <div className="euclidean-rhythm">
      <div className="tracks">
        {VOICES.map((voice, i) => {
          const track = tracks[i];
          const pattern = patternFor(track);
          const current = playing ? currentStep % track.steps : -1;
          return (
            <div className="track" key={voice.id}>
              <span className="track-label">{voice.label}</span>
              <div
                className="steps"
                style={{
                  gridTemplateColumns: `repeat(${track.steps}, minmax(0, 1fr))`,
                }}
              >
                {pattern.map((active, i2) => (
                  <span
                    key={i2}
                    className={[
                      'step',
                      active ? 'active' : '',
                      i2 === current ? 'current' : '',
                    ]
                      .filter(Boolean)
                      .join(' ')}
                  />
                ))}
              </div>
              <div className="track-controls">
                <label>
                  hits {track.pulses}
                  <input
                    type="range"
                    min={0}
                    max={track.steps}
                    value={track.pulses}
                    onChange={(e) =>
                      updateTrack(i, { pulses: Number(e.target.value) })
                    }
                  />
                </label>
                <label>
                  steps {track.steps}
                  <input
                    type="range"
                    min={2}
                    max={16}
                    value={track.steps}
                    onChange={(e) =>
                      updateTrack(i, { steps: Number(e.target.value) })
                    }
                  />
                </label>
                <div className="rotate">
                  <button
                    type="button"
                    onClick={() =>
                      updateTrack(i, { rotation: track.rotation - 1 })
                    }
                  >
                    &lsaquo;
                  </button>
                  <span>rot {track.rotation}</span>
                  <button
                    type="button"
                    onClick={() =>
                      updateTrack(i, { rotation: track.rotation + 1 })
                    }
                  >
                    &rsaquo;
                  </button>
                </div>
              </div>
            </div>
          );
        })}
      </div>

      <div className="controls">
        <button type="button" onClick={() => (playing ? stop() : start())}>
          {playing ? 'stop' : 'play'}
        </button>
        <button type="button" onClick={randomize}>
          randomize
        </button>
        <label className="bpm">
          {bpm} bpm
          <input
            type="range"
            min={60}
            max={180}
            value={bpm}
            onChange={(e) => setBpm(Number(e.target.value))}
          />
        </label>
      </div>

      <div className="presets">
        <span className="group-label">presets</span>
        {PRESETS.map((preset) => (
          <button
            key={preset.id}
            type="button"
            className={presetId === preset.id ? 'active' : ''}
            onClick={() => loadPreset(preset)}
          >
            {preset.label}
          </button>
        ))}
      </div>

      <p className="blurb">
        {activePreset
          ? activePreset.blurb
          : 'Custom pattern — drag hits, steps, or rotation on any track, or load a preset above.'}
      </p>

      <style>{`
        .euclidean-rhythm {
          border: 1px solid #2a2d31;
          background: #0e1113;
          padding: 1rem;
        }
        .euclidean-rhythm .tracks {
          display: flex;
          flex-direction: column;
          gap: 1rem;
        }
        .euclidean-rhythm .track {
          display: flex;
          flex-direction: column;
          gap: 0.4rem;
        }
        .euclidean-rhythm .track-label {
          color: var(--muted);
          font-size: 0.75rem;
          text-transform: uppercase;
          letter-spacing: 0.04em;
        }
        .euclidean-rhythm .steps {
          display: grid;
          gap: 0.2rem;
        }
        .euclidean-rhythm .step {
          aspect-ratio: 1;
          border: 1px solid #2a2d31;
          background: transparent;
          display: block;
        }
        .euclidean-rhythm .step.active {
          background: var(--accent);
          border-color: var(--accent);
        }
        .euclidean-rhythm .step.current {
          box-shadow: 0 0 0 2px #e6e6e6 inset;
        }
        .euclidean-rhythm .track-controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 1rem;
        }
        .euclidean-rhythm .track-controls label {
          display: flex;
          align-items: center;
          gap: 0.5rem;
          color: var(--muted);
          font-size: 0.75rem;
        }
        .euclidean-rhythm .rotate {
          display: flex;
          align-items: center;
          gap: 0.4rem;
          color: var(--muted);
          font-size: 0.75rem;
        }
        .euclidean-rhythm .rotate button {
          font-family: inherit;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.1rem 0.5rem;
          cursor: pointer;
          line-height: 1.4;
        }
        .euclidean-rhythm .rotate button:hover {
          color: #e6e6e6;
        }
        .euclidean-rhythm .controls {
          display: flex;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1.25rem;
          padding-top: 1rem;
          border-top: 1px solid #2a2d31;
          flex-wrap: wrap;
        }
        .euclidean-rhythm .controls button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.25rem 0.75rem;
          cursor: pointer;
        }
        .euclidean-rhythm .controls button:hover {
          color: #e6e6e6;
        }
        .euclidean-rhythm .bpm {
          display: flex;
          align-items: center;
          gap: 0.5rem;
          color: var(--muted);
          font-size: 0.75rem;
        }
        .euclidean-rhythm .presets {
          display: flex;
          align-items: center;
          gap: 0.4rem;
          flex-wrap: wrap;
          margin-top: 0.75rem;
        }
        .euclidean-rhythm .group-label {
          color: var(--muted);
          font-size: 0.75rem;
          text-transform: uppercase;
          letter-spacing: 0.04em;
          margin-right: 0.15rem;
        }
        .euclidean-rhythm .presets button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.25rem 0.6rem;
          cursor: pointer;
        }
        .euclidean-rhythm .presets button:hover {
          color: #e6e6e6;
        }
        .euclidean-rhythm .presets button.active {
          color: #0b0d0f;
          background: var(--accent);
          border-color: var(--accent);
        }
        .euclidean-rhythm .blurb {
          color: var(--muted);
          font-size: 0.8rem;
          margin: 0.75rem 0 0;
          max-width: 60ch;
        }
      `}</style>
    </div>
  );
}