A Typebulb bulb.
---
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.
```