← 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

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 remainder-splitting step that Euclid's algorithm for the greatest common divisor uses, which is where the name comes from. The notation below, E(k,n), is the standard one.

toussaint's observation

The musical observation is Godfried Toussaint's, from his 2005 paper The Euclidean Algorithm Generates Traditional Musical Rhythms: a striking number of rhythms that developed independently around the world turn out to be exactly these Euclidean patterns. E(3,8) is the Cuban tresillo, the same figure American musicians call the habanera; 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. Toussaint later expanded the argument into a book, The Geometry of Musical Rhythm, which treats rhythms as points spaced around a circle and asks what makes some of those arrangements stick.

the three tracks

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. Two tracks only realign after a number of steps equal to the least common multiple of their lengths, which is why a 7 against a 16 takes so long to repeat itself. 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.

checking the evenness

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;
        }
      `}</style>
    </div>
  );
}