Markov Drums

A Markov chain drum machine: stochastic beats with real-time state transition visualization.

code.tsx

import React, { useState, useEffect, useRef, useCallback } from "react"
import { createRoot } from "react-dom/client"

// ============ Types & Constants ============
type DrumType = "kick" | "snare" | "hat" | "open" | "rim"
type MarkovState = DrumType | "rest"

const DRUMS: DrumType[] = ["kick", "snare", "hat", "open", "rim"]
const STATES: MarkovState[] = [...DRUMS, "rest"]
const N = STATES.length

const COLORS: Record<MarkovState, string> = {
  kick: "#e94560", snare: "#50fa7b", hat: "#f1fa8c",
  open: "#bd93f9", rim: "#ff79c6", rest: "#555555",
}
const LABELS: Record<MarkovState, string> = {
  kick: "KICK", snare: "SNR", hat: "HAT",
  open: "OPN", rim: "RIM", rest: "·",
}

// ============ Presets ============
interface Preset { name: string; bpm: number; matrix: number[][] }

const PRESETS: Record<string, Preset> = {
  ae: {
    name: "IDM",
    bpm: 140,
    matrix: [
      [0.02, 0.08, 0.40, 0.05, 0.10, 0.35],
      [0.10, 0.02, 0.38, 0.10, 0.10, 0.30],
      [0.15, 0.12, 0.28, 0.08, 0.12, 0.25],
      [0.10, 0.15, 0.25, 0.02, 0.13, 0.35],
      [0.15, 0.10, 0.35, 0.08, 0.02, 0.30],
      [0.25, 0.08, 0.35, 0.07, 0.10, 0.15],
    ],
  },
  four: {
    name: "House",
    bpm: 125,
    matrix: [
      [0.05, 0.05, 0.50, 0.05, 0.05, 0.30],
      [0.30, 0.02, 0.35, 0.05, 0.08, 0.20],
      [0.20, 0.10, 0.30, 0.05, 0.05, 0.30],
      [0.20, 0.10, 0.40, 0.02, 0.08, 0.20],
      [0.25, 0.05, 0.40, 0.05, 0.02, 0.23],
      [0.35, 0.05, 0.35, 0.05, 0.05, 0.15],
    ],
  },
  brk: {
    name: "Break",
    bpm: 160,
    matrix: [
      [0.05, 0.20, 0.30, 0.10, 0.15, 0.20],
      [0.10, 0.05, 0.25, 0.15, 0.15, 0.30],
      [0.15, 0.15, 0.20, 0.10, 0.15, 0.25],
      [0.10, 0.15, 0.30, 0.02, 0.13, 0.30],
      [0.15, 0.15, 0.25, 0.10, 0.05, 0.30],
      [0.25, 0.15, 0.25, 0.10, 0.10, 0.15],
    ],
  },
  min: {
    name: "Sparse",
    bpm: 95,
    matrix: [
      [0.02, 0.05, 0.15, 0.03, 0.05, 0.70],
      [0.05, 0.02, 0.10, 0.05, 0.08, 0.70],
      [0.08, 0.05, 0.10, 0.05, 0.07, 0.65],
      [0.05, 0.05, 0.10, 0.02, 0.08, 0.70],
      [0.08, 0.05, 0.12, 0.05, 0.02, 0.68],
      [0.15, 0.05, 0.15, 0.05, 0.05, 0.55],
    ],
  },
}

// ============ Markov ============
function applyEntropy(matrix: number[][], chaos: number): number[][] {
  const u = 1 / N
  return matrix.map(row => {
    const m = row.map(p => p * (1 - chaos) + u * chaos)
    const s = m.reduce((a, b) => a + b, 0)
    return m.map(p => p / s)
  })
}

function step(matrix: number[][], current: number): number {
  let r = Math.random()
  const row = matrix[current]
  for (let i = 0; i < row.length; i++) {
    r -= row[i]
    if (r <= 0) return i
  }
  return row.length - 1
}

