@tremolo-ui/dom 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Options for setting the amount of keyboard and mouse wheel changes.
3
+ */
4
+ export type InputEventOption = readonly ['normalized' | 'raw', number]
5
+
6
+ /**
7
+ * A modifier key that can carry an amount of its own.
8
+ *
9
+ * `ctrl` and `meta` are kept apart rather than folded into one "command" key:
10
+ * a plugin UI that mirrors a desktop host usually wants the same physical key
11
+ * on every platform, not the platform's own convention.
12
+ */
13
+ export type Modifier = 'shift' | 'alt' | 'ctrl' | 'meta'
14
+
15
+ /** The modifier flags of a `WheelEvent` or a `KeyboardEvent`. */
16
+ export interface ModifierState {
17
+ shiftKey: boolean
18
+ altKey: boolean
19
+ ctrlKey: boolean
20
+ metaKey: boolean
21
+ }
22
+
23
+ /** One setting per modifier key, with `default` for none of them. */
24
+ type ModifierSetting = number | InputEventOption
25
+
26
+ export type ModifierMap<T extends ModifierSetting> = { default: T } & Partial<
27
+ Record<Modifier, T>
28
+ >
29
+
30
+ /**
31
+ * A single setting, or one per modifier key.
32
+ *
33
+ * @example
34
+ * ['raw', 1]
35
+ * { default: ['raw', 1], shift: ['raw', 0.1] }
36
+ */
37
+ export type ModifierValue<T extends ModifierSetting> = T | ModifierMap<T>
38
+
39
+ /**
40
+ * Checked in this order, and the first one that is both held and configured
41
+ * wins. Fixing an order is what keeps two modifiers held at once from
42
+ * behaving differently between browsers.
43
+ */
44
+ const MODIFIER_ORDER = ['meta', 'ctrl', 'alt', 'shift'] as const
45
+
46
+ const MODIFIER_FLAG = {
47
+ meta: 'metaKey',
48
+ ctrl: 'ctrlKey',
49
+ alt: 'altKey',
50
+ shift: 'shiftKey',
51
+ } as const satisfies Record<Modifier, keyof ModifierState>
52
+
53
+ /**
54
+ * A map is the only form with a `default` key, which is what tells it apart
55
+ * from a bare setting. Tuples are arrays, so they never match.
56
+ */
57
+ function isModifierMap<T extends ModifierSetting>(
58
+ value: ModifierValue<T>,
59
+ ): value is ModifierMap<T> {
60
+ return (
61
+ typeof value === 'object' &&
62
+ value !== null &&
63
+ !Array.isArray(value) &&
64
+ 'default' in value
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Pick the setting that applies, given the modifier keys being held.
70
+ *
71
+ * @example
72
+ * selectModifier({ default: 1, shift: 0.1 }, event)
73
+ */
74
+ export function selectModifier<T extends ModifierSetting>(
75
+ options: ModifierValue<T>,
76
+ modifiers?: ModifierState,
77
+ ): { value: T; modifier: Modifier | null } {
78
+ if (!isModifierMap(options)) {
79
+ // TypeScript cannot subtract the map from `ModifierValue<T>` while `T` is
80
+ // still a type parameter, so the other half has to be spelled out.
81
+ return { value: options as T, modifier: null }
82
+ }
83
+ if (modifiers) {
84
+ for (const modifier of MODIFIER_ORDER) {
85
+ const value = options[modifier]
86
+ // Compared against undefined rather than checked for truthiness: 0 is a
87
+ // legitimate setting.
88
+ if (value !== undefined && modifiers[MODIFIER_FLAG[modifier]]) {
89
+ return { value, modifier }
90
+ }
91
+ }
92
+ }
93
+ return { value: options.default, modifier: null }
94
+ }
95
+
96
+ /**
97
+ * Turn every entry of a setting into another kind of setting, keeping which
98
+ * modifier each belongs to.
99
+ *
100
+ * A drag sensitivity is a number and a keyboard amount is a tuple, but the two
101
+ * describe the same thing from the caller's side. This carries one over to the
102
+ * other so that a component can hand a sensitivity to {@link applyDelta}
103
+ * without unpicking the modifier map itself — which matters, since naming a
104
+ * modifier is also what takes `step` out of the pipeline.
105
+ *
106
+ * @example
107
+ * mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])
108
+ * // { default: ['raw', 1], shift: ['raw', 0.1] }
109
+ */
110
+ export function mapModifier<
111
+ T extends ModifierSetting,
112
+ U extends ModifierSetting,
113
+ >(options: ModifierValue<T>, fn: (value: T) => U): ModifierValue<U> {
114
+ if (!isModifierMap(options)) return fn(options as T)
115
+ const mapped = { default: fn(options.default) } as ModifierMap<U>
116
+ for (const modifier of MODIFIER_ORDER) {
117
+ const value = options[modifier]
118
+ if (value !== undefined) mapped[modifier] = fn(value)
119
+ }
120
+ return mapped
121
+ }
@@ -2,23 +2,48 @@
2
2
  export const PERMISSION_DENIED = 'PERMISSION_DENIED'
3
3
  /** @private */
4
4
  export const NOT_SUPPORTED = 'NOT_SUPPORTED'
5
+ /** @private */
6
+ export const UNAVAILABLE = 'UNAVAILABLE'
5
7
 
6
8
  /** @private */
7
- export type MIDIAccessError = typeof PERMISSION_DENIED | typeof NOT_SUPPORTED
9
+ export type MIDIAccessError =
10
+ | typeof PERMISSION_DENIED
11
+ | typeof NOT_SUPPORTED
12
+ | typeof UNAVAILABLE
13
+
14
+ export type MIDIAccessOptions = {
15
+ /**
16
+ * Ask for system exclusive messages as well.
17
+ *
18
+ * Browsers treat this as a separate, more sensitive permission, so leave it
19
+ * off unless the app actually reads or sends sysex.
20
+ *
21
+ * @default false
22
+ */
23
+ sysex?: boolean
24
+ }
8
25
 
9
26
  export type MIDIAccessState = {
10
27
  readonly midiAccess: MIDIAccess | null
11
28
  readonly error: MIDIAccessError | null
29
+ /**
30
+ * The inputs currently connected, in the order MIDIAccess lists them.
31
+ *
32
+ * Kept up to date as devices are plugged in and unplugged, so a UI listing
33
+ * the devices does not have to watch `statechange` itself.
34
+ */
35
+ readonly inputs: readonly MIDIInput[]
12
36
  }
13
37
 
14
38
  const INITIAL_STATE: MIDIAccessState = {
15
39
  midiAccess: null,
16
40
  error: null,
41
+ inputs: [],
17
42
  }
18
43
 
19
44
  export interface MIDIAccessInstance {
20
45
  /** Request MIDI access. Safe to call more than once. */
21
- request: () => void
46
+ request: (options?: MIDIAccessOptions) => void
22
47
  getState: () => MIDIAccessState
23
48
  /** Snapshot for server side rendering. Always the initial state. */
24
49
  getServerState: () => MIDIAccessState
@@ -27,6 +52,28 @@ export interface MIDIAccessInstance {
27
52
  destroy: () => void
28
53
  }
29
54
 
55
+ /**
56
+ * Which of our errors a rejected `requestMIDIAccess()` amounts to.
57
+ *
58
+ * The spec names the reasons, and they need telling apart: a user who said no
59
+ * can say yes on a second ask, while a browser without the API never will.
60
+ */
61
+ function toError(reason: unknown): MIDIAccessError {
62
+ const name =
63
+ typeof reason === 'object' && reason !== null && 'name' in reason
64
+ ? String((reason as { name: unknown }).name)
65
+ : ''
66
+
67
+ if (name === 'SecurityError' || name === 'NotAllowedError') {
68
+ return PERMISSION_DENIED
69
+ }
70
+ if (name === 'NotSupportedError' || name === 'TypeError') {
71
+ return NOT_SUPPORTED
72
+ }
73
+ // AbortError, InvalidStateError, and anything a browser makes up.
74
+ return UNAVAILABLE
75
+ }
76
+
30
77
  /**
31
78
  * Request MIDI access in the browser.
32
79
  *
@@ -36,6 +83,8 @@ export interface MIDIAccessInstance {
36
83
  export function createMIDIAccess(): MIDIAccessInstance {
37
84
  let state = INITIAL_STATE
38
85
  let destroyed = false
86
+ let access: MIDIAccess | null = null
87
+ let requestGeneration = 0
39
88
  const listeners = new Set<() => void>()
40
89
 
41
90
  function setState(next: MIDIAccessState) {
@@ -43,22 +92,40 @@ export function createMIDIAccess(): MIDIAccessInstance {
43
92
  for (const listener of listeners) listener()
44
93
  }
45
94
 
46
- function request() {
95
+ /**
96
+ * A new array every time, so that `useSyncExternalStore` and its equivalents
97
+ * see the change. The identity of the state object is what they compare.
98
+ */
99
+ function readInputs() {
100
+ return access ? [...access.inputs.values()] : []
101
+ }
102
+
103
+ function handleStateChange(event?: Event) {
104
+ if (destroyed || !access) return
105
+ if (event && (event as MIDIConnectionEvent).port?.type === 'output') return
106
+ setState({ ...state, inputs: readInputs() })
107
+ }
108
+
109
+ function request(options: MIDIAccessOptions = {}) {
47
110
  if (destroyed) return
111
+ const generation = ++requestGeneration
48
112
  if (typeof navigator === 'undefined' || !navigator.requestMIDIAccess) {
49
113
  setState({ ...state, error: NOT_SUPPORTED })
50
114
  return
51
115
  }
52
- navigator
53
- .requestMIDIAccess()
54
- .then((access) => {
55
- if (destroyed) return
56
- setState({ midiAccess: access, error: null })
57
- })
58
- .catch(() => {
59
- if (destroyed) return
60
- setState({ ...state, error: PERMISSION_DENIED })
61
- })
116
+ navigator.requestMIDIAccess({ sysex: options.sysex ?? false }).then(
117
+ (granted) => {
118
+ if (destroyed || generation !== requestGeneration) return
119
+ access?.removeEventListener('statechange', handleStateChange)
120
+ access = granted
121
+ granted.addEventListener('statechange', handleStateChange)
122
+ setState({ midiAccess: granted, error: null, inputs: readInputs() })
123
+ },
124
+ (reason: unknown) => {
125
+ if (destroyed || generation !== requestGeneration) return
126
+ setState({ ...state, error: toError(reason) })
127
+ },
128
+ )
62
129
  }
63
130
 
64
131
  return {
@@ -73,6 +140,9 @@ export function createMIDIAccess(): MIDIAccessInstance {
73
140
  },
74
141
  destroy: () => {
75
142
  destroyed = true
143
+ requestGeneration += 1
144
+ access?.removeEventListener('statechange', handleStateChange)
145
+ access = null
76
146
  listeners.clear()
77
147
  },
78
148
  }
package/src/midi/input.ts CHANGED
@@ -1,43 +1,114 @@
1
- import { createMIDIMessage, type MIDIMessageInstance } from './message'
1
+ import { createMIDIMessage } from './message'
2
2
 
3
- const MIDI_EVENT_TO_NUMBER = {
4
- NOTE_ON: 0x90,
3
+ /** Status byte of each channel message, with the channel nibble cleared. */
4
+ const STATUS = {
5
5
  NOTE_OFF: 0x80,
6
+ NOTE_ON: 0x90,
7
+ AFTERTOUCH: 0xa0,
8
+ CONTROL_CHANGE: 0xb0,
9
+ PROGRAM_CHANGE: 0xc0,
10
+ CHANNEL_PRESSURE: 0xd0,
6
11
  PITCH_BEND: 0xe0,
7
12
  }
8
13
 
14
+ /**
15
+ * Centre of the 14-bit pitch bend range: no bend.
16
+ *
17
+ * The range is not symmetric — 0 is 8192 below centre and 16383 is 8191 above
18
+ * — so a wheel at rest reports exactly this rather than half of the maximum.
19
+ */
20
+ export const PITCH_BEND_CENTER = 8192
21
+
22
+ /**
23
+ * Every handler is given the channel last, as 0-15. MIDI channels are written
24
+ * 1-16 on hardware, so add one before showing it to anyone.
25
+ */
9
26
  export type MIDIInputHandlers = {
10
- onNoteOnEvent?: (note: number, velocity: number) => void
11
- onNoteOffEvent?: (note: number) => void
12
- onPitchBendEvent?: (msb: number, lsb: number) => void
27
+ onNoteOnEvent?: (note: number, velocity: number, channel: number) => void
28
+ onNoteOffEvent?: (note: number, channel: number) => void
29
+ /**
30
+ * The 14-bit bend, 0-16383, centred at {@link PITCH_BEND_CENTER}.
31
+ *
32
+ * The two data bytes are little-endian — the first carries the low 7 bits —
33
+ * which is the other way round from every other message.
34
+ */
35
+ onPitchBendEvent?: (value: number, channel: number) => void
36
+ /** `controller` is the CC number, `value` is 0-127. */
37
+ onControlChangeEvent?: (
38
+ controller: number,
39
+ value: number,
40
+ channel: number,
41
+ ) => void
42
+ onProgramChangeEvent?: (program: number, channel: number) => void
43
+ /** Pressure for one held note (polyphonic aftertouch). */
44
+ onAftertouchEvent?: (note: number, pressure: number, channel: number) => void
45
+ /** Pressure for the whole channel, sent by keyboards with one sensor. */
46
+ onChannelPressureEvent?: (pressure: number, channel: number) => void
13
47
  }
14
48
 
15
- export type MIDIInputInstance = MIDIMessageInstance
49
+ export interface MIDIInputInstance {
50
+ /** Replace the handlers, keeping the listeners in place. */
51
+ update: (handlers: MIDIInputHandlers) => void
52
+ destroy: () => void
53
+ }
16
54
 
17
55
  /**
18
- * Handle note on/off and pitch bend events. To be used with {@link createMIDIAccess}.
19
- * Internally uses {@link createMIDIMessage}.
56
+ * Handle the channel voice messages of every connected input. To be used with
57
+ * {@link createMIDIAccess}. Internally uses {@link createMIDIMessage}, so
58
+ * devices plugged in later are picked up.
59
+ *
60
+ * System messages (clock, sysex, and the rest of `0xf0`-`0xff`) are not
61
+ * decoded here; reach for {@link createMIDIMessage} for those.
20
62
  */
21
63
  export function createMIDIInput(
22
64
  midiAccess: MIDIAccess | null,
23
- { onNoteOnEvent, onNoteOffEvent, onPitchBendEvent }: MIDIInputHandlers,
65
+ handlers: MIDIInputHandlers,
24
66
  ): MIDIInputInstance {
25
- return createMIDIMessage(midiAccess, (event) => {
26
- if (!event.data) return
27
- // event.data[0] ... command
28
- // event.data[1] ... note, MSB (Most Significant Byte)
29
- // event.data[2] ... velocity, LSB (Least Significant Byte)
30
- const kind = event.data[0] & 0xf0
31
-
32
- if (
33
- kind == MIDI_EVENT_TO_NUMBER.NOTE_OFF ||
34
- (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON && event.data[2] == 0)
35
- ) {
36
- onNoteOffEvent?.(event.data[1])
37
- } else if (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON) {
38
- onNoteOnEvent?.(event.data[1], event.data[2])
39
- } else if (kind == MIDI_EVENT_TO_NUMBER.PITCH_BEND) {
40
- onPitchBendEvent?.(event.data[1], event.data[2])
67
+ let current = handlers
68
+
69
+ const message = createMIDIMessage(midiAccess, (event) => {
70
+ if (!event.data || event.data.length === 0) return
71
+
72
+ const status = event.data[0]
73
+ // 0xf0 and above are system messages, which carry no channel.
74
+ if (status >= 0xf0) return
75
+
76
+ const kind = status & 0xf0
77
+ const channel = status & 0x0f
78
+ const first = event.data[1] ?? 0
79
+ const second = event.data[2] ?? 0
80
+
81
+ switch (kind) {
82
+ case STATUS.NOTE_OFF:
83
+ current.onNoteOffEvent?.(first, channel)
84
+ break
85
+ case STATUS.NOTE_ON:
86
+ // A note on with zero velocity is how most devices say note off.
87
+ if (second === 0) current.onNoteOffEvent?.(first, channel)
88
+ else current.onNoteOnEvent?.(first, second, channel)
89
+ break
90
+ case STATUS.AFTERTOUCH:
91
+ current.onAftertouchEvent?.(first, second, channel)
92
+ break
93
+ case STATUS.CONTROL_CHANGE:
94
+ current.onControlChangeEvent?.(first, second, channel)
95
+ break
96
+ case STATUS.PROGRAM_CHANGE:
97
+ current.onProgramChangeEvent?.(first, channel)
98
+ break
99
+ case STATUS.CHANNEL_PRESSURE:
100
+ current.onChannelPressureEvent?.(first, channel)
101
+ break
102
+ case STATUS.PITCH_BEND:
103
+ current.onPitchBendEvent?.((second << 7) | first, channel)
104
+ break
41
105
  }
42
106
  })
107
+
108
+ return {
109
+ update: (next) => {
110
+ current = next
111
+ },
112
+ destroy: message.destroy,
113
+ }
43
114
  }
@@ -1,30 +1,59 @@
1
1
  export interface MIDIMessageInstance {
2
+ /** Replace the handler, keeping the listeners in place. */
3
+ update: (onMIDIMessage: (event: MIDIMessageEvent) => void) => void
2
4
  destroy: () => void
3
5
  }
4
6
 
5
7
  /**
6
8
  * Listen to raw `midimessage` events on every input of a MIDIAccess.
7
9
  *
10
+ * The set of inputs is followed rather than sampled: MIDIAccess fires
11
+ * `statechange` when a device is plugged in or unplugged, and the listeners
12
+ * move with it. A keyboard connected after access was granted works without
13
+ * the caller having to rebuild anything.
14
+ *
8
15
  * Use this when you need more detail than {@link createMIDIInput} provides.
9
16
  */
10
17
  export function createMIDIMessage(
11
18
  midiAccess: MIDIAccess | null,
12
19
  onMIDIMessage: (event: MIDIMessageEvent) => void,
13
20
  ): MIDIMessageInstance {
14
- if (!midiAccess) {
15
- return { destroy: () => {} }
16
- }
21
+ let handler = onMIDIMessage
22
+
23
+ /** Inputs this instance currently listens on. */
24
+ const attached = new Set<MIDIInput>()
25
+
26
+ const listener = (event: Event) => handler(event as MIDIMessageEvent)
17
27
 
18
- const inputs = [...midiAccess.inputs.values()]
19
- for (const input of inputs) {
20
- input.addEventListener('midimessage', onMIDIMessage)
28
+ function sync() {
29
+ if (!midiAccess) return
30
+ const connected = new Set(midiAccess.inputs.values())
31
+
32
+ for (const input of connected) {
33
+ if (attached.has(input)) continue
34
+ input.addEventListener('midimessage', listener)
35
+ attached.add(input)
36
+ }
37
+ for (const input of [...attached]) {
38
+ if (connected.has(input)) continue
39
+ input.removeEventListener('midimessage', listener)
40
+ attached.delete(input)
41
+ }
21
42
  }
22
43
 
44
+ sync()
45
+ midiAccess?.addEventListener('statechange', sync)
46
+
23
47
  return {
48
+ update: (next) => {
49
+ handler = next
50
+ },
24
51
  destroy: () => {
25
- for (const input of inputs) {
26
- input.removeEventListener('midimessage', onMIDIMessage)
52
+ midiAccess?.removeEventListener('statechange', sync)
53
+ for (const input of attached) {
54
+ input.removeEventListener('midimessage', listener)
27
55
  }
56
+ attached.clear()
28
57
  },
29
58
  }
30
59
  }
@@ -1,7 +1,7 @@
1
- import { noteAt, type PianoLayout } from '@tremolo-ui/functions'
2
-
3
1
  import { createDrag } from '../pointer/drag'
4
2
 
3
+ import { noteAt, type PianoLayout } from './layout'
4
+
5
5
  /**
6
6
  * What asked for a note to sound.
7
7
  *
@@ -99,6 +99,9 @@ export function createPianoInput(
99
99
  velocity,
100
100
  }: { source?: NoteSource; velocity?: number } = {},
101
101
  ) {
102
+ if (!Number.isInteger(note) || note < 0 || note > 127) {
103
+ throw new RangeError('note: requirements: an integer from 0 to 127')
104
+ }
102
105
  if (note > (opts.midiMax ?? 127)) return
103
106
 
104
107
  const sources = held.get(note)
@@ -117,6 +120,9 @@ export function createPianoInput(
117
120
  note: number,
118
121
  { source = DEFAULT_SOURCE }: { source?: NoteSource } = {},
119
122
  ) {
123
+ if (!Number.isInteger(note) || note < 0 || note > 127) {
124
+ throw new RangeError('note: requirements: an integer from 0 to 127')
125
+ }
120
126
  const sources = held.get(note)
121
127
  if (!sources) return
122
128
 
@@ -142,7 +148,7 @@ export function createPianoInput(
142
148
 
143
149
  if (previous !== undefined) noteOff(previous, { source })
144
150
 
145
- if (note === null) {
151
+ if (note === null || note > (opts.midiMax ?? 127)) {
146
152
  pointerNotes.delete(pointerId)
147
153
  } else {
148
154
  pointerNotes.set(pointerId, note)
@@ -166,6 +172,19 @@ export function createPianoInput(
166
172
  return {
167
173
  update: (next) => {
168
174
  opts = { ...opts, ...next }
175
+
176
+ const midiMax = opts.midiMax ?? 127
177
+ const stoppedNotes = activeNotes().filter((note) => note > midiMax)
178
+ if (stoppedNotes.length === 0) return
179
+
180
+ for (const note of stoppedNotes) {
181
+ held.delete(note)
182
+ opts.onStopNote?.(note)
183
+ }
184
+ for (const [pointerId, note] of pointerNotes) {
185
+ if (note > midiMax) pointerNotes.delete(pointerId)
186
+ }
187
+ opts.onActiveNotesChange?.(activeNotes())
169
188
  },
170
189
  noteOn,
171
190
  noteOff,
@@ -174,12 +193,13 @@ export function createPianoInput(
174
193
  drag.destroy()
175
194
  // Anything still held is released, so a caller that mirrors these
176
195
  // callbacks into a synth is not left with a stuck note.
177
- for (const note of activeNotes()) {
196
+ const notes = activeNotes()
197
+ for (const note of notes) {
178
198
  held.delete(note)
179
199
  opts.onStopNote?.(note)
180
200
  }
181
201
  pointerNotes.clear()
182
- opts.onActiveNotesChange?.([])
202
+ if (notes.length > 0) opts.onActiveNotesChange?.([])
183
203
  },
184
204
  }
185
205
  }