chore: enhanced controls and engine 4498e871
Steve · 2026-05-03 21:15 6 file(s) · +811 −225
src/App.css +95 −0
68 68
	flex: 1;
69 69
	max-width: 110px;
70 70
}
71 +
72 +
.controls {
73 +
	max-height: calc(100vh - 2rem);
74 +
	overflow-y: auto;
75 +
}
76 +
77 +
.controls details {
78 +
	border-top: 1px solid rgba(245, 243, 238, 0.15);
79 +
	padding-top: 0.4rem;
80 +
}
81 +
82 +
.controls details:first-of-type {
83 +
	border-top: none;
84 +
	padding-top: 0;
85 +
}
86 +
87 +
.controls summary {
88 +
	cursor: pointer;
89 +
	font-size: 11px;
90 +
	text-transform: uppercase;
91 +
	letter-spacing: 0.5px;
92 +
	opacity: 0.8;
93 +
	padding: 0.15rem 0;
94 +
	user-select: none;
95 +
}
96 +
97 +
.controls details[open] summary {
98 +
	margin-bottom: 0.35rem;
99 +
}
100 +
101 +
.controls details > * + * {
102 +
	margin-top: 0.4rem;
103 +
}
104 +
105 +
.controls label > span:first-child {
106 +
	flex-shrink: 0;
107 +
	min-width: 70px;
108 +
}
109 +
110 +
.controls .val {
111 +
	font-variant-numeric: tabular-nums;
112 +
	font-size: 10px;
113 +
	opacity: 0.6;
114 +
	min-width: 36px;
115 +
	text-align: right;
116 +
}
117 +
118 +
.controls button.reset {
119 +
	font-size: 10px;
120 +
	opacity: 0.7;
121 +
	padding: 0.2rem 0.4rem;
122 +
}
123 +
124 +
.controls button.reset:hover {
125 +
	opacity: 1;
126 +
}
127 +
128 +
.controls-header {
129 +
	display: flex;
130 +
	gap: 0.5rem;
131 +
	align-items: center;
132 +
}
133 +
134 +
.controls-header > button:first-child {
135 +
	flex: 1;
136 +
}
137 +
138 +
.controls-header .close {
139 +
	width: 28px;
140 +
	height: 28px;
141 +
	padding: 0;
142 +
	font-size: 16px;
143 +
	line-height: 1;
144 +
}
145 +
146 +
.controls-toggle {
147 +
	position: fixed;
148 +
	top: 1rem;
149 +
	right: 1rem;
150 +
	z-index: 10;
151 +
	width: 36px;
152 +
	height: 36px;
153 +
	background: rgba(18, 17, 19, 0.85);
154 +
	border: 1px solid rgba(245, 243, 238, 0.2);
155 +
	color: #f5f3ee;
156 +
	font: inherit;
157 +
	font-size: 18px;
158 +
	line-height: 1;
159 +
	cursor: pointer;
160 +
	backdrop-filter: blur(4px);
161 +
}
162 +
163 +
.controls-toggle:hover {
164 +
	background: rgba(245, 243, 238, 0.15);
165 +
}
src/audio/engine.ts +42 −6
21 21
	chorus: 0.45,
22 22
};
23 23
24 +
export interface NoteOpts {
25 +
	attack?: number;
26 +
	release?: number;
27 +
	bypassRateLimit?: boolean;
28 +
}
29 +
24 30
export interface AudioEngine {
25 31
	kind: 'midi' | 'synth';
26 -
	noteOn(note: number, velocity: number, durationMs: number): void;
32 +
	noteOn(note: number, velocity: number, durationMs: number, opts?: NoteOpts): void;
33 +
	setModulation?(value: number): void;
27 34
	setVolume?(v: number): void;
28 35
	setSynthParams?(p: Partial<SynthParams>): void;
29 36
	listOutputs?(): MidiOutputInfo[];
45 52
	private master: GainNode;
46 53
	private padBus: GainNode;
47 54
	private wetBus: GainNode;
55 +
	private morph: BiquadFilterNode;
48 56
	private active: PadVoice[] = [];
49 57
	private params: SynthParams = { ...DEFAULT_SYNTH_PARAMS };
50 58
	private lastNoteAt = 0;
64 72
		this.wetBus = this.ctx.createGain();
65 73
		this.wetBus.gain.value = this.params.chorus;
66 74
75 +
		this.morph = this.ctx.createBiquadFilter();
76 +
		this.morph.type = 'lowpass';
77 +
		this.morph.Q.value = 0.4;
78 +
		this.morph.frequency.value = 1200;
79 +
		this.morph.connect(this.padBus);
80 +
67 81
		this.buildChorus(this.padBus, this.wetBus);
68 82
		this.padBus.connect(this.master);
69 83
		this.wetBus.connect(this.master);
84 +
	}
85 +
86 +
	setModulation(value: number) {
87 +
		const v = Math.max(0, Math.min(1, value));
88 +
		const target = 350 + v * v * 5500;
89 +
		this.morph.frequency.setTargetAtTime(target, this.ctx.currentTime, 0.15);
70 90
	}
71 91
72 92
	setSynthParams(p: Partial<SynthParams>) {
106 126
		this.master.gain.setTargetAtTime(v, this.ctx.currentTime, 0.02);
107 127
	}
