228 | ) => React.createElement('midi', { sysex: false, ...props });
229 |
230 | type MidiExtension = Omit<
231 | MidiProps,
232 | 'pattern' | 'value' | 'unmountValue' | 'onInput' | 'onChange'
233 | > & {
234 | channel: number;
235 | value?: number;
236 | unmountValue?: number;
237 | onInput?: (value: number) => void;
238 | onChange?: (value: number) => void;
239 | } & P;
240 |
241 | Midi.NoteOn = ({
242 | channel,
243 | note,
244 | value,
245 | unmountValue,
246 | onInput,
247 | onChange,
248 | ...rest
249 | }: MidiExtension<{ note: number }>) => (
250 | onInput(data2) : undefined}
257 | onChange={onChange ? ({ data2 }) => onChange(data2) : undefined}
258 | {...rest}
259 | />
260 | );
261 |
262 | Midi.NoteOff = ({
263 | channel,
264 | note,
265 | value,
266 | unmountValue,
267 | onInput,
268 | onChange,
269 | ...rest
270 | }: MidiExtension<{ note: number }>) => (
271 | onInput(data2) : undefined}
278 | onChange={onChange ? ({ data2 }) => onChange(data2) : undefined}
279 | {...rest}
280 | />
281 | );
282 |
283 | Midi.KeyPressure = ({
284 | channel,
285 | note,
286 | value,
287 | unmountValue,
288 | onInput,
289 | onChange,
290 | ...rest
291 | }: MidiExtension<{ note: number }>) => (
292 | onInput(data2) : undefined}
299 | onChange={onChange ? ({ data2 }) => onChange(data2) : undefined}
300 | {...rest}
301 | />
302 | );
303 |
304 | Midi.CC = ({
305 | channel,
306 | control,
307 | value,
308 | unmountValue,
309 | onInput,
310 | onChange,
311 | ...rest
312 | }: MidiExtension<{ control: number }>) => (
313 | onInput(data2) : undefined}
320 | onChange={onChange ? ({ data2 }) => onChange(data2) : undefined}
321 | {...rest}
322 | />
323 | );
324 |
325 | Midi.ProgramChange = ({
326 | channel,
327 | program,
328 | value,
329 | unmountValue,
330 | onInput,
331 | onChange,
332 | ...rest
333 | }: MidiExtension<{ program: number }>) => (
334 | onInput(data1) : undefined}
341 | onChange={onChange ? ({ data1 }) => onChange(data1) : undefined}
342 | {...rest}
343 | />
344 | );
345 |
346 | Midi.ChannelPressure = ({
347 | channel,
348 | value,
349 | unmountValue,
350 | onInput,
351 | onChange,
352 | ...rest
353 | }: MidiExtension) => (
354 | onInput(data2) : undefined}
361 | onChange={onChange ? ({ data2 }) => onChange(data2) : undefined}
362 | {...rest}
363 | />
364 | );
365 |
366 | Midi.PitchBend = ({
367 | channel,
368 | value,
369 | unmountValue,
370 | onInput,
371 | onChange,
372 | ...rest
373 | }: MidiExtension) => (
374 | > 7) & 0x7f }
379 | : undefined
380 | }
381 | unmountValue={
382 | value !== undefined
383 | ? { data1: value & 0x7f, data2: (value >> 7) & 0x7f }
384 | : undefined
385 | }
386 | onInput={
387 | onInput ? ({ data1, data2 }) => onInput(128 * data2 + data1) : undefined
388 | }
389 | onChange={
390 | onChange ? ({ data1, data2 }) => onChange(128 * data2 + data1) : undefined
391 | }
392 | {...rest}
393 | />
394 | );
395 |
396 | export type SysexProps = {
397 | label?: string;
398 | port?: number;
399 | pattern?: string;
400 | defaultValue?: string;
401 | value?: string;
402 | unmountValue?: string;
403 | onInput?: (data: string) => void;
404 | onChange?: (data: string) => void;
405 | cacheOnInput?: boolean;
406 | cacheOnOutput?: boolean;
407 | urgent?: boolean;
408 | };
409 |
410 | Midi.Sysex = (props: SysexProps) =>
411 | React.createElement('midi', { sysex: true, ...props });
412 |
--------------------------------------------------------------------------------
/src/controls/base.ts:
--------------------------------------------------------------------------------
1 | import { Session } from '../session';
2 |
3 | export abstract class ControlInstanceBase {
4 | props: P;
5 |
6 | constructor(initialProps: P) {
7 | this.props = initialProps;
8 | }
9 |
10 | abstract commitUpdate(updatePayload: U, oldProps: P, newProps: P): void;
11 | }
12 |
13 | export abstract class ControlConnectorBase<
14 | T extends ControlInstanceBase = ControlInstanceBase
15 | > {
16 | session: Session;
17 | instances: T[] = [];
18 |
19 | constructor(session: Session) {
20 | this.session = session;
21 | }
22 |
23 | abstract addInstance(instance: T): void;
24 | abstract removeInstance(instance: T): void;
25 | }
26 |
--------------------------------------------------------------------------------
/src/controls/midi/index.ts:
--------------------------------------------------------------------------------
1 | export * from './midi-message';
2 | export * from './sysex-message';
3 | export * from './midi-out-proxy';
4 | export * from './midi-node';
5 |
--------------------------------------------------------------------------------
/src/controls/midi/midi-connector.ts:
--------------------------------------------------------------------------------
1 | import { Session } from '../../session';
2 | import { ControlConnectorBase } from '../base';
3 | import { deepEqual } from './utils';
4 | import { MidiInstance } from './midi-instance';
5 | import { MidiMessage } from './midi-message';
6 | import { MidiNode } from './midi-node';
7 | import { MidiOutProxy } from './midi-out-proxy';
8 | import { SysexMessage } from './sysex-message';
9 | import { MidiObjectPatternByte } from '../../components/midi';
10 |
11 | export type MidiInstanceUpdatePayload = null | {
12 | midiNodeToConnect: MidiNode | null;
13 | midiOutput:
14 | | {
15 | label?: string;
16 | status: number;
17 | data1: number;
18 | data2: number;
19 | }
20 | | {
21 | label?: string;
22 | data: string;
23 | }
24 | | null;
25 | inputHandlerChanged: boolean;
26 | };
27 |
28 | const getMidiMessageByteFromPatternAndValueBytes = (
29 | patternByte?: MidiObjectPatternByte,
30 | valueByte?: MidiObjectPatternByte
31 | ): number => {
32 | if (typeof patternByte === 'number') {
33 | return patternByte;
34 | } else if (typeof valueByte === 'number') {
35 | return valueByte;
36 | } else if (
37 | patternByte &&
38 | valueByte &&
39 | 'msn' in patternByte &&
40 | 'lsn' in valueByte
41 | ) {
42 | return patternByte.msn * 16 + valueByte.lsn;
43 | } else if (
44 | patternByte &&
45 | valueByte &&
46 | 'msn' in valueByte &&
47 | 'lsn' in patternByte
48 | ) {
49 | return valueByte.msn * 16 + patternByte.lsn;
50 | } else {
51 | throw new Error(
52 | 'Something went wrong here. This error should never be reached because Midi match patterns and values should be complimentary and produce a full midi message when both exist'
53 | );
54 | }
55 | };
56 |
57 | const getMidiOutput = ({
58 | midiNode,
59 | value,
60 | label,
61 | }: {
62 | midiNode?: MidiNode;
63 | value?: MidiInstance['props']['value'];
64 | label?: string;
65 | }) => {
66 | let result: NonNullable['midiOutput'] = null;
67 | if (value) {
68 | if (midiNode) {
69 | let output:
70 | | {
71 | label?: string;
72 | port?: number | undefined;
73 | status: number;
74 | data1: number;
75 | data2: number;
76 | }
77 | | {
78 | label?: string;
79 | port?: number | undefined;
80 | data: string;
81 | };
82 |
83 | if (typeof value === 'string') {
84 | output = {
85 | label,
86 | port: midiNode.port,
87 | data: value,
88 | };
89 | } else {
90 | output = {
91 | label,
92 | port: midiNode.port,
93 | status: getMidiMessageByteFromPatternAndValueBytes(
94 | midiNode.pattern?.status,
95 | value.status
96 | ),
97 | data1: getMidiMessageByteFromPatternAndValueBytes(
98 | midiNode.pattern?.data1,
99 | value.data1
100 | ),
101 | data2: getMidiMessageByteFromPatternAndValueBytes(
102 | midiNode.pattern?.data2,
103 | value.data2
104 | ),
105 | };
106 | }
107 |
108 | if (midiNode.shouldOutputMessage(output)) {
109 | result = output;
110 | }
111 | }
112 | }
113 | return result;
114 | };
115 |
116 | export class MidiConnector extends ControlConnectorBase {
117 | activeMidiNodes = new Set();
118 | midiNodeMap = new Map();
119 |
120 | midiOut: MidiOutProxy;
121 |
122 | constructor(session: Session) {
123 | super(session);
124 | // midi output
125 | this.midiOut = new MidiOutProxy(session);
126 | // midi input
127 | session.on('init', () => {
128 | const midiInPorts = this.midiInPorts;
129 | for (let port = 0; port < midiInPorts.length; port += 1) {
130 | midiInPorts[port].setMidiCallback(
131 | (status: number, data1: number, data2: number) => {
132 | this.handleMidiInput(
133 | new MidiMessage({ port, status, data1, data2 })
134 | );
135 | }
136 | );
137 | midiInPorts[port].setSysexCallback((data: string) => {
138 | this.handleMidiInput(new SysexMessage({ port, data }));
139 | });
140 | }
141 | });
142 | // cleanup on exit
143 | session.on('exit', () => this.clearInstances());
144 | }
145 |
146 | private disconnectInstanceFromNode(
147 | instance: MidiInstance,
148 | synchronous?: boolean
149 | ) {
150 | const midiNode = this.midiNodeMap.get(instance);
151 | if (midiNode) {
152 | // remove from midiNodes and midiNodeMap
153 | midiNode.instances.splice(midiNode.instances.indexOf(instance), 1);
154 | this.midiNodeMap.delete(instance);
155 | // send unmount value if no instances using node
156 | if (midiNode.instances.length === 0) {
157 | const unmountOutput = getMidiOutput({
158 | label: instance.props.label,
159 | midiNode,
160 | value: instance.props.unmountValue,
161 | });
162 | if (unmountOutput) {
163 | const sendUnmountOutput = () => {
164 | // for async, check if node instances are still 0
165 | if (midiNode.instances.length === 0) {
166 | if ('data' in unmountOutput) {
167 | this.midiOut.sendSysex(unmountOutput);
168 | } else {
169 | this.midiOut.sendMidi(unmountOutput);
170 | }
171 | midiNode.onIO('output', unmountOutput);
172 | }
173 | };
174 |
175 | if (synchronous) {
176 | // synchronous unmount is use on exit, otherwise will exit before async is called
177 | sendUnmountOutput();
178 | } else {
179 | // allows replacement instances (if any) to connect so we don't send unmount messages unnecessarily
180 | setTimeout(sendUnmountOutput, 0);
181 | }
182 | }
183 | }
184 | }
185 | }
186 |
187 | private connectInstanceToNode(instance: MidiInstance, midiNode: MidiNode) {
188 | const existingMidiNode = this.midiNodeMap.get(instance);
189 | // no change? early exit
190 | if (midiNode === existingMidiNode) return;
191 | // is change and has existing node? disconnect from existing node
192 | if (existingMidiNode) this.disconnectInstanceFromNode(instance);
193 | // update instance ref on node
194 | if (!midiNode.instances.includes(instance)) {
195 | midiNode.instances.push(instance);
196 | }
197 | // update midiNodeMap
198 | this.midiNodeMap.set(instance, midiNode);
199 | // update midiNodes list
200 | if (!this.activeMidiNodes.has(midiNode)) {
201 | this.activeMidiNodes.add(midiNode);
202 | }
203 | }
204 |
205 | addInstance(instance: MidiInstance): void {
206 | if (!this.instances.includes(instance)) {
207 | this.instances.push(instance);
208 | instance.connector = this;
209 |
210 | this.commitInstanceUpdate(
211 | instance,
212 | this.prepareInstanceUpdate(
213 | instance,
214 | instance.props,
215 | instance.props,
216 | true
217 | ),
218 | instance.props,
219 | instance.props
220 | );
221 | }
222 | }
223 |
224 | prepareInstanceUpdate(
225 | instance: MidiInstance,
226 | oldProps: MidiInstance['props'],
227 | newProps: MidiInstance['props'],
228 | isMount?: boolean
229 | ): MidiInstanceUpdatePayload {
230 | let isMidiNodeNew = false;
231 | let midiNodeToConnect: MidiNode | null = null;
232 | // handle modified pattern
233 | if (
234 | isMount ||
235 | newProps.port !== oldProps.port ||
236 | // TODO: look into more perf friendly way to solve this in the midi node
237 | (newProps.pattern === undefined &&
238 | !deepEqual(newProps.value, oldProps.value)) ||
239 | !deepEqual(newProps.pattern, oldProps.pattern)
240 | ) {
241 | // create new node
242 | let midiNodeForNewProps = new MidiNode(newProps);
243 | const currentMidiNode = this.midiNodeMap.get(instance);
244 | if (
245 | !currentMidiNode ||
246 | midiNodeForNewProps.string !== currentMidiNode.string ||
247 | midiNodeForNewProps.cacheOnInput !== currentMidiNode.cacheOnInput ||
248 | midiNodeForNewProps.cacheOnOutput !== currentMidiNode.cacheOnOutput
249 | // TODO: check unmountValue is consistent here as well (needs to be added to MidiNode)
250 | ) {
251 | // check for existing conflicting node
252 | for (const existingMidiNode of this.activeMidiNodes) {
253 | if (midiNodeForNewProps.conflictsWith(existingMidiNode)) {
254 | if (
255 | midiNodeForNewProps.string === existingMidiNode.string &&
256 | midiNodeForNewProps.cacheOnInput ===
257 | existingMidiNode.cacheOnInput &&
258 | midiNodeForNewProps.cacheOnOutput ===
259 | existingMidiNode.cacheOnOutput
260 | ) {
261 | midiNodeToConnect = existingMidiNode;
262 | break;
263 | } else {
264 | throw new Error(
265 | `MidiNode conflicts with existing MidiNode ({ existing: ${existingMidiNode.string}, new: ${midiNodeForNewProps.string} })`
266 | );
267 | }
268 | }
269 | }
270 |
271 | if (!midiNodeToConnect) {
272 | // set new midi node for update and add to prepared list (to check if other components conflict in the same batch)
273 | midiNodeToConnect = midiNodeForNewProps;
274 | this.activeMidiNodes.add(midiNodeToConnect);
275 | isMidiNodeNew = true;
276 | }
277 | }
278 | }
279 |
280 | // handle modified value
281 | const midiOutput = getMidiOutput({
282 | label: newProps.label,
283 | midiNode: midiNodeToConnect || this.midiNodeMap.get(instance),
284 | value:
285 | isMount && isMidiNodeNew
286 | ? newProps.value || newProps.defaultValue // send default value if any on initial mount
287 | : newProps.value,
288 | });
289 |
290 | // handle modified onInput handler
291 | let inputHandlerChanged = false;
292 | if (isMount || newProps.onInput !== oldProps.onInput) {
293 | inputHandlerChanged = true;
294 | }
295 |
296 | return midiNodeToConnect === null &&
297 | midiOutput === null &&
298 | !inputHandlerChanged
299 | ? null
300 | : {
301 | midiNodeToConnect: midiNodeToConnect,
302 | midiOutput,
303 | inputHandlerChanged,
304 | };
305 | }
306 |
307 | commitInstanceUpdate(
308 | instance: MidiInstance,
309 | updatePayload: MidiInstanceUpdatePayload,
310 | oldProps: MidiInstance['props'],
311 | newProps: MidiInstance['props']
312 | ): void {
313 | if (updatePayload === null) return;
314 | const { midiNodeToConnect, midiOutput } = updatePayload;
315 |
316 | // handle modified pattern
317 | if (midiNodeToConnect !== null) {
318 | this.connectInstanceToNode(instance, midiNodeToConnect);
319 | }
320 |
321 | // handle modified value
322 | if (midiOutput) {
323 | const midiNode = this.midiNodeMap.get(instance)!;
324 | if ('data' in midiOutput) {
325 | this.midiOut.sendSysex(midiOutput);
326 | } else {
327 | this.midiOut.sendMidi(midiOutput);
328 | }
329 | midiNode.onIO('output', midiOutput);
330 |
331 | // output here means the midi value changed and onChange should be called
332 | midiNode.instances.forEach((instance) =>
333 | instance.props.onChange?.(
334 | 'data' in midiOutput ? midiOutput.data : midiOutput
335 | )
336 | );
337 | }
338 |
339 | instance.props = newProps;
340 | }
341 |
342 | removeInstance(instance: MidiInstance, synchronous?: boolean): void {
343 | this.disconnectInstanceFromNode(instance, synchronous);
344 | }
345 |
346 | clearInstances() {
347 | this.instances.forEach((instance) => this.removeInstance(instance, true));
348 | host.requestFlush();
349 | }
350 |
351 | /** The midi in ports available to the session */
352 | get midiInPorts(): API.MidiIn[] {
353 | const midiInPorts = [];
354 | for (let i = 0; true; i += 1) {
355 | try {
356 | midiInPorts[i] = host.getMidiInPort(i);
357 | } catch (error) {
358 | break;
359 | }
360 | }
361 | return midiInPorts;
362 | }
363 |
364 | /** Handle midi input, routing it to the correct control object */
365 | handleMidiInput(message: MidiMessage | SysexMessage) {
366 | const messageType = message instanceof MidiMessage ? '[MIDI] ' : '[SYSEX]';
367 |
368 | let node: MidiNode | undefined;
369 | for (const n of this.activeMidiNodes) {
370 | if (n.test(message)) {
371 | node = n;
372 | node.onIO('input', message);
373 | break;
374 | }
375 | }
376 |
377 | // create local copy of instance list in case it changes when running onInput
378 | const instances = [...(node?.instances || [])];
379 | const labels: string[] = [];
380 | instances.forEach((instance) => {
381 | // check that instance is still in instance list before calling (could be unmounted)
382 | if (node?.instances.includes(instance)) {
383 | instance.props.onInput &&
384 | instance.props.onInput(
385 | message instanceof SysexMessage ? message.data : message
386 | );
387 | instance.props.label && labels.push(instance.props.label);
388 | }
389 | });
390 |
391 | if (message instanceof SysexMessage) {
392 | console.log(
393 | `${messageType} IN ${message.port} ==> ${
394 | labels[0] ? `"${labels.join(',')}" ` : ''
395 | }${message.data}`
396 | );
397 | } else {
398 | console.log(
399 | `${messageType} IN ${message.port} ==> ${message.shortHex}${
400 | labels[0] ? ` "${labels.join(',')}"` : ''
401 | }`
402 | );
403 | }
404 | }
405 | }
406 |
--------------------------------------------------------------------------------
/src/controls/midi/midi-instance.ts:
--------------------------------------------------------------------------------
1 | import { MidiHexPattern, MidiObjectPattern } from '../../components/midi';
2 | import { ControlInstanceBase } from '../base';
3 | import { MidiInstanceUpdatePayload, MidiConnector } from './midi-connector';
4 |
5 | export type MidiInstanceProps = (
6 | | {
7 | sysex: true;
8 | label?: string;
9 | port?: number;
10 | pattern?: string;
11 | defaultValue?: string;
12 | value?: string;
13 | unmountValue?: string;
14 | }
15 | | {
16 | sysex: false;
17 | label?: string;
18 | port?: number;
19 | pattern?: MidiHexPattern | MidiObjectPattern;
20 | defaultValue?: MidiObjectPattern;
21 | value?: MidiObjectPattern;
22 | unmountValue?: MidiObjectPattern;
23 | }
24 | ) & {
25 | onInput?: (message: MidiObjectPattern | string) => void;
26 | onChange?: (message: MidiObjectPattern | string) => void;
27 | cacheOnInput?: boolean;
28 | cacheOnOutput?: boolean;
29 | urgent?: boolean;
30 | };
31 |
32 | export class MidiInstance extends ControlInstanceBase<
33 | MidiInstanceProps,
34 | MidiInstanceUpdatePayload
35 | > {
36 | // connector is null until control has been added to the session
37 | connector: MidiConnector | null = null;
38 |
39 | commitUpdate(
40 | updatePayload: MidiInstanceUpdatePayload,
41 | oldProps: MidiInstanceProps,
42 | newProps: MidiInstanceProps
43 | ) {
44 | this.connector?.commitInstanceUpdate(
45 | this,
46 | updatePayload,
47 | oldProps,
48 | newProps
49 | );
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/controls/midi/midi-message.ts:
--------------------------------------------------------------------------------
1 | export interface SimpleMidiMessage {
2 | port: number;
3 | status: number;
4 | data1: number;
5 | data2: number;
6 | }
7 |
8 | export interface MidiMessageConstructor {
9 | port?: number;
10 | status: number;
11 | data1: number;
12 | data2: number;
13 | urgent?: boolean;
14 | }
15 |
16 | export class MidiMessage implements SimpleMidiMessage {
17 | port: number;
18 | status: number;
19 | data1: number;
20 | data2: number;
21 | urgent: boolean;
22 | hex: string;
23 |
24 | constructor({
25 | port = 0,
26 | status,
27 | data1,
28 | data2,
29 | urgent = false,
30 | }: MidiMessageConstructor) {
31 | this.port = port;
32 | this.status = status;
33 | this.data1 = data1;
34 | this.data2 = data2;
35 | this.urgent = urgent;
36 | this.hex = [port, status, data1, data2]
37 | .map((midiByte) => {
38 | let hexByteString = midiByte.toString(16).toUpperCase();
39 | if (hexByteString.length === 1) hexByteString = `0${hexByteString}`;
40 | return hexByteString;
41 | })
42 | .join('');
43 | }
44 |
45 | get shortHex() {
46 | return this.hex.slice(2);
47 | }
48 |
49 | get channel() {
50 | return this.status & 0xf;
51 | }
52 |
53 | get pitchBendValue() {
54 | return (this.data2 << 7) | this.data1;
55 | }
56 |
57 | get isNote() {
58 | return (this.status & 0xf0) === 0x80 || (this.status & 0xf0) === 0x90;
59 | }
60 |
61 | get isNoteOff() {
62 | return (
63 | (this.status & 0xf0) === 0x80 ||
64 | ((this.status & 0xf0) === 0x90 && this.data2 === 0)
65 | );
66 | }
67 |
68 | get isNoteOn() {
69 | return (this.status & 0xf0) === 0x90;
70 | }
71 |
72 | get isKeyPressure() {
73 | return (this.status & 0xf0) === 0xa0;
74 | }
75 |
76 | get isControlChange() {
77 | return (this.status & 0xf0) === 0xb0;
78 | }
79 |
80 | get isProgramChange() {
81 | return (this.status & 0xf0) === 0xc0;
82 | }
83 |
84 | get isChannelPressure() {
85 | return (this.status & 0xf0) === 0xd0;
86 | }
87 |
88 | get isPitchBend() {
89 | return (this.status & 0xf0) === 0xe0;
90 | }
91 |
92 | get isMTCQuarterFrame() {
93 | return this.status === 0xf1;
94 | }
95 |
96 | get isSongPositionPointer() {
97 | return this.status === 0xf2;
98 | }
99 |
100 | get isSongSelect() {
101 | return this.status === 0xf3;
102 | }
103 |
104 | get isTuneRequest() {
105 | return this.status === 0xf6;
106 | }
107 |
108 | get isTimingClock() {
109 | return this.status === 0xf8;
110 | }
111 |
112 | get isMIDIStart() {
113 | return this.status === 0xfa;
114 | }
115 |
116 | get isMIDIContinue() {
117 | return this.status === 0xfb;
118 | }
119 |
120 | get isMidiStop() {
121 | return this.status === 0xfc;
122 | }
123 |
124 | get isActiveSensing() {
125 | return this.status === 0xfe;
126 | }
127 |
128 | get isSystemReset() {
129 | return this.status === 0xff;
130 | }
131 |
132 | toString() {
133 | return this.hex;
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/controls/midi/midi-node.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DataHexPatternByte,
3 | MidiObjectPattern,
4 | MidiObjectPatternByte,
5 | StatusHexPatternByte,
6 | } from '../../components/midi';
7 | import { MidiInstance, MidiInstanceProps } from './midi-instance';
8 | import { deepEqual } from './utils';
9 |
10 | function isStatusHexPatternByte(byte: string): byte is StatusHexPatternByte {
11 | return /^[8-9A-F?][0-9A-F?]$/.test(byte);
12 | }
13 |
14 | function isDataHexPatternByte(byte: string): byte is DataHexPatternByte {
15 | return /^[0-7?][0-9A-F?]$/.test(byte);
16 | }
17 |
18 | function hexToObjectPatternByte(
19 | byte: StatusHexPatternByte | DataHexPatternByte
20 | ): MidiObjectPatternByte | undefined {
21 | if (/^[0-9A-F]{2,}$/.test(byte)) {
22 | return parseInt(byte, 16);
23 | } else if (/^[0-9A-F]\?$/.test(byte)) {
24 | return { msn: parseInt(byte[0], 16) };
25 | } else if (/^\?[0-9A-F]$/.test(byte)) {
26 | return { lsn: parseInt(byte[1], 16) };
27 | } else if (/^\?\?$/.test(byte)) {
28 | return undefined;
29 | } else {
30 | throw new Error(`Invalid hex pattern byte: "${byte}"`);
31 | }
32 | }
33 |
34 | function hexToObjectPattern(hexPattern: string): MidiObjectPattern {
35 | if (hexPattern.length !== 6) {
36 | throw new Error(`Invalid hex pattern length (> 6): "${hexPattern}"`);
37 | }
38 |
39 | const result: MidiObjectPattern = {};
40 |
41 | const statusByte = hexPattern.slice(0, 2);
42 | const data1Byte = hexPattern.slice(2, 4);
43 | const data2Byte = hexPattern.slice(4, 6);
44 |
45 | if (
46 | !isStatusHexPatternByte(statusByte) ||
47 | !isDataHexPatternByte(data1Byte) ||
48 | !isDataHexPatternByte(data2Byte)
49 | ) {
50 | throw new Error(`Invalid hex pattern: "${hexPattern}"`);
51 | }
52 |
53 | const status = hexToObjectPatternByte(statusByte);
54 | if (status) result.status = status;
55 |
56 | const data1 = hexToObjectPatternByte(data1Byte);
57 | if (data1) result.data1 = data1;
58 |
59 | const data2 = hexToObjectPatternByte(data2Byte);
60 | if (data2) result.data2 = data2;
61 |
62 | return result;
63 | }
64 |
65 | const getPatternStringFromMidiMessage = (
66 | message: { port?: number } & (MidiObjectPattern | { data: string })
67 | ) => {
68 | if ('data' in message) {
69 | const { port = 0, data } = message;
70 | return `${port.toString(16).toUpperCase()}${data}`;
71 | } else {
72 | const { port = 0, status, data1, data2 } = message;
73 | return [port, status, data1, data2]
74 | .map((byte) => {
75 | if (byte === undefined) {
76 | return '??';
77 | } else if (typeof byte === 'number') {
78 | let hexByteString = byte.toString(16).toUpperCase();
79 | if (hexByteString.length === 1) hexByteString = `0${hexByteString}`;
80 | return hexByteString;
81 | } else {
82 | return `${'msn' in byte ? byte.msn.toString(16) : '?'}${
83 | 'lsn' in byte ? byte.lsn.toString(16) : '?'
84 | }`;
85 | }
86 | })
87 | .join('');
88 | }
89 | };
90 |
91 | export class MidiNode {
92 | sysex: boolean;
93 | label?: string;
94 | port?: number;
95 | pattern?: MidiObjectPattern;
96 |
97 | cacheOnInput: boolean;
98 | cacheOnOutput: boolean;
99 | cachedValue?:
100 | | {
101 | port: number;
102 | status: number;
103 | data1: number;
104 | data2: number;
105 | }
106 | | { port: number; data: string };
107 |
108 | string: string;
109 | regex: RegExp;
110 |
111 | instances: MidiInstance[] = [];
112 |
113 | constructor({
114 | port = 0,
115 | sysex,
116 | pattern,
117 | value,
118 | cacheOnInput,
119 | cacheOnOutput,
120 | }: MidiInstanceProps) {
121 | this.sysex = sysex;
122 | this.port = port;
123 | this.cacheOnInput = !!cacheOnInput;
124 | this.cacheOnOutput = !!cacheOnOutput;
125 |
126 | if (sysex) {
127 | if (pattern) {
128 | if (typeof pattern !== 'string') {
129 | throw new Error('Invalid sysex MidiNode');
130 | }
131 | this.string = getPatternStringFromMidiMessage({ port, data: pattern });
132 | } else if (value) {
133 | this.string = getPatternStringFromMidiMessage({ port, data: value });
134 | } else {
135 | throw new Error('MidiNode should have a pattern or a value.');
136 | }
137 | } else if (pattern) {
138 | // convert string pattern to object pattern
139 | if (typeof pattern === 'string') {
140 | pattern = hexToObjectPattern(pattern);
141 | }
142 |
143 | this.pattern = pattern;
144 |
145 | this.string = getPatternStringFromMidiMessage({
146 | port,
147 | ...pattern,
148 | });
149 | } else if (value) {
150 | this.pattern = value;
151 | this.string = getPatternStringFromMidiMessage({
152 | port,
153 | ...value,
154 | });
155 | } else {
156 | throw new Error('MidiNode should have a pattern or a value.');
157 | }
158 |
159 | this.regex = new RegExp(`^${this.string.slice().replace(/\?/g, '.')}$`);
160 | }
161 |
162 | toString() {
163 | return this.string;
164 | }
165 |
166 | conflictsWith({ string: stringB, regex: regexB }: MidiNode) {
167 | const { string: stringA, regex: regexA } = this;
168 | return regexA.test(stringB) || regexB.test(stringA);
169 | }
170 |
171 | test(
172 | message:
173 | | {
174 | port?: number;
175 | status: number;
176 | data1: number;
177 | data2: number;
178 | }
179 | | {
180 | port?: number;
181 | data: string;
182 | }
183 | ) {
184 | const testString = getPatternStringFromMidiMessage(message);
185 |
186 | return this.regex.test(testString);
187 | }
188 |
189 | handleCachedValueChange() {
190 | this.instances.forEach((instance) => {
191 | if (this.cachedValue && instance.props.onChange) {
192 | instance.props.onChange(
193 | 'data' in this.cachedValue ? this.cachedValue.data : this.cachedValue
194 | );
195 | }
196 | });
197 | }
198 |
199 | onIO(
200 | type: 'input' | 'output',
201 | {
202 | port = 0,
203 | ...rest
204 | }:
205 | | {
206 | port?: number;
207 | status: number;
208 | data1: number;
209 | data2: number;
210 | }
211 | | { port?: number; data: string }
212 | ) {
213 | let message:
214 | | {
215 | port: number;
216 | status: number;
217 | data1: number;
218 | data2: number;
219 | }
220 | | { port: number; data: string };
221 | if ('data' in rest) {
222 | message = {
223 | port,
224 | data: rest.data,
225 | };
226 | } else {
227 | message = {
228 | port,
229 | status: rest.status,
230 | data1: rest.data1,
231 | data2: rest.data2,
232 | };
233 | }
234 |
235 | let shouldUpdateCache = false;
236 | if (
237 | (type === 'input' && this.cacheOnInput) ||
238 | (type === 'output' && this.cacheOnOutput)
239 | ) {
240 | shouldUpdateCache = !deepEqual(message, this.cachedValue);
241 | }
242 |
243 | if (shouldUpdateCache) {
244 | this.cachedValue = message;
245 | [...this.instances].forEach((instance) => {
246 | if (
247 | this.cachedValue &&
248 | instance.props.onChange &&
249 | this.instances.includes(instance)
250 | ) {
251 | instance.props.onChange('data' in message ? message.data : message);
252 | }
253 | });
254 | }
255 | }
256 |
257 | shouldOutputMessage({
258 | port = 0,
259 | ...rest
260 | }:
261 | | {
262 | port?: number;
263 | status: number;
264 | data1: number;
265 | data2: number;
266 | }
267 | | { port?: number; data: string }) {
268 | let message:
269 | | {
270 | port: number;
271 | status: number;
272 | data1: number;
273 | data2: number;
274 | }
275 | | { port: number; data: string };
276 |
277 | if ('data' in rest) {
278 | message = {
279 | port,
280 | data: rest.data,
281 | };
282 | } else {
283 | message = {
284 | port,
285 | status: rest.status,
286 | data1: rest.data1,
287 | data2: rest.data2,
288 | };
289 | }
290 |
291 | return !deepEqual(message, this.cachedValue);
292 | }
293 | }
294 |
--------------------------------------------------------------------------------
/src/controls/midi/midi-out-proxy.ts:
--------------------------------------------------------------------------------
1 | import { MidiMessage, SimpleMidiMessage } from './midi-message';
2 | import { Session } from '../../session';
3 |
4 | export interface NaiveMidiMessage extends SimpleMidiMessage {
5 | label?: string;
6 | port: number;
7 | }
8 |
9 | export interface NaiveSysexMessage {
10 | label?: string;
11 | port: number;
12 | data: string;
13 | }
14 |
15 | export class MidiOutProxy {
16 | private session: Session;
17 | private _midiQueue: NaiveMidiMessage[] = [];
18 | private _sysexQueue: NaiveSysexMessage[] = [];
19 |
20 | constructor(session: Session) {
21 | this.session = session;
22 | session.on('flush', () => this._flushQueues());
23 | }
24 |
25 | sendMidi({
26 | label,
27 | port = 0,
28 | status,
29 | data1,
30 | data2,
31 | urgent = false,
32 | }: {
33 | label?: string;
34 | port?: number;
35 | status: number;
36 | data1: number;
37 | data2: number;
38 | urgent?: boolean;
39 | }) {
40 | // if urgent, fire midi message immediately, otherwise queue it up for next flush
41 | if (urgent || this.session.isExitPhase) {
42 | console.log(
43 | `[MIDI] OUT ${port} <== ${
44 | new MidiMessage({ status, data1, data2 }).shortHex
45 | }${label ? ` "${label}"` : ''}`
46 | );
47 | host.getMidiOutPort(port).sendMidi(status, data1, data2);
48 | } else {
49 | this._midiQueue.push({ label, port, status, data1, data2 });
50 | }
51 | }
52 |
53 | sendSysex({
54 | label,
55 | port = 0,
56 | data,
57 | urgent = false,
58 | }: {
59 | label?: string;
60 | port?: number;
61 | data: string;
62 | urgent?: boolean;
63 | }) {
64 | // if urgent, fire sysex immediately, otherwise queue it up for next flush
65 | if (urgent) {
66 | console.log(
67 | `[SYSEX] OUT ${port} <== ${data}${label ? ` "${label}"` : ''}`
68 | );
69 | host.getMidiOutPort(port).sendSysex(data);
70 | } else {
71 | this._sysexQueue.push({ label, port, data });
72 | }
73 | }
74 |
75 | sendNoteOn({
76 | port = 0,
77 | channel,
78 | key,
79 | velocity,
80 | urgent = false,
81 | }: {
82 | port?: number;
83 | channel: number;
84 | key: number;
85 | velocity: number;
86 | urgent?: boolean;
87 | }) {
88 | this.sendMidi({
89 | urgent,
90 | port,
91 | status: 0x90 | channel,
92 | data1: key,
93 | data2: velocity,
94 | });
95 | }
96 |
97 | sendNoteOff({
98 | port = 0,
99 | channel,
100 | key,
101 | velocity,
102 | urgent = false,
103 | }: {
104 | port?: number;
105 | channel: number;
106 | key: number;
107 | velocity: number;
108 | urgent?: boolean;
109 | }) {
110 | this.sendMidi({
111 | urgent,
112 | port,
113 | status: 0x80 | channel,
114 | data1: key,
115 | data2: velocity,
116 | });
117 | }
118 |
119 | sendKeyPressure({
120 | port = 0,
121 | channel,
122 | key,
123 | pressure,
124 | urgent = false,
125 | }: {
126 | port?: number;
127 | channel: number;
128 | key: number;
129 | pressure: number;
130 | urgent?: boolean;
131 | }) {
132 | this.sendMidi({
133 | urgent,
134 | port,
135 | status: 0xa0 | channel,
136 | data1: key,
137 | data2: pressure,
138 | });
139 | }
140 |
141 | sendControlChange({
142 | port = 0,
143 | channel,
144 | control,
145 | value,
146 | urgent = false,
147 | }: {
148 | port?: number;
149 | channel: number;
150 | control: number;
151 | value: number;
152 | urgent?: boolean;
153 | }) {
154 | this.sendMidi({
155 | urgent,
156 | port,
157 | status: 0xb0 | channel,
158 | data1: control,
159 | data2: value,
160 | });
161 | }
162 |
163 | sendProgramChange({
164 | port = 0,
165 | channel,
166 | program,
167 | urgent = false,
168 | }: {
169 | port?: number;
170 | channel: number;
171 | program: number;
172 | urgent?: boolean;
173 | }) {
174 | this.sendMidi({
175 | urgent,
176 | port,
177 | status: 0xc0 | channel,
178 | data1: program,
179 | data2: 0,
180 | });
181 | }
182 |
183 | sendChannelPressure({
184 | port = 0,
185 | channel,
186 | pressure,
187 | urgent = false,
188 | }: {
189 | port?: number;
190 | channel: number;
191 | pressure: number;
192 | urgent?: boolean;
193 | }) {
194 | this.sendMidi({
195 | urgent,
196 | port,
197 | status: 0xd0 | channel,
198 | data1: pressure,
199 | data2: 0,
200 | });
201 | }
202 |
203 | sendPitchBend({
204 | port = 0,
205 | channel,
206 | value,
207 | urgent = false,
208 | }: {
209 | port?: number;
210 | channel: number;
211 | value: number;
212 | urgent?: boolean;
213 | }) {
214 | this.sendMidi({
215 | urgent,
216 | port,
217 | status: 0xe0 | channel,
218 | data1: value & 0x7f,
219 | data2: (value >> 7) & 0x7f,
220 | });
221 | }
222 |
223 | // flush queued midi and sysex messages
224 | protected _flushQueues() {
225 | while (this._midiQueue.length > 0 || this._sysexQueue.length > 0) {
226 | const midiMessage = this._midiQueue.shift() as NaiveMidiMessage;
227 | if (midiMessage) {
228 | const { label, port, status, data1, data2 } = midiMessage;
229 | console.log(
230 | `[MIDI] OUT ${port} <== ${
231 | new MidiMessage({ status, data1, data2 }).shortHex
232 | }${label ? ` "${label}"` : ''}`
233 | );
234 | host.getMidiOutPort(port).sendMidi(status, data1, data2);
235 | }
236 |
237 | const sysexMessage = this._sysexQueue.shift() as NaiveSysexMessage;
238 | if (sysexMessage) {
239 | const { label, port, data } = sysexMessage;
240 | console.log(
241 | `[SYSEX] OUT ${port} <== ${label ? `"${label}" ` : ''}${data}`
242 | );
243 | host.getMidiOutPort(port).sendSysex(data);
244 | }
245 | }
246 | }
247 | }
248 |
--------------------------------------------------------------------------------
/src/controls/midi/note-input.ts:
--------------------------------------------------------------------------------
1 | const tables = {
2 | DEFAULT: Array.apply(null, Array(128)).map((_, i) => i),
3 | DISABLED: Array.apply(null, Array(128)).map(() => -1),
4 | };
5 |
6 | export class NoteInputProxy {
7 | public noteInput: API.NoteInput;
8 |
9 | private _disabled = false;
10 | private _keyTranslationTable: (number | null)[] = tables.DEFAULT;
11 | private _shouldConsumeEvents: boolean = true;
12 | private _changeCallbacks: ((...args: any[]) => void)[] = [];
13 |
14 | constructor(noteInput: API.NoteInput) {
15 | this.noteInput = noteInput;
16 | }
17 |
18 | get keyTranslationTable() {
19 | return this._keyTranslationTable;
20 | }
21 |
22 | set keyTranslationTable(keyTranslationTable: (number | null)[]) {
23 | let isValid = keyTranslationTable.length === 128;
24 | const apiKeyTranslationTable = keyTranslationTable.map((note) => {
25 | // validate each slot each iteration
26 | if (!Number.isInteger(note) && note !== null) isValid = false;
27 | // filter out note values which are invalid to the API
28 | return note === null || note > 127 || note < 0 ? -1 : note;
29 | });
30 |
31 | if (!isValid) {
32 | throw new Error(
33 | 'Invalid note table: must have a length of 128 and only contain null and integer values.'
34 | );
35 | }
36 |
37 | this._keyTranslationTable = keyTranslationTable;
38 | if (!this._disabled) {
39 | this.noteInput.setKeyTranslationTable(apiKeyTranslationTable);
40 | // call the registered change callbacks
41 | this._changeCallbacks.forEach((callback) => callback());
42 | }
43 | }
44 |
45 | get shouldConsumeEvents() {
46 | return this._shouldConsumeEvents;
47 | }
48 |
49 | set shouldConsumeEvents(shouldConsumeEvents: boolean) {
50 | this._shouldConsumeEvents = shouldConsumeEvents;
51 | this.noteInput.setShouldConsumeEvents(shouldConsumeEvents);
52 | }
53 |
54 | enable() {
55 | if (!this._disabled) return;
56 | this._disabled = false;
57 | this.keyTranslationTable = this.keyTranslationTable;
58 | }
59 |
60 | disable() {
61 | if (this._disabled) return;
62 | this._disabled = true;
63 | this.noteInput.setKeyTranslationTable(tables.DISABLED);
64 | // call the registered change callbacks
65 | this._changeCallbacks.forEach((callback) => callback());
66 | }
67 |
68 | transpose(steps: number) {
69 | this.keyTranslationTable = this.keyTranslationTable.map(
70 | (note) => note && note + steps
71 | );
72 | }
73 |
74 | onChange(callback: (...args: any[]) => void) {
75 | this._changeCallbacks.push(callback);
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/controls/midi/sysex-message.ts:
--------------------------------------------------------------------------------
1 | export class SysexMessage {
2 | port: number;
3 | data: string;
4 | urgent: boolean;
5 |
6 | constructor({
7 | port = 0,
8 | data,
9 | urgent = false,
10 | }: {
11 | port?: number;
12 | data: string;
13 | urgent?: boolean;
14 | }) {
15 | this.port = port;
16 | this.data = data.toUpperCase();
17 | this.urgent = urgent;
18 | }
19 |
20 | toString() {
21 | return this.data.toUpperCase();
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/controls/midi/utils.ts:
--------------------------------------------------------------------------------
1 | export const deepEqual = function (x: any, y: any) {
2 | if (x === y) {
3 | return true;
4 | } else if (
5 | typeof x == 'object' &&
6 | x != null &&
7 | typeof y == 'object' &&
8 | y != null
9 | ) {
10 | if (Object.keys(x).length != Object.keys(y).length) return false;
11 |
12 | for (const prop in x) {
13 | if (y.hasOwnProperty(prop)) {
14 | if (!deepEqual(x[prop], y[prop])) return false;
15 | } else return false;
16 | }
17 |
18 | return true;
19 | } else return false;
20 | };
21 |
--------------------------------------------------------------------------------
/src/env/delayed-task.ts:
--------------------------------------------------------------------------------
1 | export class DelayedTask {
2 | callback: Function;
3 | delay: number;
4 | repeat: boolean;
5 | cancelled = false;
6 |
7 | constructor(callback: (...args: any[]) => any, delay = 0, repeat = false) {
8 | this.callback = callback;
9 | this.delay = delay;
10 | this.repeat = repeat;
11 | }
12 |
13 | start(...args: any[]) {
14 | host.scheduleTask(() => {
15 | if (!this.cancelled) {
16 | this.callback.call(args);
17 | if (this.repeat) this.start(...args);
18 | }
19 | }, this.delay);
20 | return this;
21 | }
22 |
23 | cancel() {
24 | this.cancelled = true;
25 | return this;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/env/index.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 |
3 | import { Logger } from './logger';
4 | import { DelayedTask } from './delayed-task';
5 |
6 | // specific env setup for bitwig environment
7 | // shim Timeout and Interval methods using DelayedTask class
8 | globalThis.setTimeout = function setTimeout(
9 | callback: (...args: any[]) => any,
10 | delay = 0,
11 | ...params: any[]
12 | ) {
13 | return new DelayedTask(callback, delay).start(...params);
14 | };
15 |
16 | globalThis.clearTimeout = function clearTimeout(timeout: DelayedTask) {
17 | if (timeout) timeout.cancel();
18 | };
19 |
20 | globalThis.setInterval = function setInterval(
21 | callback: (...args: any[]) => any,
22 | delay = 0,
23 | ...params: any[]
24 | ) {
25 | return new DelayedTask(callback, delay, true).start(...params);
26 | };
27 |
28 | globalThis.clearInterval = function clearInterval(interval: DelayedTask) {
29 | if (interval) interval.cancel();
30 | };
31 |
32 | // shim console with custom logger
33 | globalThis.console = new Logger();
34 |
35 | // hookup dummy function to unsupported logger methods
36 |
37 | // Console-polyfill. MIT license.
38 | // https://github.com/paulmillr/console-polyfill
39 | // Make it safe to do console.log() always.
40 | const con = globalThis.console;
41 | let prop;
42 | let method;
43 |
44 | const dummy = function () {};
45 | const properties = ['memory'];
46 | const methods = [
47 | 'assert',
48 | 'clear',
49 | 'count',
50 | 'debug',
51 | 'dir',
52 | 'dirxml',
53 | 'error',
54 | 'exception',
55 | 'group',
56 | 'groupCollapsed',
57 | 'groupEnd',
58 | 'info',
59 | 'log',
60 | 'markTimeline',
61 | 'profile',
62 | 'profiles',
63 | 'profileEnd',
64 | 'show',
65 | 'table',
66 | 'time',
67 | 'timeEnd',
68 | 'timeline',
69 | 'timelineEnd',
70 | 'timeStamp',
71 | 'trace',
72 | 'warn',
73 | ];
74 |
75 | while ((prop = properties.pop())) {
76 | if (!con[prop]) con[prop] = {};
77 | }
78 |
79 | while ((method = methods.pop())) {
80 | if (typeof con[method] !== 'function') con[method] = dummy;
81 | }
82 |
--------------------------------------------------------------------------------
/src/env/logger.ts:
--------------------------------------------------------------------------------
1 | import { Session } from '../session';
2 |
3 | export type Level = 'ERROR' | 'WARN' | 'INFO' | 'DEBUG';
4 | export type MidiLevel = 'Input' | 'Output' | 'Both' | 'None';
5 |
6 | /**
7 | * Simple logger implementation including integration with the Bitwig
8 | * API's preferences system for setting log level, log filtering via
9 | * regular expressions, and Midi I/O filtering.
10 | */
11 | export class Logger {
12 | private _levels = ['ERROR', 'WARN', 'INFO', 'DEBUG'];
13 | private _level!: Level;
14 | private _midiLevel!: MidiLevel;
15 | private _levelSetting!: API.SettableEnumValue;
16 | private _filter!: string;
17 | private _filterSetting!: API.SettableStringValue;
18 | private _initQueue: [Level | null, any[]][] = [];
19 | private _flushed = false;
20 |
21 | constructor(session?: Session) {
22 | session?.on('init', () => {
23 | host
24 | .getPreferences()
25 | .getEnumSetting(
26 | 'Log Midi',
27 | 'Development',
28 | ['None', 'Input', 'Output', 'Both'],
29 | 'None'
30 | )
31 | .addValueObserver((midiLevel) => {
32 | this._midiLevel = midiLevel as MidiLevel;
33 | if (this._ready && !this._flushed) this._flushQueue();
34 | });
35 |
36 | this._levelSetting = host
37 | .getPreferences()
38 | .getEnumSetting('Log Level', 'Development', this._levels, 'ERROR');
39 |
40 | this._levelSetting.addValueObserver((level) => {
41 | this._level = level as Level;
42 | if (this._ready && !this._flushed) this._flushQueue();
43 | });
44 |
45 | this._filterSetting = host
46 | .getPreferences()
47 | .getStringSetting('Log filter (Regex)', 'Development', 1000, '');
48 | this._filterSetting.addValueObserver((value) => {
49 | this._filter = value;
50 | if (this._filter) {
51 | const message = ` Log filter regex set to "${value}"`;
52 | this.log(`╭───┬${'─'.repeat(message.length)}╮`);
53 | this.log(`│ i │${message}` + '│'); // prettier-ignore
54 | this.log(`╰───┴${'─'.repeat(message.length)}╯`);
55 | }
56 | if (this._ready && !this._flushed) this._flushQueue();
57 | });
58 | });
59 | }
60 |
61 | private get _ready() {
62 | return (
63 | this._filter !== undefined &&
64 | this._level !== undefined &&
65 | this._midiLevel !== undefined
66 | );
67 | }
68 |
69 | set level(level: Level) {
70 | if (this._levelSetting !== undefined) {
71 | this._levelSetting.set(level);
72 | } else {
73 | this._level = level;
74 | }
75 | }
76 |
77 | get level() {
78 | return this._level;
79 | }
80 |
81 | set filter(value) {
82 | if (this._filterSetting !== undefined) {
83 | this._filterSetting.set(value);
84 | } else {
85 | this._filter = value;
86 | }
87 | }
88 |
89 | get filter() {
90 | return this._filter;
91 | }
92 |
93 | log(...messages: any[]) {
94 | this._log(null, ...messages);
95 | }
96 |
97 | error(...messages: any[]) {
98 | this._log('ERROR', ...messages);
99 | }
100 |
101 | warn(...messages: any[]) {
102 | this._log('WARN', ...messages);
103 | }
104 |
105 | info(...messages: any[]) {
106 | this._log('INFO', ...messages);
107 | }
108 |
109 | debug(...messages: any[]) {
110 | this._log('DEBUG', ...messages);
111 | }
112 |
113 | dir(...messages: any[]) {
114 | this._log(null, ...messages.map((m) => JSON.stringify(m, null, 2)));
115 | }
116 |
117 | private _log(level: Level | null, ...messages: any[]) {
118 | if (!this._ready) {
119 | this._initQueue.push([level, messages]);
120 | return;
121 | }
122 |
123 | if (
124 | level &&
125 | this._levels.indexOf(level) > this._levels.indexOf(this._level)
126 | )
127 | return;
128 |
129 | const message = `${level ? `[${level.toUpperCase()}] ` : ''}${messages.join(
130 | ' '
131 | )}`;
132 | if (level && this._filter) {
133 | const re = new RegExp(this._filter, 'gi');
134 | if (!re.test(message)) return;
135 | }
136 |
137 | const isMidiInput = new RegExp('^\\[(MIDI|SYSEX)\\] ? IN', 'gi').test(
138 | message
139 | );
140 | const isMidiOutput = new RegExp('^\\[(MIDI|SYSEX)\\] ? OUT', 'gi').test(
141 | message
142 | );
143 |
144 | if (this._midiLevel === 'None' && (isMidiInput || isMidiOutput)) return;
145 | if (this._midiLevel === 'Input' && isMidiOutput) return;
146 | if (this._midiLevel === 'Output' && isMidiInput) return;
147 |
148 | level === 'ERROR' ? host.errorln(message) : host.println(message);
149 | }
150 |
151 | private _flushQueue() {
152 | while (this._initQueue.length > 0) {
153 | const [level, messages] = this._initQueue.shift() as [
154 | Level | null,
155 | any[]
156 | ];
157 | this._log(level, ...messages);
158 | }
159 | this._flushed = true;
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import './env';
2 |
3 | import { Session } from './session';
4 | import { reconciler } from './reconciler';
5 | import { Midi } from './components/midi';
6 | import {
7 | ControllerScript,
8 | ControllerScriptProps,
9 | } from './components/controller-script';
10 | import {
11 | createInitState,
12 | createInitValue,
13 | createInitObject,
14 | } from './init-helpers';
15 |
16 | const session = new Session();
17 |
18 | const LEGACY_ROOT = 0; // CONCURRENT_ROOT = 1
19 |
20 | const render = (rootNode: JSX.Element) => {
21 | // If ControllerScript component provided as rootNode, call related controller definition methods
22 | if (rootNode.type === ControllerScript) {
23 | const { api, author, name, uuid, vendor, version, midi } =
24 | rootNode.props as ControllerScriptProps;
25 |
26 | // 1. set bitwig api version
27 |
28 | host.loadAPI(api);
29 |
30 | // 2. define controller script
31 |
32 | host.defineController(
33 | vendor, // vendor
34 | name, // name
35 | version, // version
36 | uuid, // uuid
37 | author // author
38 | );
39 |
40 | // 3. setup and discover midi controllers
41 |
42 | if (midi) {
43 | if (Array.isArray(midi)) {
44 | // handle multiple discovery pairs
45 | host.defineMidiPorts(midi[0].inputs.length, midi[0].outputs.length);
46 | midi.forEach(({ inputs, outputs }) =>
47 | host.addDeviceNameBasedDiscoveryPair(inputs, outputs)
48 | );
49 | } else if (Array.isArray(midi.inputs) && Array.isArray(midi.outputs)) {
50 | // handle single discovery pair
51 | host.defineMidiPorts(midi.inputs.length, midi.outputs.length);
52 | host.addDeviceNameBasedDiscoveryPair(midi.inputs, midi.outputs);
53 | } else if (
54 | typeof midi.inputs === 'number' &&
55 | typeof midi.outputs === 'number'
56 | ) {
57 | // handle simple midi port count
58 | host.defineMidiPorts(midi.inputs, midi.outputs);
59 | }
60 | }
61 | }
62 |
63 | session.on('init', () => {
64 | const fiberRoot = reconciler.createContainer(
65 | session,
66 | LEGACY_ROOT,
67 | null,
68 | false,
69 | null,
70 | '',
71 | () => {},
72 | null
73 | );
74 | reconciler.updateContainer(rootNode, fiberRoot, null, () => null);
75 | });
76 | };
77 |
78 | const ReactBitwig = {
79 | render,
80 | session,
81 | createInitState,
82 | createInitValue,
83 | createInitObject,
84 | };
85 |
86 | export default ReactBitwig;
87 |
88 | export {
89 | render,
90 | session,
91 | Midi,
92 | ControllerScript,
93 | createInitState,
94 | createInitValue,
95 | createInitObject,
96 | };
97 | export * from './components/midi';
98 | export * from './components/controller-script';
99 | export * from './init-helpers';
100 |
--------------------------------------------------------------------------------
/src/init-helpers.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { session } from './index';
3 |
4 | export type InitValue = { get: () => V };
5 |
6 | export function createInitValue(initializer: () => V): InitValue {
7 | let value: V;
8 |
9 | let initialized = false;
10 | let initializing = false;
11 | const initialize = () => {
12 | if (initializing) {
13 | throw new Error('Circular initialization dependency detected.');
14 | } else if (!session.isInitPhase) {
15 | throw new Error(
16 | 'Access to init value not allowed until during or after the init phase.'
17 | );
18 | }
19 |
20 | initializing = true;
21 | value = initializer();
22 | initialized = true;
23 | initializing = false;
24 | };
25 |
26 | session.on('init', () => {
27 | if (!initialized) initialize();
28 | });
29 |
30 | return {
31 | get: () => {
32 | if (!initialized) initialize();
33 | return value;
34 | },
35 | };
36 | }
37 |
38 | export function createInitObject(
39 | initializer: () => O
40 | ): O {
41 | let object: O = {} as O;
42 |
43 | let initialized = false;
44 | let initializing = false;
45 | const initialize = () => {
46 | if (initializing) {
47 | throw new Error('Circular initialization dependency detected.');
48 | } else if (!session.isInitPhase) {
49 | throw new Error(
50 | 'Access to init value not allowed until during or after the init phase.'
51 | );
52 | }
53 |
54 | initializing = true;
55 | object = initializer();
56 | initialized = true;
57 | initializing = false;
58 | };
59 |
60 | const gettersObject = {} as O;
61 |
62 | session.on('init', () => {
63 | if (!initialized) initialize();
64 | Object.keys(object).forEach((k) => {
65 | Object.defineProperty(gettersObject, k, {
66 | get: () => object[k],
67 | });
68 | });
69 | });
70 |
71 | return gettersObject;
72 | }
73 |
74 | export type InitState = {
75 | get: () => S;
76 | set: (state: Partial | ((state: S) => Partial)) => void;
77 | subscribe: (listener: (value: S) => void) => void;
78 | unsubscribe: (listener: (value: S) => void) => void;
79 | use: {
80 | (): S;
81 | (selector: (state: S) => T): T;
82 | };
83 | };
84 |
85 | export function createInitState(initializer: () => S): InitState {
86 | let currentState: S;
87 |
88 | const listeners: ((state: S) => void)[] = [];
89 |
90 | const setState = (state: Partial | ((state: S) => Partial)) => {
91 | state = typeof state === 'function' ? state(currentState) : state;
92 | const newState = (
93 | currentState === undefined
94 | ? state
95 | : typeof state === 'object' && !Array.isArray(state)
96 | ? { ...currentState, ...state }
97 | : state
98 | ) as S;
99 | if (currentState !== newState) {
100 | currentState = newState;
101 | listeners.forEach((listener) => {
102 | listener(newState);
103 | });
104 | }
105 | };
106 |
107 | let initialized = false;
108 | let initializing = false;
109 | const initialize = () => {
110 | if (initializing) {
111 | throw new Error('Circular initialization dependency detected.');
112 | }
113 | if (!session.isInitPhase) {
114 | throw new Error(
115 | 'Access to init state not allowed until during or after the init phase.'
116 | );
117 | }
118 |
119 | initializing = true;
120 | const initialState = initializer();
121 | if (initialState !== undefined) {
122 | setState(initialState);
123 | }
124 | initialized = true;
125 | initializing = false;
126 | };
127 |
128 | session.on('init', () => {
129 | if (!initialized) initialize();
130 | });
131 |
132 | const get = (): S => {
133 | // 1. make sure its initialized
134 | if (!initialized) initialize();
135 | // 2. return initialized state as readonly state
136 | return currentState;
137 | };
138 | const set = (state: Partial | ((state: S) => Partial)) => {
139 | setState(state);
140 | };
141 | const subscribe = (listener: (state: S) => void) => {
142 | listeners.push(listener);
143 | };
144 | const unsubscribe = (listener: (state: S) => void) => {
145 | listeners.splice(listeners.indexOf(listener), 1);
146 | };
147 | const use = any) | undefined = undefined>(
148 | selector?: T
149 | ): T extends (state: S) => any ? ReturnType : S => {
150 | const [hookState, setHookState] = React.useState(
151 | selector ? selector(currentState) : currentState
152 | );
153 |
154 | // layout effect to make sure the hook state is updated when the state changes on init
155 | React.useLayoutEffect(() => {
156 | const listener = (state: S) => {
157 | setHookState(selector ? selector(state) : state);
158 | };
159 | subscribe(listener);
160 | return () => unsubscribe(listener);
161 | }, []);
162 |
163 | return hookState;
164 | };
165 | return { get, set, subscribe, unsubscribe, use };
166 | }
167 |
--------------------------------------------------------------------------------
/src/lib/hooks.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const useButton = ({
4 | onPress,
5 | onLongPress,
6 | onDoublePress,
7 | onRelease,
8 | onDoubleRelease,
9 | }: {
10 | onPress?: () => void;
11 | onLongPress?: () => void;
12 | onDoublePress?: () => void;
13 | onRelease?: () => void;
14 | onDoubleRelease?: () => void;
15 | }) => {
16 | const timeoutsRef = React.useRef({
17 | longPress: 0,
18 | recentPress: 0,
19 | recentRelease: 0,
20 | });
21 |
22 | React.useEffect(() => {
23 | return () => {
24 | // cancel timeouts on unmount
25 | clearTimeout(timeoutsRef.current.longPress);
26 | clearTimeout(timeoutsRef.current.recentPress);
27 | clearTimeout(timeoutsRef.current.recentRelease);
28 | };
29 | }, []);
30 |
31 | const recentlyPressedRef = React.useRef(false);
32 | const recentlyReleasedRef = React.useRef(false);
33 | const hasBeenPressedRef = React.useRef(false);
34 |
35 | const fireButtonEvent = React.useCallback(
36 | (event: 'pressed' | 'released') => {
37 | event === 'pressed'
38 | ? recentlyPressedRef.current
39 | ? onDoublePress
40 | ? onDoublePress()
41 | : onPress && onPress()
42 | : onPress && onPress()
43 | : recentlyReleasedRef.current
44 | ? onDoubleRelease
45 | ? onDoubleRelease()
46 | : onRelease && onRelease()
47 | : hasBeenPressedRef.current && onRelease && onRelease();
48 |
49 | // if there's a pending longPress timeout, press or release should cancel that
50 | clearTimeout(timeoutsRef.current.longPress);
51 |
52 | if (event === 'pressed') {
53 | if (!hasBeenPressedRef.current) hasBeenPressedRef.current = true;
54 |
55 | if (onLongPress) {
56 | timeoutsRef.current.longPress = setTimeout(onLongPress, 500);
57 | }
58 |
59 | // set recently pressed & schedule reset
60 | clearTimeout(timeoutsRef.current.recentPress);
61 | recentlyPressedRef.current = true;
62 | timeoutsRef.current.recentPress = setTimeout(() => {
63 | recentlyPressedRef.current = false;
64 | }, 250);
65 | } else {
66 | // set recently released & schedule reset
67 | clearTimeout(timeoutsRef.current.recentRelease);
68 | recentlyReleasedRef.current = true;
69 | timeoutsRef.current.recentRelease = setTimeout(() => {
70 | recentlyReleasedRef.current = false;
71 | }, 250);
72 | }
73 | },
74 | [onPress, onLongPress, onDoublePress, onRelease, onDoubleRelease]
75 | );
76 |
77 | return fireButtonEvent;
78 | };
79 |
--------------------------------------------------------------------------------
/src/reconciler.ts:
--------------------------------------------------------------------------------
1 | import ReactReconciler from 'react-reconciler';
2 | import { DefaultEventPriority } from 'react-reconciler/constants';
3 |
4 | import { MidiInstanceUpdatePayload } from './controls/midi/midi-connector';
5 | import { MidiInstance, MidiInstanceProps } from './controls/midi/midi-instance';
6 | import { Session } from './session';
7 |
8 | export const reconciler = ReactReconciler<
9 | 'midi', // Type
10 | MidiInstanceProps, // Props
11 | Session, // Container
12 | MidiInstance, // Instance
13 | never, // TextInstance
14 | MidiInstance, // SuspenseInstance
15 | MidiInstance, // HydratableInstance
16 | MidiInstance, // PublicInstance
17 | null, // HostContext
18 | MidiInstanceUpdatePayload, // UpdatePayload
19 | unknown, // _ChildSet (Placeholder for undocumented API)
20 | number, // TimeoutHandle
21 | -1 // NoTimeout
22 | >({
23 | supportsMutation: true, // we're choosing to build this in mutation mode
24 | supportsPersistence: false, // (not persistence mode)
25 | supportsHydration: false, // hydration does not apply in this env
26 | scheduleTimeout: setTimeout, // timeout scheduler for env
27 | cancelTimeout: clearTimeout, // timeout clear function for env
28 | noTimeout: -1, // value that can never be a timeout id
29 | isPrimaryRenderer: true, // this will be the only renderer
30 |
31 | createInstance(type, props) {
32 | if (type !== 'midi') {
33 | throw new Error(`Unsupported intrinsic element type "${type}"`);
34 | }
35 | return new MidiInstance(props);
36 | },
37 |
38 | appendChildToContainer(container, child) {
39 | // add pattern to container if it hasn't already been registered with matching pattern and return it
40 | // if it has, increase count of components depending on it
41 | // or throw error because pattern doesn't fully match but conflicts with existing pattern
42 | // connect event listeners for new ones
43 | container.midi.addInstance(child);
44 | },
45 |
46 | insertInContainerBefore(container, child, _beforeChild) {
47 | container.midi.addInstance(child);
48 | },
49 |
50 | removeChildFromContainer(container, child) {
51 | // find child in container and decrement pointer count.
52 | // If decrementing results in count of 0, remove from container, cleanup
53 | container.midi.removeInstance(child);
54 | },
55 |
56 | prepareUpdate(instance, type, oldProps, newProps, container) {
57 | // check if update is needed, if not return null, otherwise optionally return data for use in commit
58 | return container.midi.prepareInstanceUpdate(instance, oldProps, newProps);
59 | },
60 |
61 | commitUpdate(instance, updatePayload, type, oldProps, newProps) {
62 | // commit changes to instance (instance should have access to container and node)
63 | instance.commitUpdate(updatePayload, oldProps, newProps);
64 | },
65 |
66 | getPublicInstance(instance) {
67 | return instance;
68 | },
69 |
70 | clearContainer(container) {
71 | // remove all instances from container
72 | container.midi.clearInstances();
73 | },
74 |
75 | // Required methods that are not needed in this env
76 |
77 | preparePortalMount() {
78 | throw new Error('ReactBitwig does not support portals.');
79 | },
80 |
81 | createTextInstance() {
82 | throw new Error('ReactBitwig does not support text instances.');
83 | },
84 |
85 | appendChild() {
86 | // should never reach this this
87 | throw new Error(
88 | 'ReactBitwig does not support nesting of intrinsic elements'
89 | );
90 | },
91 |
92 | appendInitialChild() {
93 | // should never reach this this
94 | throw new Error(
95 | 'ReactBitwig does not support nesting of intrinsic elements'
96 | );
97 | },
98 |
99 | removeChild() {
100 | // should never reach this this
101 | throw new Error(
102 | 'ReactBitwig does not support nesting of intrinsic elements'
103 | );
104 | },
105 |
106 | insertBefore() {
107 | // should never reach this this
108 | throw new Error(
109 | 'ReactBitwig does not support nesting of intrinsic elements'
110 | );
111 | },
112 |
113 | finalizeInitialChildren() {
114 | return false; // return false to skip this functionality
115 | },
116 |
117 | getRootHostContext() {
118 | return null;
119 | },
120 |
121 | getChildHostContext(parentHostContext) {
122 | return parentHostContext;
123 | },
124 |
125 | prepareForCommit() {
126 | return null;
127 | },
128 |
129 | resetAfterCommit() {},
130 |
131 | shouldSetTextContent() {
132 | return false;
133 | },
134 |
135 | getCurrentEventPriority() {
136 | return DefaultEventPriority;
137 | },
138 |
139 | getInstanceFromNode() {
140 | throw new Error('Not implemented.');
141 | },
142 |
143 | prepareScopeUpdate() {
144 | throw new Error('Not implemented.');
145 | },
146 |
147 | getInstanceFromScope() {
148 | throw new Error('Not implemented.');
149 | },
150 |
151 | beforeActiveInstanceBlur() {},
152 |
153 | afterActiveInstanceBlur() {},
154 |
155 | detachDeletedInstance(node) {},
156 | });
157 |
--------------------------------------------------------------------------------
/src/session/event-emitter.ts:
--------------------------------------------------------------------------------
1 | export class EventEmitter {
2 | listeners: { [key: string]: ((...args: any[]) => void)[] } = {};
3 |
4 | on void>(
5 | label: string,
6 | callback: Callback
7 | ) {
8 | if (this.listeners[label] && this.listeners[label].indexOf(callback) > -1) {
9 | throw new Error('Duplicate event subscriptions not allowed');
10 | }
11 | this.listeners = {
12 | ...this.listeners,
13 | [label]: [...(this.listeners[label] || []), callback],
14 | };
15 | }
16 |
17 | addListener void>(
18 | label: string,
19 | callback: Callback
20 | ) {
21 | this.on(label, callback);
22 | }
23 |
24 | removeListener void>(
25 | label: string,
26 | callback: Callback
27 | ) {
28 | const listeners = this.listeners[label];
29 | const index = listeners ? listeners.indexOf(callback) : -1;
30 |
31 | if (index > -1) {
32 | this.listeners = {
33 | ...this.listeners,
34 | [label]: [...listeners.slice(0, index), ...listeners.slice(index + 1)],
35 | };
36 | return true;
37 | }
38 | return false;
39 | }
40 |
41 | emit(label: string, ...args: any[]) {
42 | try {
43 | const listeners = this.listeners[label];
44 |
45 | if (listeners && listeners.length) {
46 | listeners.forEach((listener) => {
47 | listener(...args);
48 | });
49 | return true;
50 | }
51 | } catch (e: any) {
52 | console.error(
53 | `${e.fileName}:${e.lineNumber}:${e.columnNumber} ${e.message}\n${e.stack}`
54 | );
55 | }
56 | return false;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/session/index.ts:
--------------------------------------------------------------------------------
1 | export { Session } from './session';
2 |
--------------------------------------------------------------------------------
/src/session/session.ts:
--------------------------------------------------------------------------------
1 | import '../env';
2 | import { EventEmitter } from './event-emitter';
3 | import { MidiConnector } from '../controls/midi/midi-connector';
4 | import { Logger } from '../env/logger';
5 |
6 | declare const globalThis: {
7 | init: () => void;
8 | flush: () => void;
9 | exit: () => void;
10 | };
11 |
12 | export interface Session extends EventEmitter {
13 | on(label: 'init' | 'flush' | 'exit', callback: () => void): void;
14 |
15 | addListener(label: 'init' | 'flush' | 'exit', callback: () => void): void;
16 |
17 | removeListener(
18 | label: 'init' | 'flush' | 'exit',
19 | callback: () => void
20 | ): boolean;
21 | }
22 |
23 | export class Session extends EventEmitter {
24 | private _isExitPhase: boolean = false;
25 | private _isInitPhase: boolean = false;
26 | private _isInitInitialized: boolean = false;
27 |
28 | midi: MidiConnector;
29 |
30 | constructor() {
31 | super();
32 |
33 | // @ts-ignore
34 | globalThis.console = new Logger(this);
35 |
36 | this.midi = new MidiConnector(this);
37 |
38 | globalThis.init = () => {
39 | this._isInitPhase = true;
40 |
41 | // call the session init callbacks
42 | this.emit('init');
43 |
44 | this._isInitPhase = false;
45 | this._isInitInitialized = true;
46 | };
47 |
48 | globalThis.flush = () => {
49 | this.emit('flush');
50 | };
51 |
52 | globalThis.exit = () => {
53 | this._isExitPhase = true;
54 |
55 | // call registered exit callbacks
56 | this.emit('exit');
57 | };
58 | }
59 |
60 | /** Check if bitwig is currently in it's init startup phase */
61 | get isInitPhase(): boolean {
62 | return this._isInitPhase;
63 | }
64 |
65 | get isExitPhase(): boolean {
66 | return this._isExitPhase;
67 | }
68 |
69 | get isInitialized(): boolean {
70 | return this._isInitInitialized;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/types.d.ts:
--------------------------------------------------------------------------------
1 | declare const Java: any;
2 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["src/**/*.test.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2015",
4 | "module": "commonjs",
5 | "rootDir": "src",
6 | "baseUrl": "./src",
7 | "jsx": "react-jsx",
8 | "lib": ["DOM", "ESNext", "ScriptHost"],
9 | "types": ["typed-bitwig-api"],
10 | "strict": true,
11 | "esModuleInterop": true,
12 | "skipLibCheck": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "moduleResolution": "node",
15 | "outDir": "./dist",
16 | "declaration": true
17 | },
18 | "include": ["src"]
19 | }
20 |
--------------------------------------------------------------------------------