---
format: typebulb/v1
name: Polyrhythms
---

**code.tsx**

```tsx
import React, { useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";

type TrackId = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
const TRACKS: TrackId[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const MAX_ACTIVE_RINGS = 5;
const CYCLE_BEATS = 4;

const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
const getCycleDuration = (bpm: number) => (60 / clamp(bpm, 1, 400)) * CYCLE_BEATS;

function withAlpha(color: string, alpha: number) {
  const a = clamp(alpha, 0, 1);
  const rgb = color.match(/^rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/i);
  if (rgb) return `rgba(${rgb[1]},${rgb[2]},${rgb[3]},${a})`;
  const hex = color.replace("#", "").trim();
  const parse = (s: string) => parseInt(s.length === 1 ? s + s : s, 16);
  if (hex.length === 3) return `rgba(${parse(hex[0])},${parse(hex[1])},${parse(hex[2])},${a})`;
  if (hex.length === 6) return `rgba(${parse(hex.slice(0,2))},${parse(hex.slice(2,4))},${parse(hex.slice(4,6))},${a})`;
  return color;
}

function nextHitTimeAfter(t: number, startTime: number, cycleDuration: number, N: number) {
  if (cycleDuration <= 0 || N <= 0) return t;
  const cycleIndex = Math.floor((t - startTime) / cycleDuration);
  const cycleStart = startTime + cycleIndex * cycleDuration;
  const interval = cycleDuration / N;
  const k = Math.max(0, Math.ceil((t - cycleStart - 1e-6) / interval));
  return k >= N ? cycleStart + cycleDuration : cycleStart + k * interval;
}

function hitIndexFromTime(startTime: number, cycleDuration: number, N: number, time: number) {
  if (cycleDuration <= 0 || N <= 0) return 0;
  const phase = (((time - startTime) % cycleDuration) + cycleDuration) % cycleDuration;
  return clamp(Math.floor((phase + 1e-6) / (cycleDuration / N)), 0, N - 1);
}

function createClick(ctx: AudioContext, dest: AudioNode, time: number, freq: number, volume: number) {
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  const filter = ctx.createBiquadFilter();

  osc.type = "square";
  osc.frequency.setValueAtTime(freq, time);
  filter.type = "bandpass";
  filter.frequency.setValueAtTime(freq * 1.5, time);
  filter.Q.setValueAtTime(7, time);

  const v = clamp(volume, 0, 1);
  gain.gain.setValueAtTime(0.0001, time);
  gain.gain.exponentialRampToValueAtTime(0.0001 + v * 0.85, time + 0.002);
  gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.035);

  osc.connect(filter).connect(gain).connect(dest);
  osc.start(time);
  osc.stop(time + 0.06);
}

type LastHit = { t: number; k: number };

function PolyrhythmRings({ activeTracks, playing, bpm, lastHits, getAudioTime, getStartTime }: {
  activeTracks: TrackId[];
  playing: boolean;
  bpm: number;
  lastHits: React.MutableRefObject<Record<TrackId, LastHit>>;
  getAudioTime: () => number | null;
  getStartTime: () => number | null;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const wrapRef = useRef<HTMLDivElement>(null);
  const sizeRef = useRef({ w: 0, h: 0, dpr: 1 });

  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;

    const updateSize = () => {
      const rect = el.getBoundingClientRect();
      sizeRef.current = {
        w: Math.max(1, Math.floor(rect.width)),
        h: Math.max(1, Math.floor(rect.height)),
        dpr: clamp(window.devicePixelRatio || 1, 1, 2)
      };
    };
    updateSize();
    const ro = new ResizeObserver(updateSize);
    ro.observe(el);
    window.addEventListener("resize", updateSize);
    return () => { ro.disconnect(); window.removeEventListener("resize", updateSize); };
  }, []);

  useEffect(() => {
    let raf = 0;
    const cycleDuration = getCycleDuration(bpm);

    const draw = () => {
      const canvas = canvasRef.current;
      const { w: cssW, h: cssH, dpr } = sizeRef.current;
      if (!canvas || cssW <= 1 || cssH <= 1) { raf = requestAnimationFrame(draw); return; }

      const [nextW, nextH] = [Math.floor(cssW * dpr), Math.floor(cssH * dpr)];
      if (canvas.width !== nextW || canvas.height !== nextH) {
        canvas.width = nextW;
        canvas.height = nextH;
      }

      const ctx = canvas.getContext("2d");
      if (!ctx) { raf = requestAnimationFrame(draw); return; }
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

      const styles = getComputedStyle(document.documentElement);
      const getVar = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback;
      const card = getVar("--card", "#111");
      const border = getVar("--border", "#333");
      const accent = getVar("--accent", "#7aa2ff");
      const ringPalette = [1, 2, 3, 4, 5].map(i => getVar(`--ring${i}`, accent));

      const [w, h, cx, cy] = [cssW, cssH, cssW / 2, cssH / 2];
      const minDim = Math.min(w, h);
      ctx.clearRect(0, 0, w, h);

      const rings = activeTracks.length;
      let ringW = clamp(minDim * 0.035, 10, 64);
      const playheadPad = clamp(minDim * 0.06, 22, 64);
      const maxOuter = Math.max(0, minDim / 2 - (ringW / 2 + playheadPad));

      let [ringGap, baseR, outerR] = [0, 0, 0];

      if (rings > 0 && maxOuter > 0) {
        const [minBaseR, minGap, preferredGap] = [ringW * 2.4, ringW * 1.55, ringW * 2.25];

        if (rings <= 1) {
          baseR = outerR = clamp(maxOuter, minBaseR, maxOuter);
        } else {
          ringGap = clamp(preferredGap, minGap, Math.max(minGap, (maxOuter - minBaseR) / (rings - 1)));
          baseR = Math.max(minBaseR, maxOuter - (rings - 1) * ringGap);
          outerR = baseR + (rings - 1) * ringGap;

          if (outerR > maxOuter + 0.001) {
            const f = maxOuter / outerR;
            [ringW, ringGap, baseR, outerR] = [ringW * f, ringGap * f, baseR * f, outerR * f];
          }
        }
      }

      ctx.fillStyle = card;
      ctx.strokeStyle = border;
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.roundRect(0.5, 0.5, w - 1, h - 1, 18);
      ctx.fill();
      ctx.stroke();

      const audioNow = getAudioTime();
      const startTime = getStartTime();

      let phase01 = 0;
      if (playing && audioNow != null && startTime != null && cycleDuration > 0) {
        phase01 = ((((audioNow - startTime) % cycleDuration) + cycleDuration) % cycleDuration) / cycleDuration;
      }
      const playAngle = -Math.PI / 2 + phase01 * Math.PI * 2;

      if (playing && activeTracks.length > 0) {
        const len = outerR + ringW / 2 + Math.max(16, playheadPad * 0.55);
        ctx.strokeStyle = withAlpha(accent, 0.95);
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(cx, cy);
        ctx.lineTo(cx + Math.cos(playAngle) * len, cy + Math.sin(playAngle) * len);
        ctx.stroke();
      }

      ctx.beginPath();
      ctx.fillStyle = withAlpha(border, 0.25);
      ctx.arc(cx, cy, 3, 0, Math.PI * 2);
      ctx.fill();

      const baseAngle = -Math.PI / 2;
      const gapPx = 7;
      const baseAlpha = playing ? 0.82 : 0.66;

      activeTracks.forEach((N, i) => {
        const radius = baseR + i * ringGap;
        const ringColor = ringPalette[i % ringPalette.length];
        const lh = lastHits.current[N];
        const recent = playing && audioNow != null && audioNow - lh.t >= 0 && audioNow - lh.t < 0.14;
        const seg = (Math.PI * 2) / N;

        for (let k = 0; k < N; k++) {
          const isHot = recent && lh.k === k;
          const halfW = (isHot ? ringW + 1.5 : ringW) / 2;
          const [rOuter, rInner] = [Math.max(1, radius + halfW), Math.max(1, radius - halfW)];

          const gapOuter = Math.min(seg * 0.6, gapPx / Math.max(18, rOuter));
          const gapInner = Math.min(seg * 0.6, gapPx / Math.max(18, rInner));

          const [a0o, a1o] = [baseAngle + k * seg + gapOuter / 2, baseAngle + (k + 1) * seg - gapOuter / 2];
          const [a0i, a1i] = [baseAngle + k * seg + gapInner / 2, baseAngle + (k + 1) * seg - gapInner / 2];

          if (a1o <= a0o || a1i <= a0i) continue;

          if (isHot && audioNow != null) {
            const p = 1 - clamp(audioNow - lh.t, 0, 0.14) / 0.14;
            const gapC = Math.min(seg * 0.6, gapPx / Math.max(18, radius));
            ctx.beginPath();
            ctx.strokeStyle = withAlpha(ringColor, 0.28 * p);
            ctx.lineWidth = ringW + 5;
            ctx.arc(cx, cy, radius, baseAngle + k * seg + gapC / 2, baseAngle + (k + 1) * seg - gapC / 2);
            ctx.stroke();
          }

          ctx.beginPath();
          ctx.fillStyle = withAlpha(ringColor, isHot ? 0.98 : baseAlpha);
          ctx.moveTo(cx + Math.cos(a0o) * rOuter, cy + Math.sin(a0o) * rOuter);
          ctx.arc(cx, cy, rOuter, a0o, a1o, false);
          ctx.lineTo(cx + Math.cos(a1i) * rInner, cy + Math.sin(a1i) * rInner);
          ctx.arc(cx, cy, rInner, a1i, a0i, true);
          ctx.closePath();
          ctx.fill();
        }
      });

      raf = requestAnimationFrame(draw);
    };

    raf = requestAnimationFrame(draw);
    return () => cancelAnimationFrame(raf);
  }, [activeTracks, playing, bpm, getAudioTime, getStartTime, lastHits]);

  return (
    <div className="ringsWrap" ref={wrapRef}>
      <canvas ref={canvasRef} />
    </div>
  );
}

function App() {
  const [bpm, setBpm] = useState(120);
  const [master, setMaster] = useState(0.85);
  const [isPlaying, setIsPlaying] = useState(false);
  const [enabled, setEnabled] = useState<Record<TrackId, boolean>>(
    () => Object.fromEntries(TRACKS.map(n => [n, n === 3 || n === 4])) as Record<TrackId, boolean>
  );

  const audioCtxRef = useRef<AudioContext | null>(null);
  const masterGainRef = useRef<GainNode | null>(null);
  const startTimeRef = useRef<number | null>(null);
  const timerRef = useRef<number | null>(null);
  const lastHits = useRef<Record<TrackId, LastHit>>(
    Object.fromEntries(TRACKS.map(n => [n, { t: -Infinity, k: 0 }])) as Record<TrackId, LastHit>
  );

  const stateRef = useRef({ enabled, bpm, master });
  stateRef.current = { enabled, bpm, master };

  const activeTracks = useMemo(
    () => TRACKS.filter(n => enabled[n]).slice(0, MAX_ACTIVE_RINGS),
    [enabled]
  );

  useEffect(() => {
    const ctx = audioCtxRef.current, mg = masterGainRef.current;
    if (ctx && mg) mg.gain.setTargetAtTime(clamp(master, 0, 1), ctx.currentTime, 0.01);
  }, [master]);

  const ensureAudio = () => {
    if (audioCtxRef.current) return;
    const ctx = new AudioContext();
    const mg = ctx.createGain();
    mg.gain.value = clamp(stateRef.current.master, 0, 1);
    mg.connect(ctx.destination);
    audioCtxRef.current = ctx;
    masterGainRef.current = mg;
  };

  const scheduleTick = () => {
    const ctx = audioCtxRef.current, dest = masterGainRef.current, startTime = startTimeRef.current;
    if (!ctx || !dest || startTime == null) return;

    const { enabled, bpm } = stateRef.current;
    const cycleDuration = getCycleDuration(bpm);
    const windowEnd = ctx.currentTime + 0.15;

    for (const N of TRACKS) {
      if (!enabled[N]) continue;
      const [vol, freq] = [clamp(0.65 / Math.sqrt(N), 0.08, 0.6), 150 + N * 70];
      let t = nextHitTimeAfter(ctx.currentTime, startTime, cycleDuration, N);

      for (let i = 0; t < windowEnd && i < 64; i++) {
        createClick(ctx, dest, t, freq, vol);
        lastHits.current[N] = { t, k: hitIndexFromTime(startTime, cycleDuration, N, t) };
        t = nextHitTimeAfter(t + 0.0005, startTime, cycleDuration, N);
      }
    }
  };

  const start = () => {
    ensureAudio();
    const ctx = audioCtxRef.current!;
    ctx.resume();
    startTimeRef.current = ctx.currentTime + 0.06;
    setIsPlaying(true);
    if (timerRef.current != null) clearInterval(timerRef.current);
    timerRef.current = window.setInterval(scheduleTick, 25);
  };

  const stop = () => {
    setIsPlaying(false);
    startTimeRef.current = null;
    if (timerRef.current != null) { clearInterval(timerRef.current); timerRef.current = null; }
  };

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

  const activeCount = TRACKS.filter(n => enabled[n]).length;

  const toggleTrack = (n: TrackId) => setEnabled(prev => {
    if (prev[n]) return { ...prev, [n]: false };
    if (TRACKS.filter(t => prev[t]).length >= MAX_ACTIVE_RINGS) return prev;
    return { ...prev, [n]: true };
  });

  return (
    <div className="app">
      <h1>Polyrhythms</h1>
      <div className="bar">
        <div className="controlRow">
          <button className="btn transport" onClick={() => isPlaying ? stop() : start()} disabled={activeCount === 0}>
             {isPlaying ? (
               <svg viewBox="0 0 24 24" fill="currentColor" width="28" height="28"><path d="M6 4h4v16H6zm8 0h4v16h-4z"/></svg>
             ) : (
               <svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M8 5v14l11-7z"/></svg>
             )}
          </button>

          <div className="slider">
            <div className="sliderTop"><span className="k">BPM</span><span className="v">{Math.round(bpm)}</span></div>
            <input type="range" min={30} max={240} value={bpm} onChange={e => setBpm(+e.target.value)} />
          </div>

          <div className="slider">
            <div className="sliderTop"><span className="k">Vol</span><span className="v">{Math.round(master * 100)}%</span></div>
            <input type="range" min={0} max={1} step={0.01} value={master} onChange={e => setMaster(+e.target.value)} />
          </div>
        </div>

        <div className="trackRow">
          {TRACKS.map(n => {
            const isOn = enabled[n];
            const idx = activeTracks.indexOf(n);
            return (
              <button
                key={n}
                className={`trackBtn ${isOn ? "on" : ""}`}
                onClick={() => toggleTrack(n)}
                disabled={!isOn && activeCount >= MAX_ACTIVE_RINGS}
                style={idx >= 0 ? { "--btnc": `var(--ring${idx + 1})` } as React.CSSProperties : undefined}
              >
                {n}
              </button>
            );
          })}
        </div>
      </div>

      <PolyrhythmRings
        activeTracks={activeTracks}
        playing={isPlaying}
        bpm={bpm}
        lastHits={lastHits}
        getAudioTime={() => audioCtxRef.current?.currentTime ?? null}
        getStartTime={() => startTimeRef.current}
      />
    </div>
  );
}

createRoot(document.getElementById("root")!).render(<App />);
```
**styles.css**