108 128
109 -
	noteOn(note: number, velocity: number) {
129 +
	noteOn(note: number, velocity: number, _durationMs: number, opts?: NoteOpts) {
110 130
		const nowMs = performance.now();
111 -
		if (nowMs - this.lastNoteAt < this.minNoteGapMs) return;
112 -
		this.lastNoteAt = nowMs;
131 +
		if (!opts?.bypassRateLimit) {
132 +
			if (nowMs - this.lastNoteAt < this.minNoteGapMs) return;
133 +
			this.lastNoteAt = nowMs;
134 +
		}
113 135
114 -
		const { attack, release, cutoff, detune, polyphony } = this.params;
136 +
		const { cutoff, detune, polyphony } = this.params;
137 +
		const attack = opts?.attack ?? this.params.attack;
138 +
		const release = opts?.release ?? this.params.release;
115 139
116 140
		while (this.active.length >= polyphony) {
117 141
			const oldest = this.active.shift()!;
172 196
		subOsc.stop(t + total + 0.1);
173 197
		oscs.push(subOsc);
174 198
175 -
		filter.connect(g).connect(this.padBus);
199 +
		filter.connect(g).connect(this.morph);
176 200
177 201
		const entry: PadVoice = { oscs, gain: g, filter, end: t + total };
178 202
		this.active.push(entry);
192 216
	private access: MIDIAccess;
193 217
	private outputId: string | null = null;
194 218
	private channel = 0;
219 +
	private lastModSendAt = 0;
220 +
	private lastModValue = -1;
195 221
196 222
	constructor(access: MIDIAccess) {
197 223
		this.access = access;
227 253
		setTimeout(() => {
228 254
			this.send([0x80 | this.channel, n, 0]);
229 255
		}, durationMs);
256 +
	}
257 +
258 +
	setModulation(value: number) {
259 +
		const now = performance.now();
260 +
		if (now - this.lastModSendAt < 90) return;
261 +
		const cc = Math.max(0, Math.min(127, Math.round(value * 127)));
262 +
		if (cc === this.lastModValue) return;
263 +
		this.lastModValue = cc;
264 +
		this.lastModSendAt = now;
265 +
		this.send([0xb0 | this.channel, 74, cc]);
230 266
	}
231 267
232 268
	dispose() {}
src/components/Controls.tsx +380 −142
1 +
import { useState } from 'react';
1 2
import type { MidiOutputInfo, SynthParams } from '../audio/engine';
2 3
import { NOTE_NAMES, type ScaleName } from '../audio/scales';
4 +
import type { BoidParams } from '../sim/boids';
5 +
import type { MusicParams } from '../music';
3 6
4 7
interface Props {
5 8
	started: boolean;
19 22
	setVolume: (v: number) => void;
20 23
	synthParams: SynthParams;
21 24
	setSynthParam: <K extends keyof SynthParams>(k: K, v: SynthParams[K]) => void;
25 +
	musicParams: MusicParams;
26 +
	setMusicParam: <K extends keyof MusicParams>(k: K, v: MusicParams[K]) => void;
27 +
	boidParams: BoidParams;
28 +
	setBoidParam: <K extends keyof BoidParams>(k: K, v: BoidParams[K]) => void;
29 +
	boidCount: number;
30 +
	respawn: (count: number) => void;
31 +
	resetScale: () => void;
32 +
	resetFlock: () => void;
33 +
	resetTriggers: () => void;
34 +
	resetSynth: () => void;
22 35
}
23 36
24 37
const SCALE_OPTIONS: { value: ScaleName; label: string }[] = [
29 42
	{ value: 'dorian', label: 'Dorian' },
30 43
];
31 44
32 -
export default function Controls(props: Props) {
45 +
interface SliderProps {
46 +
	label: string;
47 +
	min: number;
48 +
	max: number;
49 +
	step: number;
50 +
	value: number;
51 +
	onChange: (v: number) => void;
52 +
}
53 +
54 +
function Slider({ label, min, max, step, value, onChange }: SliderProps) {
33 55
	return (
34 -
		<div className="controls">
35 -
			<button type="button" onClick={props.started ? props.onStop : props.onStart}>
36 -
				{props.started ? 'Stop' : 'Start'}
37 -
			</button>
56 +
		<label>
57 +
			<span>{label}</span>
58 +
			<input
59 +
				type="range"
60 +
				min={min}
61 +
				max={max}
62 +
				step={step}
63 +
				value={value}
64 +
				onChange={(e) => onChange(Number(e.target.value))}
65 +
			/>
66 +
			<span className="val">{value}</span>
67 +
		</label>
68 +
	);
69 +
}
38 70
39 -
			<label>
40 -
				Scale
41 -
				<select
42 -
					value={props.scale}
43 -
					onChange={(e) => props.setScale(e.target.value as ScaleName)}
44 -
				>
45 -
					{SCALE_OPTIONS.map((s) => (
46 -
						<option key={s.value} value={s.value}>
47 -
							{s.label}
48 -
						</option>
49 -
					))}
50 -
				</select>
51 -
			</label>
71 +
export default function Controls(props: Props) {
72 +
	const [open, setOpen] = useState(true);
52 73
53 -
			<label>
54 -
				Root
55 -
				<select
56 -
					value={props.rootPc}
57 -
					onChange={(e) => props.setRootPc(Number(e.target.value))}
58 -
				>
59 -
					{NOTE_NAMES.map((n, i) => (
60 -
						<option key={n} value={i}>
61 -
							{n}
62 -
						</option>
63 -
					))}
64 -
				</select>
65 -
			</label>
74 +
	if (!open) {
75 +
		return (
76 +
			<button
77 +
				type="button"
78 +
				className="controls-toggle"
79 +
				onClick={() => setOpen(true)}
80 +
				aria-label="Open controls"
81 +
			>
82 +
83 +
			</button>
84 +
		);
85 +
	}
66 86
67 -
			<label>
68 -
				Octave
69 -
				<select
70 -
					value={props.octaveBase}
71 -
					onChange={(e) => props.setOctaveBase(Number(e.target.value))}
87 +
	return (
88 +
		<div className="controls">
89 +
			<div className="controls-header">
90 +
				<button type="button" onClick={props.started ? props.onStop : props.onStart}>
91 +
					{props.started ? 'Stop' : 'Start'}
92 +
				</button>
93 +
				<button
94 +
					type="button"
95 +
					className="close"
96 +
					onClick={() => setOpen(false)}
97 +
					aria-label="Close controls"
72 98
				>
73 -
					{[1, 2, 3, 4, 5, 6].map((o) => (
74 -
						<option key={o} value={o}>
75 -
							{o}
76 -
						</option>
77 -
					))}
78 -
				</select>
79 -
			</label>
99 +
					×
100 +
				</button>
101 +
			</div>
80 102
81 -
			{props.engineKind === 'midi' && props.outputs.length > 0 && (
103 +
			<details open>
104 +
				<summary>Scale</summary>
105 +
				<label>
106 +
					<span>Scale</span>
107 +
					<select
108 +
						value={props.scale}
109 +
						onChange={(e) => props.setScale(e.target.value as ScaleName)}
110 +
					>
111 +
						{SCALE_OPTIONS.map((s) => (
112 +
							<option key={s.value} value={s.value}>
113 +
								{s.label}
114 +
							</option>
115 +
						))}
116 +
					</select>
117 +
				</label>
118 +
				<label>
119 +
					<span>Root</span>
120 +
					<select
121 +
						value={props.rootPc}
122 +
						onChange={(e) => props.setRootPc(Number(e.target.value))}
123 +
					>
124 +
						{NOTE_NAMES.map((n, i) => (
125 +
							<option key={n} value={i}>
126 +
								{n}
127 +
							</option>
128 +
						))}
129 +
					</select>
130 +
				</label>
82 131
				<label>
83 -
					MIDI
132 +
					<span>Octave</span>
84 133
					<select
85 -
						value={props.currentOutputId ?? ''}
86 -
						onChange={(e) => props.selectOutput(e.target.value)}
134 +
						value={props.octaveBase}
135 +
						onChange={(e) => props.setOctaveBase(Number(e.target.value))}
87 136
					>
88 -
						{props.outputs.map((o) => (
89 -
							<option key={o.id} value={o.id}>
90 -
								{o.name}
137 +
						{[1, 2, 3, 4, 5, 6].map((o) => (
138 +
							<option key={o} value={o}>
139 +
								{o}
91 140
							</option>
92 141
						))}
93 142
					</select>
94 143
				</label>
95 -
			)}
144 +
				<button type="button" className="reset" onClick={props.resetScale}>
145 +
					Reset defaults
146 +
				</button>
147 +
			</details>
96 148
97 -
			{props.engineKind === 'synth' && (
98 -
				<>
99 -
					<div className="section">Built-in synth (no MIDI device connected)</div>
100 -
					<label>
101 -
						Volume
102 -
						<input
103 -
							type="range"
104 -
							min={0}
105 -
							max={1}
106 -
							step={0.01}
107 -
							value={props.volume}
108 -
							onChange={(e) => props.setVolume(Number(e.target.value))}
109 -
						/>
110 -
					</label>
111 -
					<label>
112 -
						Attack
113 -
						<input
114 -
							type="range"
115 -
							min={0.05}
116 -
							max={2}
117 -
							step={0.05}
118 -
							value={props.synthParams.attack}
119 -
							onChange={(e) =>
120 -
								props.setSynthParam('attack', Number(e.target.value))
121 -
							}
122 -
						/>
123 -
					</label>
124 -
					<label>
125 -
						Release
126 -
						<input
127 -
							type="range"
128 -
							min={0.2}
129 -
							max={3}
130 -
							step={0.05}
131 -
							value={props.synthParams.release}
132 -
							onChange={(e) =>
133 -
								props.setSynthParam('release', Number(e.target.value))
134 -
							}
135 -
						/>
136 -
					</label>
137 -
					<label>
138 -
						Cutoff
139 -
						<input
140 -
							type="range"
141 -
							min={300}
142 -
							max={5000}
143 -
							step={50}
144 -
							value={props.synthParams.cutoff}
145 -
							onChange={(e) =>
146 -
								props.setSynthParam('cutoff', Number(e.target.value))
147 -
							}
148 -
						/>
149 -
					</label>
150 -
					<label>
151 -
						Detune
152 -
						<input
153 -
							type="range"
154 -
							min={0}
155 -
							max={25}
156 -
							step={1}
157 -
							value={props.synthParams.detune}
158 -
							onChange={(e) =>
159 -
								props.setSynthParam('detune', Number(e.target.value))
160 -
							}
161 -
						/>
162 -
					</label>
163 -
					<label>
164 -
						Chorus
165 -
						<input
166 -
							type="range"
167 -
							min={0}
168 -
							max={1}
169 -
							step={0.01}
170 -
							value={props.synthParams.chorus}
171 -
							onChange={(e) =>
172 -
								props.setSynthParam('chorus', Number(e.target.value))
173 -
							}
174 -
						/>
175 -
					</label>
149 +
			<details>
150 +
				<summary>Flock</summary>
151 +
				<label>
152 +
					<span>Count</span>
153 +
					<input
154 +
						type="range"
155 +
						min={40}
156 +
						max={600}
157 +
						step={10}
158 +
						value={props.boidCount}
159 +
						onChange={(e) => props.respawn(Number(e.target.value))}
160 +
					/>
161 +
					<span className="val">{props.boidCount}</span>
162 +
				</label>
163 +
				<Slider
164 +
					label="Topological K"
165 +
					min={1}
166 +
					max={15}
167 +
					step={1}
168 +
					value={props.boidParams.topologicalK}
169 +
					onChange={(v) => props.setBoidParam('topologicalK', v)}
170 +
				/>
171 +
				<Slider
172 +
					label="Perception"
173 +
					min={20}
174 +
					max={150}
175 +
					step={5}
176 +
					value={props.boidParams.perception}
177 +
					onChange={(v) => props.setBoidParam('perception', v)}
178 +
				/>
179 +
				<Slider
180 +
					label="Sep range"
181 +
					min={4}
182 +
					max={40}
183 +
					step={1}
184 +
					value={props.boidParams.sepRange}
185 +
					onChange={(v) => props.setBoidParam('sepRange', v)}
186 +
				/>
187 +
				<Slider
188 +
					label="Max speed"
189 +
					min={1}
190 +
					max={6}
191 +
					step={0.1}
192 +
					value={props.boidParams.maxSpeed}
193 +
					onChange={(v) => props.setBoidParam('maxSpeed', v)}
194 +
				/>
195 +
				<Slider
196 +
					label="Min speed"
197 +
					min={0.5}
198 +
					max={5}
199 +
					step={0.1}
200 +
					value={props.boidParams.minSpeed}
201 +
					onChange={(v) => props.setBoidParam('minSpeed', v)}
202 +
				/>
203 +
				<Slider
204 +
					label="Max force"
205 +
					min={0.02}
206 +
					max={0.4}
207 +
					step={0.01}
208 +
					value={props.boidParams.maxForce}
209 +
					onChange={(v) => props.setBoidParam('maxForce', v)}
210 +
				/>
211 +
				<Slider
212 +
					label="Separation"
213 +
					min={0}
214 +
					max={4}
215 +
					step={0.05}
216 +
					value={props.boidParams.wSep}
217 +
					onChange={(v) => props.setBoidParam('wSep', v)}
218 +
				/>
219 +
				<Slider
220 +
					label="Alignment"
221 +
					min={0}
222 +
					max={4}
223 +
					step={0.05}
224 +
					value={props.boidParams.wAlign}
225 +
					onChange={(v) => props.setBoidParam('wAlign', v)}
226 +
				/>
227 +
				<Slider
228 +
					label="Cohesion"
229 +
					min={0}
230 +
					max={4}
231 +
					step={0.05}
232 +
					value={props.boidParams.wCoh}
233 +
					onChange={(v) => props.setBoidParam('wCoh', v)}
234 +
				/>
235 +
				<Slider
236 +
					label="Attractor"
237 +
					min={0}
238 +
					max={0.5}
239 +
					step={0.01}
240 +
					value={props.boidParams.wAttract}
241 +
					onChange={(v) => props.setBoidParam('wAttract', v)}
242 +
				/>
243 +
				<Slider
244 +
					label="Noise"
245 +
					min={0}
246 +
					max={0.2}
247 +
					step={0.005}
248 +
					value={props.boidParams.wNoise}
249 +
					onChange={(v) => props.setBoidParam('wNoise', v)}
250 +
				/>
251 +
				<button type="button" onClick={() => props.respawn(props.boidCount)}>
252 +
					Respawn
253 +
				</button>
254 +
				<button type="button" className="reset" onClick={props.resetFlock}>
255 +
					Reset defaults
256 +
				</button>
257 +
			</details>
258 +
259 +
			<details>
260 +
				<summary>Triggers</summary>
261 +
				<Slider
262 +
					label="Bucket 1"
263 +
					min={1}
264 +
					max={10}
265 +
					step={1}
266 +
					value={props.musicParams.bucketB1}
267 +
					onChange={(v) => props.setMusicParam('bucketB1', v)}
268 +
				/>
269 +
				<Slider
270 +
					label="Bucket 2"
271 +
					min={2}
272 +
					max={20}
273 +
					step={1}
274 +
					value={props.musicParams.bucketB2}
275 +
					onChange={(v) => props.setMusicParam('bucketB2', v)}
276 +
				/>
277 +
				<Slider
278 +
					label="Bucket 3 (5th)"
279 +
					min={3}
280 +
					max={30}
281 +
					step={1}
282 +
					value={props.musicParams.bucketB3}
283 +
					onChange={(v) => props.setMusicParam('bucketB3', v)}
284 +
				/>
285 +
				<Slider
286 +
					label="Bucket 4 (oct)"
287 +
					min={4}
288 +
					max={50}
289 +
					step={1}
290 +
					value={props.musicParams.bucketB4}
291 +
					onChange={(v) => props.setMusicParam('bucketB4', v)}
292 +
				/>
293 +
				<Slider
294 +
					label="Accent thresh"
295 +
					min={0.005}
296 +
					max={0.2}
297 +
					step={0.005}
298 +
					value={props.musicParams.sepAccentThreshold}
299 +
					onChange={(v) => props.setMusicParam('sepAccentThreshold', v)}
300 +
				/>
301 +
				<Slider
302 +
					label="Accent cooldown"
303 +
					min={50}
304 +
					max={1500}
305 +
					step={25}
306 +
					value={props.musicParams.accentCooldownMs}
307 +
					onChange={(v) => props.setMusicParam('accentCooldownMs', v)}
308 +
				/>
309 +
				<Slider
310 +
					label="Accent rate cap"
311 +
					min={1}
312 +
					max={20}
313 +
					step={1}
314 +
					value={props.musicParams.accentRateCap}
315 +
					onChange={(v) => props.setMusicParam('accentRateCap', v)}
316 +
				/>
317 +
				<Slider
318 +
					label="Pad dur (ms)"
319 +
					min={150}
320 +
					max={2000}
321 +
					step={50}
322 +
					value={props.musicParams.padDurMs}
323 +
					onChange={(v) => props.setMusicParam('padDurMs', v)}
324 +
				/>
325 +
				<Slider
326 +
					label="Accent dur (ms)"
327 +
					min={30}
328 +
					max={500}
329 +
					step={10}
330 +
					value={props.musicParams.accentDurMs}
331 +
					onChange={(v) => props.setMusicParam('accentDurMs', v)}
332 +
				/>
333 +
				<Slider
334 +
					label="Flash (ms)"
335 +
					min={80}
336 +
					max={800}
337 +
					step={20}
338 +
					value={props.musicParams.flashMs}
339 +
					onChange={(v) => props.setMusicParam('flashMs', v)}
340 +
				/>
341 +
				<button type="button" className="reset" onClick={props.resetTriggers}>
342 +
					Reset defaults
343 +
				</button>
344 +
			</details>
345 +
346 +
			{props.engineKind === 'midi' && props.outputs.length > 0 && (
347 +
				<details open>
348 +
					<summary>MIDI</summary>
176 349
					<label>
177 -
						Polyphony
178 -
						<input
179 -
							type="range"
180 -
							min={2}
181 -
							max={12}
182 -
							step={1}
183 -
							value={props.synthParams.polyphony}
184 -
							onChange={(e) =>
185 -
								props.setSynthParam('polyphony', Number(e.target.value))
186 -
							}
187 -
						/>
350 +
						<span>Device</span>
351 +
						<select
352 +
							value={props.currentOutputId ?? ''}
353 +
							onChange={(e) => props.selectOutput(e.target.value)}
354 +
						>
355 +
							{props.outputs.map((o) => (
356 +
								<option key={o.id} value={o.id}>
357 +
									{o.name}
358 +
								</option>
359 +
							))}
360 +
						</select>
188 361
					</label>
189 -
				</>
362 +
				</details>
363 +
			)}
364 +
365 +
			{props.engineKind === 'synth' && (
366 +
				<details>
367 +
					<summary>Synth (no MIDI)</summary>
368 +
					<Slider
369 +
						label="Volume"
370 +
						min={0}
371 +
						max={1}
372 +
						step={0.01}
373 +
						value={props.volume}
374 +
						onChange={props.setVolume}
375 +
					/>
376 +
					<Slider
377 +
						label="Attack"
378 +
						min={0.05}
379 +
						max={2}
380 +
						step={0.05}
381 +
						value={props.synthParams.attack}
382 +
						onChange={(v) => props.setSynthParam('attack', v)}
383 +
					/>
384 +
					<Slider
385 +
						label="Release"
386 +
						min={0.2}
387 +
						max={3}
388 +
						step={0.05}
389 +
						value={props.synthParams.release}
390 +
						onChange={(v) => props.setSynthParam('release', v)}
391 +
					/>
392 +
					<Slider
393 +
						label="Cutoff"
394 +
						min={300}
395 +
						max={5000}
396 +
						step={50}
397 +
						value={props.synthParams.cutoff}
398 +
						onChange={(v) => props.setSynthParam('cutoff', v)}
399 +
					/>
400 +
					<Slider
401 +
						label="Detune"
402 +
						min={0}
403 +
						max={25}
404 +
						step={1}
405 +
						value={props.synthParams.detune}
406 +
						onChange={(v) => props.setSynthParam('detune', v)}
407 +
					/>
408 +
					<Slider
409 +
						label="Chorus"
410 +
						min={0}
411 +
						max={1}
412 +
						step={0.01}
413 +
						value={props.synthParams.chorus}
414 +
						onChange={(v) => props.setSynthParam('chorus', v)}
415 +
					/>
416 +
					<Slider
417 +
						label="Polyphony"
418 +
						min={2}
419 +
						max={12}
420 +
						step={1}
421 +
						value={props.synthParams.polyphony}
422 +
						onChange={(v) => props.setSynthParam('polyphony', v)}
423 +
					/>
424 +
					<button type="button" className="reset" onClick={props.resetSynth}>
425 +
						Reset defaults
426 +
					</button>
427 +
				</details>
190 428
			)}
191 429
192 430
			<div className="status">
src/components/Murmurations.tsx +149 −19
1 1
import { useEffect, useRef, useState } from 'react';
2 -
import { createSim, drawBoid, MAX_SPEED, type Sim } from '../sim/boids';
2 +
import {
3 +
	boidParams,
4 +
	createSim,
5 +
	DEFAULT_BOID_PARAMS,
6 +
	drawBoid,
7 +
	flockCoherence,
8 +
	type BoidParams,
9 +
	type Sim,
10 +
} from '../sim/boids';
3 11
import { cellAt, drawGrid } from '../sim/grid';
4 -
import { cellToMidi, type ScaleName } from '../audio/scales';
12 +
import { cellToMidi, COLS, ROWS, type ScaleName } from '../audio/scales';
5 13
import {
6 14
	createEngine,
7 15
	DEFAULT_SYNTH_PARAMS,
9 17
	type MidiOutputInfo,
10 18
	type SynthParams,
11 19
} from '../audio/engine';
20 +
import {
21 +
	bucketOf,
22 +
	DEFAULT_MUSIC_PARAMS,
23 +
	type MusicParams,
24 +
} from '../music';
12 25
import Controls from './Controls';
13 26
14 -
const FLASH_MS = 280;
15 -
const NOTE_DUR_MS = 200;
27 +
const DEFAULT_COUNT = 240;
16 28
17 29
export default function Murmurations() {
18 30
	const canvasRef = useRef<HTMLCanvasElement | null>(null);
19 31
	const simRef = useRef<Sim | null>(null);
20 32
	const engineRef = useRef<AudioEngine | null>(null);
21 33
	const flashesRef = useRef<Map<number, number>>(new Map());
34 +
	const cellBucketsRef = useRef<Int8Array>(new Int8Array(COLS * ROWS));
35 +
	const accentRateRef = useRef(0);
22 36
	const rafRef = useRef<number | null>(null);
23 37
	const sizeRef = useRef({ W: 0, H: 0 });
24 38
33 47
	const [synthParams, setSynthParams] = useState<SynthParams>({
34 48
		...DEFAULT_SYNTH_PARAMS,
35 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);
36 57
37 58
	const settingsRef = useRef({ scale, rootPc, octaveBase });
38 59
	useEffect(() => {
39 60
		settingsRef.current = { scale, rootPc, octaveBase };
40 61
	}, [scale, rootPc, octaveBase]);
62 +
63 +
	const musicRef = useRef(musicParams);
64 +
	useEffect(() => {
65 +
		musicRef.current = musicParams;
66 +
	}, [musicParams]);
41 67
42 68
	useEffect(() => {
43 69
		const canvas = canvasRef.current!;
87 113
88 114
			ctx.strokeStyle = 'rgba(245, 243, 238, 0.78)';
89 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 +
90 121
			for (let i = 0; i < sim.state.boids.length; i++) {
91 122
				const b = sim.state.boids[i];
92 123
				drawBoid(ctx, b);
93 -
94 124
				const idx = cellAt(b.x, b.y, W, H);
95 -
				if (b.lastCell !== -1 && idx !== b.lastCell) {
96 -
					if (engine) {
97 -
						const row = Math.floor(idx / 7);
98 -
						const col = idx % 7;
99 -
						const note = cellToMidi(
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(
100 140
							col,
101 141
							row,
102 142
							settings.rootPc,
103 143
							settings.octaveBase,
104 144
							settings.scale,
105 145
						);
106 -
						const sp = Math.hypot(b.vx, b.vy);
107 -
						const vel = Math.max(
108 -
							40,
109 -
							Math.min(120, Math.round(40 + (sp / MAX_SPEED) * 70)),
110 -
						);
111 -
						engine.noteOn(note, vel, NOTE_DUR_MS);
112 -
						flashesRef.current.set(idx, now + FLASH_MS);
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);
113 156
					}
157 +
					buckets[idx] = newBucket;
114 158
				}
115 -
				b.lastCell = idx;
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);
116 197
			}
117 198
118 -
			drawGrid(ctx, W, H, flashesRef.current, now, FLASH_MS);
199 +
			drawGrid(ctx, W, H, flashesRef.current, now, musicRef.current.flashMs);
119 200
120 201
			rafRef.current = requestAnimationFrame(loop);
121 202
		}
157 238
		});
158 239
	}
159 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 +
160 280
	function stop() {
161 281
		engineRef.current?.dispose();
162 282
		engineRef.current = null;
197 317
				setVolume={handleVolume}
198 318
				synthParams={synthParams}
199 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}
200 330
			/>
201 331
		</>
202 332
	);
src/music.ts (added) +33 −0
1 +
export interface MusicParams {
2 +
	bucketB1: number;
3 +
	bucketB2: number;
4 +
	bucketB3: number;
5 +
	bucketB4: number;
6 +
	sepAccentThreshold: number;
7 +
	accentCooldownMs: number;
8 +
	accentRateCap: number;
9 +
	padDurMs: number;
10 +
	accentDurMs: number;
11 +
	flashMs: number;
12 +
}
13 +
14 +
export const DEFAULT_MUSIC_PARAMS: MusicParams = {
15 +
	bucketB1: 1,
16 +
	bucketB2: 3,
17 +
	bucketB3: 6,
18 +
	bucketB4: 12,
19 +
	sepAccentThreshold: 0.04,
20 +
	accentCooldownMs: 350,
21 +
	accentRateCap: 6,
22 +
	padDurMs: 600,
23 +
	accentDurMs: 90,
24 +
	flashMs: 320,
25 +
};
26 +
27 +
export function bucketOf(count: number, p: MusicParams): number {
28 +
	if (count >= p.bucketB4) return 4;
29 +
	if (count >= p.bucketB3) return 3;
30 +
	if (count >= p.bucketB2) return 2;
31 +
	if (count >= p.bucketB1) return 1;
32 +
	return 0;
33 +
}
src/sim/boids.ts +112 −58
1 -
const TOPOLOGICAL_K = 7;
2 -
const PERCEPTION = 70;
3 -
const SEP_RANGE = 14;
4 -
export const MAX_SPEED = 3.4;
5 -
const MIN_SPEED = 2.2;
6 -
const MAX_FORCE = 0.12;
1 +
export interface BoidParams {
2 +
	topologicalK: number;
3 +
	perception: number;
4 +
	sepRange: number;
5 +
	maxSpeed: number;
6 +
	minSpeed: number;
7 +
	maxForce: number;
8 +
	wSep: number;
9 +
	wAlign: number;
10 +
	wCoh: number;
11 +
	wAttract: number;
12 +
	wNoise: number;
13 +
}
14 +
15 +
export const DEFAULT_BOID_PARAMS: BoidParams = {
16 +
	topologicalK: 7,
17 +
	perception: 70,
18 +
	sepRange: 14,
19 +
	maxSpeed: 3.4,
20 +
	minSpeed: 2.2,
21 +
	maxForce: 0.12,
22 +
	wSep: 1.8,
23 +
	wAlign: 1.4,
24 +
	wCoh: 0.9,
25 +
	wAttract: 0.06,
26 +
	wNoise: 0.015,
27 +
};
7 28
8 -
const W_SEP = 1.8;
9 -
const W_ALIGN = 1.4;
10 -
const W_COH = 0.9;
11 -
const W_ATTRACT = 0.06;
12 -
const W_NOISE = 0.015;
29 +
export const boidParams: BoidParams = { ...DEFAULT_BOID_PARAMS };
13 30
14 31
export class Boid {
15 32
	x: number;
19 36
	ax = 0;
20 37
	ay = 0;
21 38
	lastCell = -1;
39 +
	lastSepMag = 0;
40 +
	lastAccentAt = 0;
22 41
23 42
	constructor(W: number, H: number) {
24 43
		this.x = Math.random() * W;
25 44
		this.y = Math.random() * H;
26 45
		const a = Math.random() * Math.PI * 2;
27 -
		const s = MIN_SPEED + Math.random() * (MAX_SPEED - MIN_SPEED);
46 +
		const s =
47 +
			boidParams.minSpeed +
48 +
			Math.random() * (boidParams.maxSpeed - boidParams.minSpeed);
28 49
		this.vx = Math.cos(a) * s;
29 50
		this.vy = Math.sin(a) * s;
30 51
	}
38 59
	}
39 60
40 61
	flock(candidates: Boid[], attractor: { x: number; y: number }) {
62 +
		const p = boidParams;
63 +
		const perception2 = p.perception * p.perception;
64 +
		const sepRange2 = p.sepRange * p.sepRange;
65 +
41 66
		const dists: { o: Boid; d2: number; dx: number; dy: number }[] = [];
42 67
		for (let i = 0; i < candidates.length; i++) {
43 68
			const o = candidates[i];
45 70
			const dx = o.x - this.x;
46 71
			const dy = o.y - this.y;
47 72
			const d2 = dx * dx + dy * dy;
48 -
			if (d2 < PERCEPTION * PERCEPTION && d2 > 0) {
73 +
			if (d2 < perception2 && d2 > 0) {
49 74
				dists.push({ o, d2, dx, dy });
50 75
			}
51 76
		}
52 77
		dists.sort((a, b) => a.d2 - b.d2);
53 -
		const k = Math.min(TOPOLOGICAL_K, dists.length);
78 +
		const k = Math.min(p.topologicalK, dists.length);
54 79
55 80
		let alignX = 0,
56 81
			alignY = 0;
67 92
			cohX += o.x;
68 93
			cohY += o.y;
69 94
70 -
			if (d2 < SEP_RANGE * SEP_RANGE) {
95 +
			if (d2 < sepRange2) {
71 96
				const d = Math.sqrt(d2);
72 97
				const f = 1 / (d + 0.001);
73 98
				sepX -= (dx / d) * f;
84 109
			alignY /= k;
85 110
			const m = Math.hypot(alignX, alignY);
86 111
			if (m > 0) {
87 -
				alignX = (alignX / m) * MAX_SPEED - this.vx;
88 -
				alignY = (alignY / m) * MAX_SPEED - this.vy;
112 +
				alignX = (alignX / m) * p.maxSpeed - this.vx;
113 +
				alignY = (alignY / m) * p.maxSpeed - this.vy;
89 114
				const sm = Math.hypot(alignX, alignY);
90 -
				if (sm > MAX_FORCE) {
91 -
					alignX = (alignX / sm) * MAX_FORCE;
92 -
					alignY = (alignY / sm) * MAX_FORCE;
115 +
				if (sm > p.maxForce) {
116 +
					alignX = (alignX / sm) * p.maxForce;
117 +
					alignY = (alignY / sm) * p.maxForce;
93 118
				}
94 -
				fx += alignX * W_ALIGN;
95 -
				fy += alignY * W_ALIGN;
119 +
				fx += alignX * p.wAlign;
120 +
				fy += alignY * p.wAlign;
96 121
			}
97 122
98 123
			cohX = cohX / k - this.x;
99 124
			cohY = cohY / k - this.y;
100 125
			const cm = Math.hypot(cohX, cohY);
101 126
			if (cm > 0) {
102 -
				cohX = (cohX / cm) * MAX_SPEED - this.vx;
103 -
				cohY = (cohY / cm) * MAX_SPEED - this.vy;
127 +
				cohX = (cohX / cm) * p.maxSpeed - this.vx;
128 +
				cohY = (cohY / cm) * p.maxSpeed - this.vy;
104 129
				const sm = Math.hypot(cohX, cohY);
105 -
				if (sm > MAX_FORCE) {
106 -
					cohX = (cohX / sm) * MAX_FORCE;
107 -
					cohY = (cohY / sm) * MAX_FORCE;
130 +
				if (sm > p.maxForce) {
131 +
					cohX = (cohX / sm) * p.maxForce;
132 +
					cohY = (cohY / sm) * p.maxForce;
108 133
				}
109 -
				fx += cohX * W_COH;
110 -
				fy += cohY * W_COH;
134 +
				fx += cohX * p.wCoh;
135 +
				fy += cohY * p.wCoh;
111 136
			}
112 137
		}
113 138
114 139
		if (sepCount > 0) {
115 140
			const m = Math.hypot(sepX, sepY);
116 141
			if (m > 0) {
117 -
				sepX = (sepX / m) * MAX_SPEED - this.vx;
118 -
				sepY = (sepY / m) * MAX_SPEED - this.vy;
142 +
				sepX = (sepX / m) * p.maxSpeed - this.vx;
143 +
				sepY = (sepY / m) * p.maxSpeed - this.vy;
119 144
				const sm = Math.hypot(sepX, sepY);
120 -
				if (sm > MAX_FORCE * 2) {
121 -
					sepX = (sepX / sm) * MAX_FORCE * 2;
122 -
					sepY = (sepY / sm) * MAX_FORCE * 2;
145 +
				if (sm > p.maxForce * 2) {
146 +
					sepX = (sepX / sm) * p.maxForce * 2;
147 +
					sepY = (sepY / sm) * p.maxForce * 2;
123 148
				}
124 -
				fx += sepX * W_SEP;
125 -
				fy += sepY * W_SEP;
149 +
				fx += sepX * p.wSep;
150 +
				fy += sepY * p.wSep;
151 +
				this.lastSepMag = Math.hypot(sepX, sepY);
152 +
			} else {
153 +
				this.lastSepMag = 0;
126 154
			}
155 +
		} else {
156 +
			this.lastSepMag = 0;
127 157
		}
128 158
129 159
		const adx = attractor.x - this.x;
130 160
		const ady = attractor.y - this.y;
131 161
		const ad = Math.hypot(adx, ady);
132 162
		if (ad > 0) {
133 -
			fx += (adx / ad) * W_ATTRACT;
134 -
			fy += (ady / ad) * W_ATTRACT;
163 +
			fx += (adx / ad) * p.wAttract;
164 +
			fy += (ady / ad) * p.wAttract;
135 165
		}
136 166
137 -
		fx += (Math.random() - 0.5) * W_NOISE;
138 -
		fy += (Math.random() - 0.5) * W_NOISE;
167 +
		fx += (Math.random() - 0.5) * p.wNoise;
168 +
		fy += (Math.random() - 0.5) * p.wNoise;
139 169
140 170
		this.ax += fx;
141 171
		this.ay += fy;
142 172
	}
143 173
144 174
	update() {
175 +
		const p = boidParams;
145 176
		this.vx += this.ax;
146 177
		this.vy += this.ay;
147 178
		const sp = Math.hypot(this.vx, this.vy);
148 -
		if (sp > MAX_SPEED) {
149 -
			this.vx = (this.vx / sp) * MAX_SPEED;
150 -
			this.vy = (this.vy / sp) * MAX_SPEED;
151 -
		} else if (sp < MIN_SPEED && sp > 0) {
152 -
			this.vx = (this.vx / sp) * MIN_SPEED;
153 -
			this.vy = (this.vy / sp) * MIN_SPEED;
179 +
		if (sp > p.maxSpeed) {
180 +
			this.vx = (this.vx / sp) * p.maxSpeed;
181 +
			this.vy = (this.vy / sp) * p.maxSpeed;
182 +
		} else if (sp < p.minSpeed && sp > 0) {
183 +
			this.vx = (this.vx / sp) * p.minSpeed;
184 +
			this.vy = (this.vy / sp) * p.minSpeed;
154 185
		}
155 186
		this.x += this.vx;
156 187
		this.y += this.vy;
159 190
	}
160 191
}
161 192
193 +
export function flockCoherence(boids: Boid[]): number {
194 +
	let sumX = 0,
195 +
		sumY = 0,
196 +
		sumMag = 0;
197 +
	for (let i = 0; i < boids.length; i++) {
198 +
		const b = boids[i];
199 +
		sumX += b.vx;
200 +
		sumY += b.vy;
201 +
		sumMag += Math.hypot(b.vx, b.vy);
202 +
	}
203 +
	if (sumMag === 0) return 0;
204 +
	return Math.hypot(sumX, sumY) / sumMag;
205 +
}
206 +
162 207
export function drawBoid(ctx: CanvasRenderingContext2D, b: Boid) {
163 208
	const angle = Math.atan2(b.vy, b.vx);
164 209
	const cos = Math.cos(angle);
184 229
		},
185 230
	};
186 231
187 -
	const count = Math.min(420, Math.floor((W * H) / 3200));
188 -
	const cx0 = W / 2,
189 -
		cy0 = H / 2;
190 -
	for (let i = 0; i < count; i++) {
191 -
		const b = new Boid(W, H);
192 -
		const r = Math.random() * Math.min(W, H) * 0.25;
193 -
		const a = Math.random() * Math.PI * 2;
194 -
		b.x = cx0 + Math.cos(a) * r;
195 -
		b.y = cy0 + Math.sin(a) * r;
196 -
		state.boids.push(b);
232 +
	function defaultCount(W: number, H: number) {
233 +
		return Math.min(420, Math.floor((W * H) / 3200));
197 234
	}
198 235
199 -
	const cellSize = PERCEPTION;
236 +
	function spawn(count: number) {
237 +
		state.boids.length = 0;
238 +
		const cx0 = state.W / 2,
239 +
			cy0 = state.H / 2;
240 +
		for (let i = 0; i < count; i++) {
241 +
			const b = new Boid(state.W, state.H);
242 +
			const r = Math.random() * Math.min(state.W, state.H) * 0.25;
243 +
			const a = Math.random() * Math.PI * 2;
244 +
			b.x = cx0 + Math.cos(a) * r;
245 +
			b.y = cy0 + Math.sin(a) * r;
246 +
			state.boids.push(b);
247 +
		}
248 +
	}
249 +
250 +
	spawn(defaultCount(W, H));
251 +
200 252
	const grid = new Map<string, Boid[]>();
201 253
202 254
	function buildGrid() {
203 255
		grid.clear();
256 +
		const cellSize = boidParams.perception;
204 257
		for (let i = 0; i < state.boids.length; i++) {
205 258
			const b = state.boids[i];
206 259
			const cx = Math.floor(b.x / cellSize);
216 269
	}
217 270
218 271
	function neighbors(b: Boid): Boid[] {
272 +
		const cellSize = boidParams.perception;
219 273
		const cx = Math.floor(b.x / cellSize);
220 274
		const cy = Math.floor(b.y / cellSize);
221 275
		const out: Boid[] = [];
256 310
		state.H = H;
257 311
	}
258 312
259 -
	return { state, step, resize };
313 +
	return { state, step, resize, spawn };
260 314
}