src/components/Murmurations.tsx 9.0 K raw
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
}