@blibliki/engine 0.3.9 → 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.9",
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
- "jzz": "^1.9.6",
22
22
  "node-web-audio-api": "^1.0.3",
23
- "@blibliki/utils": "^0.3.9",
24
- "@blibliki/transport": "^0.3.9"
23
+ "@blibliki/utils": "^0.3.10",
24
+ "@blibliki/transport": "^0.3.10"
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsup",
@@ -1,7 +1,7 @@
1
1
  import { Context } from "@blibliki/utils";
2
- import type { JZZMidiMessage, JZZPort } from "./jzz.types";
3
- import MidiEvent, { MidiEventType } from "./MidiEvent";
4
2
  import Message from "./Message";
3
+ import MidiEvent, { MidiEventType } from "./MidiEvent";
4
+ import type { IMidiInputPort, IMidiMessageEvent } from "./adapters";
5
5
 
6
6
  export enum MidiPortState {
7
7
  connected = "connected",
@@ -26,12 +26,12 @@ export default class MidiDevice implements IMidiDevice {
26
26
  eventListerCallbacks: EventListerCallback[] = [];
27
27
 
28
28
  private context: Readonly<Context>;
29
- private input: JZZPort;
30
- private _state: MidiPortState = MidiPortState.connected;
29
+ private input: IMidiInputPort;
30
+ private messageHandler: ((event: IMidiMessageEvent) => void) | null = null;
31
31
 
32
- constructor(input: JZZPort, id: string, name: string, context: Context) {
33
- this.id = id;
34
- this.name = name || `Device ${id}`;
32
+ constructor(input: IMidiInputPort, context: Context) {
33
+ this.id = input.id;
34
+ this.name = input.name;
35
35
  this.input = input;
36
36
  this.context = context;
37
37
 
@@ -39,19 +39,21 @@ export default class MidiDevice implements IMidiDevice {
39
39
  }
40
40
 
41
41
  get state() {
42
- return this._state;
42
+ return this.input.state as MidiPortState;
43
43
  }
44
44
 
45
45
  connect() {
46
- // JZZ uses a callback function to receive MIDI messages
47
- this.input.connect((msg: JZZMidiMessage) => {
48
- this.processEvent(msg);
49
- });
46
+ this.messageHandler = (e: IMidiMessageEvent) => {
47
+ this.processEvent(e);
48
+ };
49
+ this.input.addEventListener(this.messageHandler);
50
50
  }
51
51
 
52
52
  disconnect() {
53
- this.input.close();
54
- this._state = MidiPortState.disconnected;
53
+ if (this.messageHandler) {
54
+ this.input.removeEventListener(this.messageHandler);
55
+ this.messageHandler = null;
56
+ }
55
57
  }
56
58
 
57
59
  serialize() {
@@ -70,16 +72,11 @@ export default class MidiDevice implements IMidiDevice {
70
72
  );
71
73
  }
72
74
 
73
- private processEvent(msg: JZZMidiMessage) {
74
- // Convert JZZ MIDI message to Uint8Array
75
- const data = new Uint8Array(msg.slice());
76
- const message = new Message(data);
77
-
78
- // Use current time as timestamp since JZZ doesn't provide precise timestamps
79
- const timestamp = performance.now();
75
+ private processEvent(event: IMidiMessageEvent) {
76
+ const message = new Message(event.data);
80
77
  const midiEvent = new MidiEvent(
81
78
  message,
82
- this.context.browserToContextTime(timestamp),
79
+ this.context.browserToContextTime(event.timeStamp),
83
80
  );
84
81
 
85
82
  switch (midiEvent.type) {
@@ -1,8 +1,7 @@
1
1
  import { Context } from "@blibliki/utils";
2
- import JZZ from "jzz";
3
- import type { JZZ as JZZType, JZZInputInfo, JZZPort } from "./jzz.types";
4
2
  import ComputerKeyboardDevice from "./ComputerKeyboardDevice";
5
3
  import MidiDevice from "./MidiDevice";
4
+ import { createMidiAdapter, type IMidiAccess } from "./adapters";
6
5
 
7
6
  type ListenerCallback = (device: MidiDevice) => void;
8
7
 
@@ -11,7 +10,8 @@ export default class MidiDeviceManager {
11
10
  private initialized = false;
12
11
  private listeners: ListenerCallback[] = [];
13
12
  private context: Readonly<Context>;
14
- private jzz: JZZType | null = null;
13
+ private midiAccess: IMidiAccess | null = null;
14
+ private adapter = createMidiAdapter();
15
15
 
16
16
  constructor(context: Context) {
17
17
  this.context = context;
@@ -41,21 +41,25 @@ export default class MidiDeviceManager {
41
41
  if (this.initialized) return;
42
42
 
43
43
  try {
44
- const jzz: JZZType = (await JZZ()) as unknown as JZZType;
45
- this.jzz = jzz;
46
- const info = jzz.info();
47
-
48
- // Get all MIDI input devices
49
- for (const inputInfo of info.inputs ?? []) {
50
- const id: string = inputInfo.id ?? inputInfo.name;
51
- if (!this.devices.has(id)) {
52
- const port = (await jzz.openMidiIn(inputInfo.name)) as unknown as JZZPort;
53
- const device = new MidiDevice(port, id, inputInfo.name, this.context);
54
- this.devices.set(id, device);
44
+ if (!this.adapter.isSupported()) {
45
+ console.warn("MIDI is not supported on this platform");
46
+ return;
47
+ }
48
+
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()) {
57
+ if (!this.devices.has(input.id)) {
58
+ this.devices.set(input.id, new MidiDevice(input, this.context));
55
59
  }
56
60
  }
57
61
  } catch (err) {
58
- console.error("Error enabling JZZ MIDI:", err);
62
+ console.error("Error enabling MIDI:", err);
59
63
  }
60
64
  }
61
65
 
@@ -67,53 +71,31 @@ export default class MidiDeviceManager {
67
71
  }
68
72
 
69
73
  private listenChanges() {
70
- if (!this.jzz) return;
71
-
72
- // JZZ watch for MIDI device changes
73
- this.jzz.onChange(() => {
74
- if (!this.jzz) return;
75
-
76
- const info = this.jzz.info();
77
- const currentInputIds = new Set<string>(
78
- (info.inputs ?? []).map((i: JZZInputInfo) => i.id ?? i.name),
79
- );
80
-
81
- // Check for new devices
82
- for (const inputInfo of info.inputs ?? []) {
83
- const id: string = inputInfo.id ?? inputInfo.name;
84
- if (!this.devices.has(id)) {
85
- // New device connected
86
- void this.jzz
87
- .openMidiIn(inputInfo.name)
88
- .then((port: unknown) => {
89
- const jzzPort = port as JZZPort;
90
- const device = new MidiDevice(
91
- jzzPort,
92
- id,
93
- inputInfo.name,
94
- this.context,
95
- );
96
- this.devices.set(id, device);
97
-
98
- this.listeners.forEach((listener) => {
99
- listener(device);
100
- });
101
- });
102
- }
103
- }
104
-
105
- // Check for removed devices
106
- for (const [id, device] of this.devices) {
107
- if (device instanceof ComputerKeyboardDevice) continue;
108
- if (!currentInputIds.has(id)) {
109
- // Device disconnected
110
- device.disconnect();
111
- this.devices.delete(id);
112
-
113
- this.listeners.forEach((listener) => {
114
- listener(device);
115
- });
116
- }
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
+ });
117
99
  }
118
100
  });
119
101
  }
@@ -1,6 +1,6 @@
1
1
  import { ContextTime } from "@blibliki/transport";
2
- import Message from "./Message";
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
+ }
@@ -1,51 +0,0 @@
1
- /**
2
- * Type definitions for JZZ MIDI library
3
- */
4
-
5
- export interface JZZInputInfo {
6
- id?: string;
7
- name: string;
8
- manufacturer?: string;
9
- version?: string;
10
- engine?: string;
11
- }
12
-
13
- export interface JZZOutputInfo {
14
- id?: string;
15
- name: string;
16
- manufacturer?: string;
17
- version?: string;
18
- engine?: string;
19
- }
20
-
21
- export interface JZZInfo {
22
- inputs?: JZZInputInfo[];
23
- outputs?: JZZOutputInfo[];
24
- version?: string;
25
- engine?: string;
26
- }
27
-
28
- export interface JZZMidiMessage extends Array<number> {
29
- slice(): number[];
30
- }
31
-
32
- export interface JZZPort {
33
- connect(callback: (msg: JZZMidiMessage) => void): JZZPort;
34
- close(): void;
35
- disconnect(): void;
36
- }
37
-
38
- // JZZ uses a custom Promise-like type called Async
39
- export interface JZZAsync<T> {
40
- then<TResult1 = T, TResult2 = never>(
41
- onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
42
- onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
43
- ): JZZAsync<TResult1 | TResult2>;
44
- }
45
-
46
- export interface JZZ {
47
- info(): JZZInfo;
48
- openMidiIn(name: string): JZZAsync<JZZPort>;
49
- openMidiOut(name: string): JZZAsync<JZZPort>;
50
- onChange(callback: () => void): void;
51
- }