leach.studio

Plinko

rhythm · euclidean · polyrhythm
↓ source · seed

about this piece

Three percussive layers running on Euclidean rhythms — kick at 5-of-8, hat at 11-of-16, pitched perc at 7-of-13 (against a 4-feel) — beating polyrhythmically against each other. Densities re-roll every 8 beats. Pad and sub underneath.

seed
plinko-preview
by
stuart
year
2026
tags
rhythm · euclidean · polyrhythm
permalink
/a/plinko/plinko-preview

source

import type { ElementaryPatch } from '@/lib/engine/types'
import { euclidean } from '@/lib/generative'

const plinko: ElementaryPatch = {
  id: 'plinko',
  title: 'Plinko',
  author: 'stuart',
  year: 2026,
  tags: ['rhythm', 'euclidean', 'polyrhythm'],
  description:
    'Three percussive layers running on Euclidean rhythms — kick at 5-of-8, hat at 11-of-16, pitched perc at 7-of-13 (against a 4-feel) — beating polyrhythmically against each other. Densities re-roll every 8 beats. Pad and sub underneath.',
  engine: 'elementary',

  audio: ({ el, rng, schedule, render }) => {
    const chords = [
      [220, 261.63, 329.63, 392, 493.88], // Am add9
      [196, 246.94, 293.66, 369.99, 440], // G6
      [174.61, 220, 261.63, 329.63, 392], // Fmaj7
      [164.81, 207.65, 246.94, 311.13, 415.3], // E
    ]

    const bpm = 88
    const beatMs = 60_000 / bpm
    const eighthMs = beatMs / 2
    const sixteenthMs = beatMs / 4
    const interval = (beatMs * 8) / 1000 // chord change every 8 beats

    schedule(interval, (tick) => {
      const chord = rng.pick(chords, tick, 0)
      const kickHits = rng.int(3, 6, tick, 1) // 3-6 in 8 — varies kick density
      const hatHits = rng.int(7, 13, tick, 2) // 7-13 in 16
      const percHits = rng.int(4, 8, tick, 3) // 4-8 in 13 — irregular phrase length

      // ── Kick — Euclidean(kickHits, 8) at eighth-note resolution ──
      const kickStep = el.metro({ name: 'kickStep', interval: eighthMs })
      const kickGate = el.seq(
        {
          key: `kickPat-${tick}`,
          seq: euclidean(kickHits, 8),
          loop: true,
        },
        kickStep,
        el.const({ key: 'kickReset', value: 0 }),
      )
      const kickEnv = el.adsr(0.001, 0.18, 0, 0, kickGate)
      const kickPitch = el.add(
        el.const({ key: 'kFreq', value: 55 }),
        el.mul(kickEnv, 45),
      )
      const kick = el.mul(el.cycle(kickPitch), el.mul(kickEnv, 0.55))

      // ── Hat — Euclidean(hatHits, 16) at sixteenth-note resolution ──
      const hatStep = el.metro({ name: 'hatStep', interval: sixteenthMs })
      const hatGate = el.seq(
        {
          key: `hatPat-${tick}`,
          seq: euclidean(hatHits, 16),
          loop: true,
        },
        hatStep,
        el.const({ key: 'hatReset', value: 0 }),
      )
      const hatEnv = el.adsr(0.001, 0.045, 0, 0, hatGate)
      const hat = el.mul(
        el.highpass(
          el.const({ key: 'hatHpf', value: 6500 }),
          0.9,
          el.noise(),
        ),
        el.mul(hatEnv, 0.07),
      )

      // ── Pitched perc — Euclidean(percHits, 13) at sixteenth-note resolution.
      // 13 against 16 yields shifting alignment ("rotating" feel).
      const percStep = el.metro({ name: 'percStep', interval: sixteenthMs })
      const percGate = el.seq(
        {
          key: `percPat-${tick}`,
          seq: euclidean(percHits, 13),
          loop: true,
        },
        percStep,
        el.const({ key: 'percReset', value: 0 }),
      )
      const percEnv = el.adsr(0.001, 0.1, 0, 0, percGate)
      const percFreq = chord[2] * 2 // pitched in chord
      const percMod = el.mul(
        el.cycle(el.const({ key: 'percMod', value: percFreq * 2 })),
        30,
      )
      const perc = el.mul(
        el.cycle(
          el.add(el.const({ key: 'percCar', value: percFreq }), percMod),
        ),
        el.mul(percEnv, 0.13),
      )

      // ── Pad chord (held under percussion) ──
      const padVoices = chord.map((f, i) =>
        el.mul(el.cycle(el.const({ key: `pad-${i}`, value: f })), 0.07),
      )
      const pad = el.lowpass(
        el.const({ key: 'padCut', value: 1500 }),
        0.4,
        el.add(...padVoices),
      )

      // ── Sub bass ──
      const sub = el.mul(
        el.cycle(el.const({ key: 'sub', value: chord[0] / 2 })),
        0.18,
      )

      const mix = el.add(pad, sub, kick, hat, perc)
      const out = el.mul(el.tanh(el.mul(mix, 0.5)), 0.2)
      render(out, out)
    })
  },

  visual: ({ ctx, rng, t, width, height, mouseX, mouseY, mouseActive, getRms }) => {
    // Three rotating rings — one per percussion layer. Cursor accelerates
    // ring rotation when it's near the rings; nodes near the cursor brighten.
    ctx.fillStyle = 'rgba(12, 12, 12, 0.18)'
    ctx.fillRect(0, 0, width, height)

    const cx = width / 2
    const cy = height / 2
    const baseR = Math.min(width, height) * 0.32
    const mx = mouseActive ? mouseX * width : cx
    const my = mouseActive ? mouseY * height : cy

    // Cursor distance from center (normalized) — used to speed up rotation
    const dCenter = Math.hypot(mx - cx, my - cy) / baseR
    const speedBoost = mouseActive ? 1 + Math.min(dCenter, 1.2) * 1.5 : 1
    // Audio RMS adds to overall brightness — quiet beats fade, loud ones flare
    const audioRms = getRms()
    const audioBoost = Math.min(1, audioRms * 6)

    const rings = [
      { steps: 8, hits: 5, radius: baseR, rate: 0.08, color: '232,230,225' },
      { steps: 16, hits: 11, radius: baseR * 0.7, rate: 0.13, color: '180,180,180' },
      { steps: 13, hits: 7, radius: baseR * 0.45, rate: 0.21, color: '239,68,68' },
    ]

    for (const ring of rings) {
      const hitMask = new Array(ring.steps).fill(false)
      for (let i = 0; i < ring.hits; i++) {
        hitMask[Math.floor((i * ring.steps) / ring.hits)] = true
      }
      const rotation = t * ring.rate * speedBoost
      for (let i = 0; i < ring.steps; i++) {
        const a = (i / ring.steps) * Math.PI * 2 + rotation
        const x = cx + Math.cos(a) * ring.radius
        const y = cy + Math.sin(a) * ring.radius
        // Distance from cursor → brightness boost (closer = brighter)
        const cursorDist = mouseActive
          ? Math.hypot(x - mx, y - my)
          : Number.POSITIVE_INFINITY
        const proximity = mouseActive
          ? Math.max(0, 1 - cursorDist / 120)
          : 0
        if (hitMask[i]) {
          const pulse = 0.5 + 0.5 * Math.sin(t * 4 + i * 0.4)
          const alpha = 0.3 + pulse * 0.5 + proximity * 0.4 + audioBoost * 0.2
          ctx.fillStyle = `rgba(${ring.color}, ${Math.min(1, alpha)})`
          ctx.beginPath()
          ctx.arc(x, y, 3 + pulse * 4 + proximity * 3 + audioBoost * 4, 0, Math.PI * 2)
          ctx.fill()
        } else {
          ctx.fillStyle = `rgba(${ring.color}, ${0.12 + proximity * 0.4})`
          ctx.beginPath()
          ctx.arc(x, y, 1.5 + proximity * 2, 0, Math.PI * 2)
          ctx.fill()
        }
        // Optional cursor "tug" line: nearest node to cursor
        if (mouseActive && proximity > 0.5) {
          ctx.strokeStyle = `rgba(${ring.color}, ${proximity * 0.3})`
          ctx.lineWidth = 1
          ctx.beginPath()
          ctx.moveTo(x, y)
          ctx.lineTo(mx, my)
          ctx.stroke()
        }
      }
    }
  },
}

export default plinko
← leach.studio