// ============ Audio ============
class DrumSynth {
  ctx: AudioContext | null = null
  private master: GainNode | null = null
  private noiseBuf: AudioBuffer | null = null

  init() {
    if (this.ctx) return
    this.ctx = new AudioContext()
    const comp = this.ctx.createDynamicsCompressor()
    comp.threshold.value = -15
    comp.ratio.value = 4
    comp.connect(this.ctx.destination)
    this.master = this.ctx.createGain()
    this.master.gain.value = 0.7
    this.master.connect(comp)
    const len = this.ctx.sampleRate * 2
    this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate)
    const d = this.noiseBuf.getChannelData(0)
    for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1
  }

  private noise(t: number, dur: number) {
    const s = this.ctx!.createBufferSource()
    s.buffer = this.noiseBuf!
    s.start(t, Math.random() * 1.5)
    s.stop(t + dur)
    return s
  }

  play(drum: DrumType, vel = 0.8) {
    if (!this.ctx || !this.master) return
    const t = this.ctx.currentTime
    const out = this.master

    switch (drum) {
      case "kick": {
        const o = this.ctx.createOscillator()
        o.frequency.setValueAtTime(150, t)
        o.frequency.exponentialRampToValueAtTime(30, t + 0.1)
        const g = this.ctx.createGain()
        g.gain.setValueAtTime(vel, t)
        g.gain.exponentialRampToValueAtTime(0.001, t + 0.35)
        o.connect(g).connect(out)
        o.start(t); o.stop(t + 0.4)
        const n = this.noise(t, 0.02)
        const ng = this.ctx.createGain()
        ng.gain.setValueAtTime(vel * 0.3, t)
        ng.gain.exponentialRampToValueAtTime(0.001, t + 0.015)
        const lp = this.ctx.createBiquadFilter()
        lp.type = "lowpass"; lp.frequency.value = 500
        n.connect(lp).connect(ng).connect(out)
        break
      }
      case "snare": {
        const n = this.noise(t, 0.15)
        const bp = this.ctx.createBiquadFilter()
        bp.type = "bandpass"; bp.frequency.value = 3000; bp.Q.value = 1
        const ng = this.ctx.createGain()
        ng.gain.setValueAtTime(vel * 0.45, t)
        ng.gain.exponentialRampToValueAtTime(0.001, t + 0.12)
        n.connect(bp).connect(ng).connect(out)
        const o = this.ctx.createOscillator()
        o.frequency.value = 180
        const og = this.ctx.createGain()
        og.gain.setValueAtTime(vel * 0.35, t)
        og.gain.exponentialRampToValueAtTime(0.001, t + 0.06)
        o.connect(og).connect(out)
        o.start(t); o.stop(t + 0.15)
        break
      }
      case "hat": {
        const n = this.noise(t, 0.05)
        const hp = this.ctx.createBiquadFilter()
        hp.type = "highpass"; hp.frequency.value = 8000
        const g = this.ctx.createGain()
        g.gain.setValueAtTime(vel * 0.22, t)
        g.gain.exponentialRampToValueAtTime(0.001, t + 0.035)
        n.connect(hp).connect(g).connect(out)
        break
      }
      case "open": {
        const n = this.noise(t, 0.25)
        const hp = this.ctx.createBiquadFilter()
        hp.type = "highpass"; hp.frequency.value = 6000
        const g = this.ctx.createGain()
        g.gain.setValueAtTime(vel * 0.22, t)
        g.gain.exponentialRampToValueAtTime(0.001, t + 0.18)
        n.connect(hp).connect(g).connect(out)
        break
      }
      case "rim": {
        const n = this.noise(t, 0.03)
        const bp = this.ctx.createBiquadFilter()
        bp.type = "bandpass"; bp.frequency.value = 900; bp.Q.value = 2.5
        const g = this.ctx.createGain()
        g.gain.setValueAtTime(vel * 0.35, t)
        g.gain.exponentialRampToValueAtTime(0.001, t + 0.025)
        n.connect(bp).connect(g).connect(out)
        break
      }
    }
  }

  setVolume(v: number) { if (this.master) this.master.gain.value = v }
  async resume() { if (this.ctx?.state === "suspended") await this.ctx.resume() }
}

