A Markov chain drum machine: stochastic beats with real-time state transition visualization.
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 />)