chore: enhanced controls and engine
4498e871
6 file(s) · +811 −225
| 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 | + | } |
| 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() {} |
|
| 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"> |
|
| 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 | ); |
|
| 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 | + | } |
| 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 | } |
|