// ============ Canvas Rendering ============
interface Particle { from: number; to: number; t: number; color: string }

interface RS {
  cur: number
  step: number
  ring: (MarkovState | null)[]
  particles: Particle[]
  heat: number[][]
  matrix: number[][]
  pulse: number[]
}

function draw(canvas: HTMLCanvasElement, s: RS, loopLen: number) {
  const c = canvas.getContext("2d")!
  const dpr = window.devicePixelRatio || 1
  const W = canvas.width / dpr
  const H = canvas.height / dpr

  c.setTransform(1, 0, 0, 1, 0, 0)
  c.clearRect(0, 0, canvas.width, canvas.height)
  c.setTransform(dpr, 0, 0, dpr, 0, 0)

  // Subtle background gradient
  const cx = W / 2, cy = H / 2
  const dim = Math.min(W, H)
  const bg = c.createRadialGradient(cx, cy, 0, cx, cy, dim * 0.5)
  bg.addColorStop(0, "#111")
  bg.addColorStop(1, "#0a0a0a")
  c.fillStyle = bg
  c.fillRect(0, 0, W, H)

  const ringR = dim * 0.42
  const ringW = dim * 0.045
  const nodeR = dim * 0.26
  const nodeSz = dim * 0.05

  // ---- Outer ring (step sequencer) ----
  const segA = (Math.PI * 2) / loopLen
  const gap = 0.03
  const curPos = s.step > 0 ? (s.step - 1) % loopLen : -1

  for (let i = 0; i < loopLen; i++) {
    const a0 = i * segA - Math.PI / 2 + gap / 2
    const a1 = a0 + segA - gap
    const st = s.ring[i]
    const col = st ? COLORS[st] : "#1c1c1c"
    const isCur = i === curPos

    c.beginPath()
    c.arc(cx, cy, ringR, a0, a1)
    c.lineWidth = ringW
    c.lineCap = "butt"
    c.strokeStyle = col
    c.globalAlpha = isCur ? 1 : st ? 0.55 : 0.12
    c.stroke()

    if (isCur && st) {
      c.beginPath()
      c.arc(cx, cy, ringR, a0, a1)
      c.lineWidth = ringW + 4
      c.strokeStyle = col
      c.globalAlpha = 0.25
      c.stroke()
    }
  }
  c.globalAlpha = 1

  // ---- Node positions ----
  const pos: [number, number][] = STATES.map((_, i) => {
    const a = (i / N) * Math.PI * 2 - Math.PI / 2
    return [cx + Math.cos(a) * nodeR, cy + Math.sin(a) * nodeR]
  })

  // ---- Edges ----
  for (let i = 0; i < N; i++) {
    for (let j = i + 1; j < N; j++) {
      const prob = Math.max(s.matrix[i][j], s.matrix[j][i])
      if (prob < 0.04) continue
      const ht = Math.max(s.heat[i][j], s.heat[j][i])

      const [x1, y1] = pos[i]
      const [x2, y2] = pos[j]
      const mx = (x1 + x2) / 2, my = (y1 + y2) / 2
      const dx = mx - cx, dy = my - cy
      const len = Math.sqrt(dx * dx + dy * dy) || 1
      const bow = 25
      const cpx = mx + (dx / len) * bow
      const cpy = my + (dy / len) * bow

      c.beginPath()
      c.moveTo(x1, y1)
      c.quadraticCurveTo(cpx, cpy, x2, y2)
      c.strokeStyle = `rgba(255,255,255,${0.04 + prob * 0.15 + ht * 0.5})`
      c.lineWidth = 0.5 + prob * 2.5 + ht * 3
      c.lineCap = "round"
      c.stroke()
    }
  }

  // ---- Particles ----
  for (const p of s.particles) {
    if (p.from === p.to) continue
    const [x1, y1] = pos[p.from]
    const [x2, y2] = pos[p.to]
    const mx = (x1 + x2) / 2, my = (y1 + y2) / 2
    const dx = mx - cx, dy = my - cy
    const len = Math.sqrt(dx * dx + dy * dy) || 1
    const cpx = mx + (dx / len) * 25
    const cpy = my + (dy / len) * 25

    const u = 1 - p.t
    const px = u * u * x1 + 2 * u * p.t * cpx + p.t * p.t * x2
    const py = u * u * y1 + 2 * u * p.t * cpy + p.t * p.t * y2

    const sz = 8 + (1 - p.t) * 6
    const grad = c.createRadialGradient(px, py, 0, px, py, sz)
    grad.addColorStop(0, p.color)
    grad.addColorStop(0.4, p.color + "88")
    grad.addColorStop(1, "transparent")
    c.fillStyle = grad
    c.beginPath()
    c.arc(px, py, sz, 0, Math.PI * 2)
    c.fill()
  }

  // ---- Nodes ----
  for (let i = 0; i < N; i++) {
    const [x, y] = pos[i]
    const st = STATES[i]
    const isCur = i === s.cur
    const pl = s.pulse[i]

    if (isCur || pl > 0.05) {
      const glowR = nodeSz * (2.2 + pl * 0.8)
      const grad = c.createRadialGradient(x, y, nodeSz * 0.3, x, y, glowR)
      grad.addColorStop(0, COLORS[st] + "70")
      grad.addColorStop(1, "transparent")
      c.fillStyle = grad
      c.globalAlpha = isCur ? 0.4 + pl * 0.4 : pl * 0.5
      c.beginPath()
      c.arc(x, y, glowR, 0, Math.PI * 2)
      c.fill()
      c.globalAlpha = 1
    }

    const r = nodeSz + pl * 4
    c.beginPath()
    c.arc(x, y, r, 0, Math.PI * 2)
    c.fillStyle = isCur ? COLORS[st] : "#111"
    c.fill()
    c.strokeStyle = COLORS[st]
    c.lineWidth = isCur ? 2.5 : 1.5
    c.stroke()

    c.fillStyle = isCur ? (st === "hat" ? "#111" : "#000") : COLORS[st]
    c.font = `bold ${Math.max(9, nodeSz * 0.48)}px 'SF Mono',Consolas,monospace`
    c.textAlign = "center"
    c.textBaseline = "middle"
    c.fillText(LABELS[st], x, y)
  }
}

