@blibliki/engine 0.3.8 → 0.3.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blibliki/engine",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "type": "module",
5
5
  "source": "src/index.ts",
6
6
  "main": "dist/index.cjs",
@@ -17,11 +17,11 @@
17
17
  "vitest": "^4.0.6"
18
18
  },
19
19
  "dependencies": {
20
+ "@julusian/midi": "^3.6.1",
20
21
  "es-toolkit": "^1.41.0",
21
22
  "node-web-audio-api": "^1.0.3",
22
- "webmidi": "^3.1.14",
23
- "@blibliki/transport": "^0.3.8",
24
- "@blibliki/utils": "^0.3.8"
23
+ "@blibliki/utils": "^0.3.10",
24
+ "@blibliki/transport": "^0.3.10"
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsup",
@@ -1,5 +1,5 @@
1
1
  import { Seconds } from "@blibliki/transport";
2
- import { Message } from "webmidi";
2
+ import Message from "../midi/Message";
3
3
  import frequencyTable from "./frequencyTable";
4
4
 
5
5
  const Notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Simple wrapper around MIDI message data (Uint8Array)
3
+ * Replaces the webmidi Message class with native Web MIDI API data
4
+ */
5
+ export default class Message {
6
+ public readonly data: Uint8Array;
7
+
8
+ constructor(data: Uint8Array) {
9
+ this.data = data;
10
+ }
11
+
12
+ /**
13
+ * Returns the data bytes (excluding the status byte)
14
+ */
15
+ get dataBytes(): number[] {
16
+ return Array.from(this.data.slice(1));
17
+ }
18
+
19
+ /**
20
+ * Returns the MIDI message type based on the status byte
21
+ */
22
+ get type(): string {
23
+ const statusByte = this.data[0];
24
+ const messageType = statusByte & 0xf0;
25
+
26
+ switch (messageType) {
27
+ case 0x90: // Note On
28
+ // Check if velocity is 0 (which is actually Note Off)
29
+ return this.data[2] === 0 ? "noteoff" : "noteon";
30
+ case 0x80: // Note Off
31
+ return "noteoff";
32
+ case 0xb0: // Control Change
33
+ return "controlchange";
34
+ case 0xe0: // Pitch Bend
35
+ return "pitchbend";
36
+ case 0xd0: // Channel Pressure (Aftertouch)
37
+ return "channelaftertouch";
38
+ case 0xa0: // Polyphonic Key Pressure
39
+ return "keyaftertouch";
40
+ case 0xc0: // Program Change
41
+ return "programchange";
42
+ default:
43
+ return "unknown";
44
+ }
45
+ }
46
+ }
@@ -1,6 +1,7 @@
1
1
  import { Context } from "@blibliki/utils";
2
- import { MessageEvent, Input } from "webmidi";
2
+ import Message from "./Message";
3
3
  import MidiEvent, { MidiEventType } from "./MidiEvent";
4
+ import type { IMidiInputPort, IMidiMessageEvent } from "./adapters";
4
5
 
5
6
  export enum MidiPortState {
6
7
  connected = "connected",
@@ -25,11 +26,12 @@ export default class MidiDevice implements IMidiDevice {
25
26
  eventListerCallbacks: EventListerCallback[] = [];
26
27
 
27
28
  private context: Readonly<Context>;
28
- private input: Input;
29
+ private input: IMidiInputPort;
30
+ private messageHandler: ((event: IMidiMessageEvent) => void) | null = null;
29
31
 
30
- constructor(input: Input, context: Context) {
32
+ constructor(input: IMidiInputPort, context: Context) {
31
33
  this.id = input.id;
32
- this.name = input.name || `Device ${input.id}`;
34
+ this.name = input.name;
33
35
  this.input = input;
34
36
  this.context = context;
35
37
 
@@ -41,13 +43,17 @@ export default class MidiDevice implements IMidiDevice {
41
43
  }
42
44
 
43
45
  connect() {
44
- this.input.addListener("midimessage", (e: MessageEvent) => {
46
+ this.messageHandler = (e: IMidiMessageEvent) => {
45
47
  this.processEvent(e);
46
- });
48
+ };
49
+ this.input.addEventListener(this.messageHandler);
47
50
  }
48
51
 
49
52
  disconnect() {
50
- this.input.removeListener();
53
+ if (this.messageHandler) {
54
+ this.input.removeEventListener(this.messageHandler);
55
+ this.messageHandler = null;
56
+ }
51
57
  }
52
58
 
53
59
  serialize() {
@@ -66,10 +72,11 @@ export default class MidiDevice implements IMidiDevice {
66
72
  );
67
73
  }
68
74
 
69
- private processEvent(event: MessageEvent) {
75
+ private processEvent(event: IMidiMessageEvent) {
76
+ const message = new Message(event.data);
70
77
  const midiEvent = new MidiEvent(
71
- event.message,
72
- this.context.browserToContextTime(event.timestamp),
78
+ message,
79
+ this.context.browserToContextTime(event.timeStamp),
73
80
  );
74
81
 
75
82
  switch (midiEvent.type) {
@@ -1,7 +1,7 @@
1
1
  import { Context } from "@blibliki/utils";
2
- import { Input, Output, WebMidi } from "webmidi";
3
2
  import ComputerKeyboardDevice from "./ComputerKeyboardDevice";
4
3
  import MidiDevice from "./MidiDevice";
4
+ import { createMidiAdapter, type IMidiAccess } from "./adapters";
5
5
 
6
6
  type ListenerCallback = (device: MidiDevice) => void;
7
7
 
@@ -10,6 +10,8 @@ export default class MidiDeviceManager {
10
10
  private initialized = false;
11
11
  private listeners: ListenerCallback[] = [];
12
12
  private context: Readonly<Context>;
13
+ private midiAccess: IMidiAccess | null = null;
14
+ private adapter = createMidiAdapter();
13
15
 
14
16
  constructor(context: Context) {
15
17
  this.context = context;
@@ -39,15 +41,25 @@ export default class MidiDeviceManager {
39
41
  if (this.initialized) return;
40
42
 
41
43
  try {
42
- await WebMidi.enable();
44
+ if (!this.adapter.isSupported()) {
45
+ console.warn("MIDI is not supported on this platform");
46
+ return;
47
+ }
43
48
 
44
- WebMidi.inputs.forEach((input) => {
49
+ this.midiAccess = await this.adapter.requestMIDIAccess();
50
+
51
+ if (!this.midiAccess) {
52
+ console.error("Failed to get MIDI access");
53
+ return;
54
+ }
55
+
56
+ for (const input of this.midiAccess.inputs()) {
45
57
  if (!this.devices.has(input.id)) {
46
58
  this.devices.set(input.id, new MidiDevice(input, this.context));
47
59
  }
48
- });
60
+ }
49
61
  } catch (err) {
50
- console.error("Error enabling WebMidi:", err);
62
+ console.error("Error enabling MIDI:", err);
51
63
  }
52
64
  }
53
65
 
@@ -59,34 +71,32 @@ export default class MidiDeviceManager {
59
71
  }
60
72
 
61
73
  private listenChanges() {
62
- WebMidi.addListener("connected", (event) => {
63
- const port = event.port as Input | Output;
64
- if (port instanceof Output) return;
65
-
66
- if (this.devices.has(port.id)) return;
67
-
68
- const device = new MidiDevice(port, this.context);
69
- this.devices.set(device.id, device);
70
-
71
- this.listeners.forEach((listener) => {
72
- listener(device);
73
- });
74
- });
75
-
76
- WebMidi.addListener("disconnected", (event) => {
77
- const port = event.port as Input | Output;
78
- if (port instanceof Output) return;
79
-
80
- const device = this.devices.get(port.id);
81
- if (!device) return;
82
- if (device instanceof ComputerKeyboardDevice) return;
83
-
84
- device.disconnect();
85
- this.devices.delete(device.id);
86
-
87
- this.listeners.forEach((listener) => {
88
- listener(device);
89
- });
74
+ if (!this.midiAccess) return;
75
+
76
+ this.midiAccess.addEventListener("statechange", (port) => {
77
+ if (port.state === "connected") {
78
+ // Device connected
79
+ if (this.devices.has(port.id)) return;
80
+
81
+ const device = new MidiDevice(port, this.context);
82
+ this.devices.set(device.id, device);
83
+
84
+ this.listeners.forEach((listener) => {
85
+ listener(device);
86
+ });
87
+ } else {
88
+ // Device disconnected
89
+ const device = this.devices.get(port.id);
90
+ if (!device) return;
91
+ if (device instanceof ComputerKeyboardDevice) return;
92
+
93
+ device.disconnect();
94
+ this.devices.delete(device.id);
95
+
96
+ this.listeners.forEach((listener) => {
97
+ listener(device);
98
+ });
99
+ }
90
100
  });
91
101
  }
92
102
  }
@@ -1,6 +1,6 @@
1
1
  import { ContextTime } from "@blibliki/transport";
2
- import { Message } from "webmidi";
3
2
  import Note, { INote } from "../Note";
3
+ import Message from "./Message";
4
4
 
5
5
  export enum MidiEventType {
6
6
  noteOn = "noteon",
@@ -0,0 +1,174 @@
1
+ /**
2
+ * node-midi adapter for Node.js
3
+ */
4
+ import { isNode } from "es-toolkit";
5
+ import type {
6
+ IMidiAccess,
7
+ IMidiAdapter,
8
+ IMidiInputPort,
9
+ MidiMessageCallback,
10
+ } from "./types";
11
+
12
+ // Dynamic import type for node-midi
13
+ type NodeMidiInput = {
14
+ getPortCount(): number;
15
+ getPortName(port: number): string;
16
+ openPort(port: number): void;
17
+ closePort(): void;
18
+ on(
19
+ event: "message",
20
+ callback: (deltaTime: number, message: number[]) => void,
21
+ ): void;
22
+ off(
23
+ event: "message",
24
+ callback: (deltaTime: number, message: number[]) => void,
25
+ ): void;
26
+ isPortOpen(): boolean;
27
+ };
28
+
29
+ type NodeMidiModule = {
30
+ Input: new () => NodeMidiInput;
31
+ };
32
+
33
+ class NodeMidiInputPort implements IMidiInputPort {
34
+ readonly id: string;
35
+ readonly name: string;
36
+ private portIndex: number;
37
+ private input: NodeMidiInput;
38
+ private callbacks = new Set<MidiMessageCallback>();
39
+ private handler: ((deltaTime: number, message: number[]) => void) | null =
40
+ null;
41
+ private _state: "connected" | "disconnected" = "disconnected";
42
+
43
+ constructor(portIndex: number, name: string, input: NodeMidiInput) {
44
+ this.portIndex = portIndex;
45
+ this.id = `node-midi-${portIndex}`;
46
+ this.name = name;
47
+ this.input = input;
48
+ }
49
+
50
+ get state(): "connected" | "disconnected" {
51
+ return this._state;
52
+ }
53
+
54
+ setState(state: "connected" | "disconnected"): void {
55
+ this._state = state;
56
+ }
57
+
58
+ addEventListener(callback: MidiMessageCallback): void {
59
+ if (this.callbacks.size === 0) {
60
+ this.handler = (_deltaTime: number, message: number[]) => {
61
+ const event = {
62
+ data: new Uint8Array(message),
63
+ timeStamp: performance.now(),
64
+ };
65
+
66
+ this.callbacks.forEach((cb) => {
67
+ cb(event);
68
+ });
69
+ };
70
+
71
+ try {
72
+ if (!this.input.isPortOpen()) {
73
+ this.input.openPort(this.portIndex);
74
+ this._state = "connected";
75
+ }
76
+ this.input.on("message", this.handler);
77
+ } catch (err) {
78
+ console.error(`Error opening MIDI port ${this.portIndex}:`, err);
79
+ }
80
+ }
81
+ this.callbacks.add(callback);
82
+ }
83
+
84
+ removeEventListener(callback: MidiMessageCallback): void {
85
+ this.callbacks.delete(callback);
86
+
87
+ if (this.callbacks.size === 0 && this.handler) {
88
+ try {
89
+ this.input.off("message", this.handler);
90
+ if (this.input.isPortOpen()) {
91
+ this.input.closePort();
92
+ this._state = "disconnected";
93
+ }
94
+ } catch (err) {
95
+ console.error(`Error closing MIDI port ${this.portIndex}:`, err);
96
+ }
97
+ this.handler = null;
98
+ }
99
+ }
100
+ }
101
+
102
+ class NodeMidiAccess implements IMidiAccess {
103
+ private ports = new Map<string, NodeMidiInputPort>();
104
+ private MidiModule: NodeMidiModule;
105
+
106
+ constructor(MidiModule: NodeMidiModule) {
107
+ this.MidiModule = MidiModule;
108
+ this.scanPorts();
109
+ }
110
+
111
+ private scanPorts(): void {
112
+ try {
113
+ const input = new this.MidiModule.Input();
114
+ const portCount = input.getPortCount();
115
+
116
+ for (let i = 0; i < portCount; i++) {
117
+ const portName = input.getPortName(i);
118
+ const id = `node-midi-${i}`;
119
+
120
+ if (!this.ports.has(id)) {
121
+ // Create a new input instance for each port
122
+ const portInput = new this.MidiModule.Input();
123
+ const port = new NodeMidiInputPort(i, portName, portInput);
124
+ this.ports.set(id, port);
125
+ }
126
+ }
127
+
128
+ // Clean up the scanning input
129
+ if (input.isPortOpen()) {
130
+ input.closePort();
131
+ }
132
+ } catch (err) {
133
+ console.error("Error scanning MIDI ports:", err);
134
+ }
135
+ }
136
+
137
+ *inputs(): IterableIterator<IMidiInputPort> {
138
+ for (const [, port] of this.ports) {
139
+ yield port;
140
+ }
141
+ }
142
+
143
+ addEventListener(
144
+ _event: "statechange",
145
+ _callback: (port: IMidiInputPort) => void,
146
+ ): void {
147
+ // node-midi doesn't support hot-plugging detection
148
+ // This could be implemented with polling if needed
149
+ console.warn(
150
+ "Hot-plug detection not supported with node-midi adapter. Restart required for new devices.",
151
+ );
152
+ }
153
+ }
154
+
155
+ export default class NodeMidiAdapter implements IMidiAdapter {
156
+ async requestMIDIAccess(): Promise<IMidiAccess | null> {
157
+ try {
158
+ // Dynamic import to avoid bundling in browser builds
159
+ const midi = (await import("@julusian/midi")) as
160
+ | NodeMidiModule
161
+ | { default: NodeMidiModule };
162
+ const midiModule = "default" in midi ? midi.default : midi;
163
+ return new NodeMidiAccess(midiModule);
164
+ } catch (err) {
165
+ console.error("Error loading node-midi:", err);
166
+ return null;
167
+ }
168
+ }
169
+
170
+ isSupported(): boolean {
171
+ // Check if we're in Node.js environment
172
+ return isNode();
173
+ }
174
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Web MIDI API adapter for browsers
3
+ */
4
+ import type {
5
+ IMidiAccess,
6
+ IMidiAdapter,
7
+ IMidiInputPort,
8
+ MidiMessageCallback,
9
+ } from "./types";
10
+
11
+ class WebMidiInputPort implements IMidiInputPort {
12
+ private input: MIDIInput;
13
+ private callbacks = new Set<MidiMessageCallback>();
14
+ private handler: ((e: MIDIMessageEvent) => void) | null = null;
15
+
16
+ constructor(input: MIDIInput) {
17
+ this.input = input;
18
+ }
19
+
20
+ get id(): string {
21
+ return this.input.id;
22
+ }
23
+
24
+ get name(): string {
25
+ return this.input.name ?? `Device ${this.input.id}`;
26
+ }
27
+
28
+ get state(): "connected" | "disconnected" {
29
+ return this.input.state as "connected" | "disconnected";
30
+ }
31
+
32
+ addEventListener(callback: MidiMessageCallback): void {
33
+ if (this.callbacks.size === 0) {
34
+ this.handler = (e: MIDIMessageEvent) => {
35
+ if (!e.data) return;
36
+
37
+ const event = {
38
+ data: e.data,
39
+ timeStamp: e.timeStamp,
40
+ };
41
+
42
+ this.callbacks.forEach((cb) => {
43
+ cb(event);
44
+ });
45
+ };
46
+ this.input.addEventListener("midimessage", this.handler);
47
+ }
48
+ this.callbacks.add(callback);
49
+ }
50
+
51
+ removeEventListener(callback: MidiMessageCallback): void {
52
+ this.callbacks.delete(callback);
53
+
54
+ if (this.callbacks.size === 0 && this.handler) {
55
+ this.input.removeEventListener("midimessage", this.handler);
56
+ this.handler = null;
57
+ }
58
+ }
59
+ }
60
+
61
+ class WebMidiAccess implements IMidiAccess {
62
+ private midiAccess: MIDIAccess;
63
+ private portCache = new Map<string, WebMidiInputPort>();
64
+
65
+ constructor(midiAccess: MIDIAccess) {
66
+ this.midiAccess = midiAccess;
67
+ }
68
+
69
+ *inputs(): IterableIterator<IMidiInputPort> {
70
+ for (const [, input] of this.midiAccess.inputs) {
71
+ if (!this.portCache.has(input.id)) {
72
+ this.portCache.set(input.id, new WebMidiInputPort(input));
73
+ }
74
+ yield this.portCache.get(input.id)!;
75
+ }
76
+ }
77
+
78
+ addEventListener(
79
+ event: "statechange",
80
+ callback: (port: IMidiInputPort) => void,
81
+ ): void {
82
+ this.midiAccess.addEventListener(event, (e) => {
83
+ const port = e.port;
84
+ if (!port || port.type !== "input") return;
85
+
86
+ const input = port as MIDIInput;
87
+ if (!this.portCache.has(input.id)) {
88
+ this.portCache.set(input.id, new WebMidiInputPort(input));
89
+ }
90
+
91
+ callback(this.portCache.get(input.id)!);
92
+ });
93
+ }
94
+ }
95
+
96
+ export default class WebMidiAdapter implements IMidiAdapter {
97
+ async requestMIDIAccess(): Promise<IMidiAccess | null> {
98
+ try {
99
+ if (
100
+ typeof navigator === "undefined" ||
101
+ typeof navigator.requestMIDIAccess !== "function"
102
+ ) {
103
+ return null;
104
+ }
105
+
106
+ const midiAccess = await navigator.requestMIDIAccess();
107
+ return new WebMidiAccess(midiAccess);
108
+ } catch (err) {
109
+ console.error("Error enabling Web MIDI API:", err);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ isSupported(): boolean {
115
+ return (
116
+ typeof navigator !== "undefined" &&
117
+ typeof navigator.requestMIDIAccess === "function"
118
+ );
119
+ }
120
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * MIDI adapter factory
3
+ * Automatically selects the correct MIDI implementation based on the platform
4
+ */
5
+ import { isNode } from "es-toolkit";
6
+ import NodeMidiAdapter from "./NodeMidiAdapter";
7
+ import WebMidiAdapter from "./WebMidiAdapter";
8
+ import type { IMidiAdapter } from "./types";
9
+
10
+ export * from "./types";
11
+
12
+ /**
13
+ * Creates the appropriate MIDI adapter for the current platform
14
+ * @returns The MIDI adapter (Web MIDI API for browsers, node-midi for Node.js)
15
+ */
16
+ export function createMidiAdapter(): IMidiAdapter {
17
+ if (isNode()) {
18
+ return new NodeMidiAdapter();
19
+ }
20
+
21
+ // Default to Web MIDI API for browsers
22
+ return new WebMidiAdapter();
23
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Platform-agnostic MIDI interfaces
3
+ * Allows switching between Web MIDI API and node-midi without refactoring
4
+ */
5
+
6
+ export interface IMidiMessageEvent {
7
+ data: Uint8Array;
8
+ timeStamp: number;
9
+ }
10
+
11
+ export type MidiMessageCallback = (event: IMidiMessageEvent) => void;
12
+
13
+ export interface IMidiInputPort {
14
+ readonly id: string;
15
+ readonly name: string;
16
+ readonly state: "connected" | "disconnected";
17
+ addEventListener(callback: MidiMessageCallback): void;
18
+ removeEventListener(callback: MidiMessageCallback): void;
19
+ }
20
+
21
+ export interface IMidiAccess {
22
+ inputs(): IterableIterator<IMidiInputPort>;
23
+ addEventListener(
24
+ event: "statechange",
25
+ callback: (port: IMidiInputPort) => void,
26
+ ): void;
27
+ }
28
+
29
+ export interface IMidiAdapter {
30
+ requestMIDIAccess(): Promise<IMidiAccess | null>;
31
+ isSupported(): boolean;
32
+ }