src/components/Controls.tsx 10.1 K raw
1
import { useState } from 'react';
2
import type { MidiOutputInfo, SynthParams } from '../audio/engine';
3
import { NOTE_NAMES, type ScaleName } from '../audio/scales';
4
import type { BoidParams } from '../sim/boids';
5
import type { MusicParams } from '../music';
6
7
interface Props {
8
	started: boolean;
9
	onStart: () => void;
10
	onStop: () => void;
11
	scale: ScaleName;
12
	setScale: (s: ScaleName) => void;
13
	rootPc: number;
14
	setRootPc: (n: number) => void;
15
	octaveBase: number;
16
	setOctaveBase: (n: number) => void;
17
	engineKind: 'midi' | 'synth' | null;
18
	outputs: MidiOutputInfo[];
19
	currentOutputId: string | null;
20
	selectOutput: (id: string) => void;
21
	volume: number;
22
	setVolume: (v: number) => void;
23
	synthParams: SynthParams;
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;
35
}
36
37
const SCALE_OPTIONS: { value: ScaleName; label: string }[] = [
38
	{ value: 'major', label: 'Major' },
39
	{ value: 'minor', label: 'Minor' },
40
	{ value: 'pentatonicMaj', label: 'Pentatonic Maj' },
41
	{ value: 'pentatonicMin', label: 'Pentatonic Min' },
42
	{ value: 'dorian', label: 'Dorian' },
43
];
44
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) {
55
	return (
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
}
70
71
export default function Controls(props: Props) {
72
	const [open, setOpen] = useState(true);
73
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
	}
86
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"
98
				>
99
					×
100
				</button>
101
			</div>
102
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>
131
				<label>
132
					<span>Octave</span>
133
					<select
134
						value={props.octaveBase}
135
						onChange={(e) => props.setOctaveBase(Number(e.target.value))}
136
					>
137
						{[1, 2, 3, 4, 5, 6].map((o) => (
138
							<option key={o} value={o}>
139
								{o}
140
							</option>
141
						))}
142
					</select>
143
				</label>
144
				<button type="button" className="reset" onClick={props.resetScale}>
145
					Reset defaults
146
				</button>
147
			</details>
148
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>
349
					<label>
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>
361
					</label>
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>
428
			)}
429
430
			<div className="status">
431
				{props.engineKind === null
432
					? 'audio idle'
433
					: props.engineKind === 'midi'
434
						? 'midi out'
435
						: 'built-in synth'}
436
			</div>
437
		</div>
438
	);
439
}