| 1 | import { useEffect, useRef, useState } from 'react'; |
| 2 | import { |
| 3 | boidParams, |
| 4 | createSim, |
| 5 | DEFAULT_BOID_PARAMS, |
| 6 | drawBoid, |
| 7 | flockCoherence, |
| 8 | type BoidParams, |
| 9 | type Sim, |
| 10 | } from '../sim/boids'; |
| 11 | import { cellAt, drawGrid } from '../sim/grid'; |
| 12 | import { cellToMidi, COLS, ROWS, type ScaleName } from '../audio/scales'; |
| 13 | import { |
| 14 | createEngine, |
| 15 | DEFAULT_SYNTH_PARAMS, |
| 16 | type AudioEngine, |
| 17 | type MidiOutputInfo, |
| 18 | type SynthParams, |
| 19 | } from '../audio/engine'; |
| 20 | import { |
| 21 | bucketOf, |
| 22 | DEFAULT_MUSIC_PARAMS, |
| 23 | type MusicParams, |
| 24 | } from '../music'; |
| 25 | import Controls from './Controls'; |
| 26 | |
| 27 | const DEFAULT_COUNT = 240; |
| 28 | |
| 29 | export default function Murmurations() { |
| 30 | const canvasRef = useRef<HTMLCanvasElement | null>(null); |
| 31 | const simRef = useRef<Sim | null>(null); |
| 32 | const engineRef = useRef<AudioEngine | null>(null); |
| 33 | const flashesRef = useRef<Map<number, number>>(new Map()); |
| 34 | const cellBucketsRef = useRef<Int8Array>(new Int8Array(COLS * ROWS)); |
| 35 | const accentRateRef = useRef(0); |
| 36 | const rafRef = useRef<number | null>(null); |
| 37 | const sizeRef = useRef({ W: 0, H: 0 }); |
| 38 | |
| 39 | const [started, setStarted] = useState(false); |
| 40 | const [scale, setScale] = useState<ScaleName>('pentatonicMaj'); |
| 41 | const [rootPc, setRootPc] = useState(0); |
| 42 | const [octaveBase, setOctaveBase] = useState(3); |
| 43 | const [engineKind, setEngineKind] = useState<'midi' | 'synth' | null>(null); |
| 44 | const [outputs, setOutputs] = useState<MidiOutputInfo[]>([]); |
| 45 | const [currentOutputId, setCurrentOutputId] = useState<string | null>(null); |
| 46 | const [volume, setVolume] = useState(0.25); |
| 47 | const [synthParams, setSynthParams] = useState<SynthParams>({ |
| 48 | ...DEFAULT_SYNTH_PARAMS, |
| 49 | }); |
| 50 | const [musicParams, setMusicParams] = useState<MusicParams>({ |
| 51 | ...DEFAULT_MUSIC_PARAMS, |
| 52 | }); |
| 53 | const [boidState, setBoidState] = useState<BoidParams>({ |
| 54 | ...DEFAULT_BOID_PARAMS, |
| 55 | }); |
| 56 | const [boidCount, setBoidCount] = useState(DEFAULT_COUNT); |
| 57 | |
| 58 | const settingsRef = useRef({ scale, rootPc, octaveBase }); |
| 59 | useEffect(() => { |
| 60 | settingsRef.current = { scale, rootPc, octaveBase }; |
| 61 | }, [scale, rootPc, octaveBase]); |
| 62 | |
| 63 | const musicRef = useRef(musicParams); |
| 64 | useEffect(() => { |
| 65 | musicRef.current = musicParams; |
| 66 | }, [musicParams]); |
| 67 | |
| 68 | useEffect(() => { |
| 69 | const canvas = canvasRef.current!; |
| 70 | const ctx = canvas.getContext('2d')!; |
| 71 | const DPR = Math.min(window.devicePixelRatio || 1, 2); |
| 72 | |
| 73 | function viewport() { |
| 74 | const vv = window.visualViewport; |
| 75 | return { |
| 76 | w: vv ? vv.width : window.innerWidth, |
| 77 | h: vv ? vv.height : window.innerHeight, |
| 78 | }; |
| 79 | } |
| 80 | |
| 81 | function resize() { |
| 82 | const { w, h } = viewport(); |
| 83 | sizeRef.current = { W: w, H: h }; |
| 84 | canvas.width = w * DPR; |
| 85 | canvas.height = h * DPR; |
| 86 | canvas.style.width = w + 'px'; |
| 87 | canvas.style.height = h + 'px'; |
| 88 | ctx.setTransform(DPR, 0, 0, DPR, 0, 0); |
| 89 | ctx.lineCap = 'round'; |
| 90 | if (simRef.current) simRef.current.resize(w, h); |
| 91 | } |
| 92 | |
| 93 | resize(); |
| 94 | const { W, H } = sizeRef.current; |
| 95 | simRef.current = createSim(W, H); |
| 96 | |
| 97 | window.addEventListener('resize', resize); |
| 98 | window.addEventListener('orientationchange', resize); |
| 99 | const vv = window.visualViewport; |
| 100 | if (vv) vv.addEventListener('resize', resize); |
| 101 | |
| 102 | function loop() { |
| 103 | const { W, H } = sizeRef.current; |
| 104 | const sim = simRef.current!; |
| 105 | const engine = engineRef.current; |
| 106 | const settings = settingsRef.current; |
| 107 | const now = performance.now(); |
| 108 | |
| 109 | ctx.fillStyle = 'rgba(18, 17, 19, 0.55)'; |
| 110 | ctx.fillRect(0, 0, W, H); |
| 111 | |
| 112 | sim.step(); |
| 113 | |
| 114 | ctx.strokeStyle = 'rgba(245, 243, 238, 0.78)'; |
| 115 | ctx.lineWidth = 1.4; |
| 116 | |
| 117 | const cellCounts = new Int32Array(COLS * ROWS); |
| 118 | const cellBoids: number[][] = []; |
| 119 | for (let i = 0; i < COLS * ROWS; i++) cellBoids.push([]); |
| 120 | |
| 121 | for (let i = 0; i < sim.state.boids.length; i++) { |
| 122 | const b = sim.state.boids[i]; |
| 123 | drawBoid(ctx, b); |
| 124 | const idx = cellAt(b.x, b.y, W, H); |
| 125 | cellCounts[idx]++; |
| 126 | cellBoids[idx].push(i); |
| 127 | b.lastCell = idx; |
| 128 | } |
| 129 | |
| 130 | if (engine) { |
| 131 | const mp = musicRef.current; |
| 132 | const buckets = cellBucketsRef.current; |
| 133 | for (let idx = 0; idx < COLS * ROWS; idx++) { |
| 134 | const newBucket = bucketOf(cellCounts[idx], mp); |
| 135 | const prev = buckets[idx]; |
| 136 | if (newBucket > prev) { |
| 137 | const row = Math.floor(idx / COLS); |
| 138 | const col = idx % COLS; |
| 139 | const baseNote = cellToMidi( |
| 140 | col, |
| 141 | row, |
| 142 | settings.rootPc, |
| 143 | settings.octaveBase, |
| 144 | settings.scale, |
| 145 | ); |
| 146 | const vel = Math.min(120, 55 + newBucket * 18); |
| 147 | const dur = mp.padDurMs + newBucket * 200; |
| 148 | engine.noteOn(baseNote, vel, dur); |
| 149 | if (newBucket >= 3) { |
| 150 | engine.noteOn(baseNote + 7, vel - 10, dur); |
| 151 | } |
| 152 | if (newBucket >= 4) { |
| 153 | engine.noteOn(baseNote + 12, vel - 15, dur); |
| 154 | } |
| 155 | flashesRef.current.set(idx, now + mp.flashMs + newBucket * 60); |
| 156 | } |
| 157 | buckets[idx] = newBucket; |
| 158 | } |
| 159 | |
| 160 | accentRateRef.current = Math.max(0, accentRateRef.current - 1); |
| 161 | if (accentRateRef.current < mp.accentRateCap) { |
| 162 | for (let i = 0; i < sim.state.boids.length; i++) { |
| 163 | const b = sim.state.boids[i]; |
| 164 | if ( |
| 165 | b.lastSepMag > mp.sepAccentThreshold && |
| 166 | now - b.lastAccentAt > mp.accentCooldownMs |
| 167 | ) { |
| 168 | b.lastAccentAt = now; |
| 169 | accentRateRef.current += 1; |
| 170 | const row = Math.floor(b.lastCell / COLS); |
| 171 | const col = b.lastCell % COLS; |
| 172 | const note = |
| 173 | cellToMidi( |
| 174 | col, |
| 175 | row, |
| 176 | settings.rootPc, |
| 177 | settings.octaveBase, |
| 178 | settings.scale, |
| 179 | ) + 12; |
| 180 | const vel = Math.min( |
| 181 | 95, |
| 182 | 45 + Math.round(b.lastSepMag * 400), |
| 183 | ); |
| 184 | engine.noteOn(note, vel, mp.accentDurMs, { |
| 185 | attack: 0.005, |
| 186 | release: 0.18, |
| 187 | bypassRateLimit: true, |
| 188 | }); |
| 189 | flashesRef.current.set(b.lastCell, now + 120); |
| 190 | if (accentRateRef.current >= mp.accentRateCap) break; |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | const coh = flockCoherence(sim.state.boids); |
| 196 | engine.setModulation?.(coh); |
| 197 | } |
| 198 | |
| 199 | drawGrid(ctx, W, H, flashesRef.current, now, musicRef.current.flashMs); |
| 200 | |
| 201 | rafRef.current = requestAnimationFrame(loop); |
| 202 | } |
| 203 | rafRef.current = requestAnimationFrame(loop); |
| 204 | |
| 205 | return () => { |
| 206 | if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); |
| 207 | window.removeEventListener('resize', resize); |
| 208 | window.removeEventListener('orientationchange', resize); |
| 209 | if (vv) vv.removeEventListener('resize', resize); |
| 210 | engineRef.current?.dispose(); |
| 211 | engineRef.current = null; |
| 212 | }; |
| 213 | }, []); |
| 214 | |
| 215 | async function start() { |
| 216 | if (engineRef.current) { |
| 217 | setStarted(true); |
| 218 | return; |
| 219 | } |
| 220 | const engine = await createEngine(true); |
| 221 | engineRef.current = engine; |
| 222 | setEngineKind(engine.kind); |
| 223 | if (engine.listOutputs) { |
| 224 | const outs = engine.listOutputs(); |
| 225 | setOutputs(outs); |
| 226 | setCurrentOutputId(engine.currentOutputId?.() ?? null); |
| 227 | } |
| 228 | if (engine.setVolume) engine.setVolume(volume); |
| 229 | if (engine.setSynthParams) engine.setSynthParams(synthParams); |
| 230 | setStarted(true); |
| 231 | } |
| 232 | |
| 233 | function setSynthParam<K extends keyof SynthParams>(k: K, v: SynthParams[K]) { |
| 234 | setSynthParams((prev) => { |
| 235 | const next = { ...prev, [k]: v }; |
| 236 | engineRef.current?.setSynthParams?.({ [k]: v } as Partial<SynthParams>); |
| 237 | return next; |
| 238 | }); |
| 239 | } |
| 240 | |
| 241 | function setMusicParam<K extends keyof MusicParams>(k: K, v: MusicParams[K]) { |
| 242 | setMusicParams((prev) => ({ ...prev, [k]: v })); |
| 243 | } |
| 244 | |
| 245 | function setBoidParam<K extends keyof BoidParams>(k: K, v: BoidParams[K]) { |
| 246 | boidParams[k] = v; |
| 247 | setBoidState((prev) => ({ ...prev, [k]: v })); |
| 248 | } |
| 249 | |
| 250 | function respawn(count: number) { |
| 251 | setBoidCount(count); |
| 252 | simRef.current?.spawn(count); |
| 253 | cellBucketsRef.current.fill(0); |
| 254 | flashesRef.current.clear(); |
| 255 | } |
| 256 | |
| 257 | function resetScale() { |
| 258 | setScale('pentatonicMaj'); |
| 259 | setRootPc(0); |
| 260 | setOctaveBase(3); |
| 261 | } |
| 262 | |
| 263 | function resetFlock() { |
| 264 | Object.assign(boidParams, DEFAULT_BOID_PARAMS); |
| 265 | setBoidState({ ...DEFAULT_BOID_PARAMS }); |
| 266 | respawn(DEFAULT_COUNT); |
| 267 | } |
| 268 | |
| 269 | function resetTriggers() { |
| 270 | setMusicParams({ ...DEFAULT_MUSIC_PARAMS }); |
| 271 | } |
| 272 | |
| 273 | function resetSynth() { |
| 274 | setSynthParams({ ...DEFAULT_SYNTH_PARAMS }); |
| 275 | engineRef.current?.setSynthParams?.({ ...DEFAULT_SYNTH_PARAMS }); |
| 276 | setVolume(0.25); |
| 277 | engineRef.current?.setVolume?.(0.25); |
| 278 | } |
| 279 | |
| 280 | function stop() { |
| 281 | engineRef.current?.dispose(); |
| 282 | engineRef.current = null; |
| 283 | setEngineKind(null); |
| 284 | setOutputs([]); |
| 285 | setCurrentOutputId(null); |
| 286 | setStarted(false); |
| 287 | } |
| 288 | |
| 289 | function selectOutput(id: string) { |
| 290 | engineRef.current?.selectOutput?.(id); |
| 291 | setCurrentOutputId(id); |
| 292 | } |
| 293 | |
| 294 | function handleVolume(v: number) { |
| 295 | setVolume(v); |
| 296 | engineRef.current?.setVolume?.(v); |
| 297 | } |
| 298 | |
| 299 | return ( |
| 300 | <> |
| 301 | <canvas ref={canvasRef} className="murmurations-canvas" /> |
| 302 | <Controls |
| 303 | started={started} |
| 304 | onStart={start} |
| 305 | onStop={stop} |
| 306 | scale={scale} |
| 307 | setScale={setScale} |
| 308 | rootPc={rootPc} |
| 309 | setRootPc={setRootPc} |
| 310 | octaveBase={octaveBase} |
| 311 | setOctaveBase={setOctaveBase} |
| 312 | engineKind={engineKind} |
| 313 | outputs={outputs} |
| 314 | currentOutputId={currentOutputId} |
| 315 | selectOutput={selectOutput} |
| 316 | volume={volume} |
| 317 | setVolume={handleVolume} |
| 318 | synthParams={synthParams} |
| 319 | setSynthParam={setSynthParam} |
| 320 | musicParams={musicParams} |
| 321 | setMusicParam={setMusicParam} |
| 322 | boidParams={boidState} |
| 323 | setBoidParam={setBoidParam} |
| 324 | boidCount={boidCount} |
| 325 | respawn={respawn} |
| 326 | resetScale={resetScale} |
| 327 | resetFlock={resetFlock} |
| 328 | resetTriggers={resetTriggers} |
| 329 | resetSynth={resetSynth} |
| 330 | /> |
| 331 | </> |
| 332 | ); |
| 333 | } |