```css
:root {
  --bg: #ffffff;
  --card: #ffffff;
  --text: #121625;
  --muted: rgba(18, 22, 37, 0.6);
  --border: rgba(18, 22, 37, 0.14);
  --accent: #2f6bff;

  /* ring palette (canvas reads these) */
  --ring1: #2f6bff;
  --ring2: #ff3d6e;
  --ring3: #00bfa6;
  --ring4: #ffb703;
  --ring5: #9b5de5;

  --shadow: 0 18px 60px rgba(18, 22, 37, 0.12);

  --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
  --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}

html[data-theme="dark"] {
  --bg: #0a0d14;
  --card: #0e1421;
  --text: #e9eeff;
  --muted: rgba(233, 238, 255, 0.62);
  --border: rgba(233, 238, 255, 0.14);
  --accent: #7aa2ff;

  /* ring palette (canvas reads these) */
  --ring1: #7aa2ff;
  --ring2: #ff5a86;
  --ring3: #33d6c0;
  --ring4: #ffd166;
  --ring5: #c79bff;

  --shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
}

html,
body {
  height: 100%;
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
}

h1 {
  margin:auto auto;
}

* {
  box-sizing: border-box;
}

.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 18px 16px 22px;
  display: grid;
  gap: 14px;
}

.bar {
  display: grid;
  gap: 10px;
}

.controlRow {
  display: grid;
  grid-template-columns: 44px minmax(0, 1fr) minmax(0, 1fr);
  align-items: center;
  gap: 12px;
  width: 100%;
}

.btn {
  appearance: none;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  padding: 10px 12px;
  border-radius: 12px;
  font-weight: 650;
  cursor: pointer;
  transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
}

.btn:hover {
  border-color: rgba(47, 107, 255, 0.35);
  background: rgba(47, 107, 255, 0.06);
}

.btn:active {
  transform: translateY(1px);
}

.btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.btn.transport {
  width: 44px;
  height: 44px;
  padding: 0;
  border-radius: 12px;
  display: grid;
  place-items: center;
  border-color: rgba(47, 107, 255, 0.28);
}

.btn.transport svg {
  display: block;
}

.btn.transport:hover {
  border-color: rgba(47, 107, 255, 0.5);
}

.trackRow {
  display: flex;
  gap: 8px;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
}

.trackBtn {
  appearance: none;
  width: 36px;
  height: 36px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  font-weight: 750;
  cursor: pointer;
  transition: transform 120ms ease, border-color 120ms ease, background 120ms ease, color 120ms ease;
  --btnc: var(--accent);
}

.trackBtn:hover {
  border-color: rgba(47, 107, 255, 0.42);
  background: rgba(47, 107, 255, 0.06);
  border-color: color-mix(in srgb, var(--btnc) 55%, var(--border));
  background: color-mix(in srgb, var(--btnc) 10%, transparent);
}

.trackBtn:active {
  transform: translateY(1px);
}

.trackBtn.on {
  border-color: color-mix(in srgb, var(--btnc) 45%, var(--border));
  background: linear-gradient(
    135deg,
    color-mix(in srgb, var(--btnc) 88%, white),
    var(--btnc)
  );
  color: #fff;
}

.trackBtn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.slider {
  display: grid;
  gap: 4px;
  min-width: 0;
}

.sliderTop {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
}

.sliderTop .k {
  font-size: 12px;
  color: var(--muted);
  letter-spacing: 0.02em;
}

.sliderTop .v {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text);
}

input[type="range"] {
  width: 100%;
  min-width: 0;
}

.ringsWrap {
  border-radius: 18px;
  overflow: hidden;
  box-shadow: var(--shadow);
  border: 1px solid var(--border);
  width: 100%;
  /* Let the viz grow with available width (fixes the "stops at a square" feel),
     while still respecting sensible min/max heights. */
  aspect-ratio: 16 / 10;
  height: auto;
  min-height: 340px;
  max-height: 780px;
}

.ringsWrap canvas {
  display: block;
  width: 100%;
  height: 100%;
}

@media (max-width: 420px) {
  .controlRow {
    gap: 8px;
    grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr);
  }

  .btn.transport {
    width: 40px;
    height: 40px;
    border-radius: 11px;
  }

  .sliderTop .k,
  .sliderTop .v {
    font-size: 11px;
  }
}

```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "dependencies": {
    "react": "^19.2.4",
    "react-dom": "^19.2.4"
  }
}
```
**notes.md**

```md
Play and visualize polyrhythms.
```