@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.
- package/dist/index.cjs +842 -101
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +490 -47
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +490 -47
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +829 -102
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/canvas/animation.ts +49 -31
- package/src/canvas/context.ts +32 -5
- package/src/file/accept.ts +58 -0
- package/src/file/drop-zone.ts +202 -0
- package/src/index.ts +39 -2
- package/src/input/apply-delta.ts +73 -0
- package/src/input/modifiers.ts +121 -0
- package/src/midi/access.ts +83 -13
- package/src/midi/input.ts +97 -26
- package/src/midi/message.ts +37 -8
- package/src/piano/{input.ts → index.ts} +25 -5
- package/src/piano/layout.ts +183 -0
- package/src/pointer/{dragValue.ts → drag-value.ts} +155 -19
- package/src/pointer/drag.ts +215 -20
- package/src/pointer/wheel.ts +4 -2
- package/src/selection/box.ts +136 -0
|
@@ -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
|
+
}
|
package/src/midi/access.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
1
|
+
import { createMIDIMessage } from './message'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
65
|
+
handlers: MIDIInputHandlers,
|
|
24
66
|
): MIDIInputInstance {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
}
|
package/src/midi/message.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
}
|