// ============ App ============
function App() {
  const synth = useRef(new DrumSynth())
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const [playing, setPlaying] = useState(false)
  const [bpm, setBpm] = useState(140)
  const [chaos, setChaos] = useState(0.25)
  const [loopLen, setLoopLen] = useState(16)
  const [volume, setVolume] = useState(0.7)
  const [preset, setPreset] = useState("ae")
  const [baseMatrix, setBaseMatrix] = useState(PRESETS.ae.matrix)

  const rs = useRef<RS>({
    cur: N - 1,
    step: 0,
    ring: Array(16).fill(null),
    particles: [],
    heat: Array.from({ length: N }, () => Array(N).fill(0)),
    matrix: applyEntropy(PRESETS.ae.matrix, 0.25),
    pulse: Array(N).fill(0),
  })

  const playingRef = useRef(false)
  const rafRef = useRef(0)

  useEffect(() => { rs.current.matrix = applyEntropy(baseMatrix, chaos) }, [chaos, baseMatrix])
  useEffect(() => { synth.current.setVolume(volume) }, [volume])
  useEffect(() => {
    rs.current.ring = Array(loopLen).fill(null)
    rs.current.step = 0
  }, [loopLen])

  // Animation loop
  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return

    const resize = () => {
      const dpr = window.devicePixelRatio || 1
      const rect = canvas.getBoundingClientRect()
      if (rect.width === 0 || rect.height === 0) return
      canvas.width = rect.width * dpr
      canvas.height = rect.height * dpr
    }
    const ro = new ResizeObserver(resize)
    ro.observe(canvas)

    const loop = () => {
      const s = rs.current
      s.particles = s.particles.map(p => ({ ...p, t: p.t + 0.055 })).filter(p => p.t <= 1)
      for (let i = 0; i < N; i++) {
        for (let j = 0; j < N; j++) s.heat[i][j] *= 0.96
        s.pulse[i] *= 0.88
      }
      try { draw(canvas, s, loopLen) } catch {}
      rafRef.current = requestAnimationFrame(loop)
    }
    rafRef.current = requestAnimationFrame(loop)
    return () => { cancelAnimationFrame(rafRef.current); ro.disconnect() }
  }, [loopLen])

  // Sequencer
  useEffect(() => {
    if (!playing) return
    const s = rs.current
    const tick = () => {
      const prev = s.cur
      const next = step(s.matrix, prev)
      s.cur = next
      const state = STATES[next]
      if (state !== "rest") synth.current.play(state as DrumType, 0.5 + Math.random() * 0.35)
      s.ring[s.step % loopLen] = state
      s.particles.push({ from: prev, to: next, t: 0, color: COLORS[state] })
      s.heat[prev][next] = Math.min(s.heat[prev][next] + 0.6, 1)
      s.pulse[next] = 1
      s.step++
    }
    tick()
    const id = window.setInterval(tick, 60000 / bpm / 4)
    return () => clearInterval(id)
  }, [playing, bpm, loopLen])

  const toggle = useCallback(async () => {
    if (playingRef.current) {
      playingRef.current = false
      setPlaying(false)
    } else {
      try { synth.current.init(); await synth.current.resume() } catch {}
      playingRef.current = true
      setPlaying(true)
    }
  }, [])

  const applyPreset = useCallback((key: string) => {
    const p = PRESETS[key]
    setPreset(key)
    setBaseMatrix(p.matrix)
    setBpm(p.bpm)
    const s = rs.current
    s.ring = Array(loopLen).fill(null)
    s.step = 0
    s.cur = N - 1
    s.heat = Array.from({ length: N }, () => Array(N).fill(0))
  }, [loopLen])

  return (
    <div className="app">
      <div className="controls">
        <button className={`play-btn ${playing ? "on" : ""}`} onClick={toggle}>
          {playing ? (
            <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="1" /></svg>
          ) : (
            <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><polygon points="2,0 14,7 2,14" /></svg>
          )}
        </button>
        <div className="presets">
          {Object.entries(PRESETS).map(([k, p]) => (
            <button key={k} className={`preset ${preset === k ? "on" : ""}`} onClick={() => applyPreset(k)}>
              {p.name}
            </button>
          ))}
        </div>
        <div className="sliders">
          <label><span>BPM <b>{bpm}</b></span>
            <input type="range" min={60} max={200} value={bpm} onChange={e => setBpm(+e.target.value)} />
          </label>
          <label><span>CHAOS <b>{(chaos * 100).toFixed(0)}%</b></span>
            <input type="range" min={0} max={100} value={chaos * 100} onChange={e => setChaos(+e.target.value / 100)} />
          </label>
          <label><span>VOL <b>{(volume * 100).toFixed(0)}%</b></span>
            <input type="range" min={0} max={100} value={volume * 100} onChange={e => setVolume(+e.target.value / 100)} />
          </label>
        </div>
        <div className="loop-btns">
          {[8, 16, 32].map(n => (
            <button key={n} className={loopLen === n ? "on" : ""} onClick={() => setLoopLen(n)}>{n}</button>
          ))}
        </div>
      </div>
      <canvas ref={canvasRef} className="viz" />
      <div className="legend">
        <span className="title">Markov Drums</span>
        {DRUMS.map(d => (
          <span key={d} className="leg">
            <span className="dot" style={{ background: COLORS[d] }} />
            {LABELS[d]}
          </span>
        ))}
      </div>
    </div>
  )
}

createRoot(document.getElementById("root")!).render(<App />)

Markdown source · More bulbs by samples · Typebulb home