@blibliki/engine 0.4.2 → 0.5.1

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.4.2",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -22,8 +22,8 @@
22
22
  "dependencies": {
23
23
  "@julusian/midi": "^3.6.1",
24
24
  "es-toolkit": "^1.41.0",
25
- "@blibliki/transport": "^0.4.2",
26
- "@blibliki/utils": "^0.4.2"
25
+ "@blibliki/transport": "^0.5.1",
26
+ "@blibliki/utils": "^0.5.1"
27
27
  },
28
28
  "scripts": {
29
29
  "build": "tsup",
package/src/Engine.ts CHANGED
@@ -269,6 +269,10 @@ export class Engine {
269
269
  return this.midiDeviceManager.findByName(name);
270
270
  }
271
271
 
272
+ findMidiDeviceByFuzzyName(name: string, threshold?: number) {
273
+ return this.midiDeviceManager.findByFuzzyName(name, threshold);
274
+ }
275
+
272
276
  onPropsUpdate(
273
277
  callback: <T extends ModuleType>(
274
278
  params: IModule<T> | IPolyModule<T>,
@@ -37,8 +37,15 @@ export default class Note implements INote {
37
37
  }
38
38
 
39
39
  static fromEvent(message: Message) {
40
- const name = Notes[message.data[1] % 12];
41
- const octave = Math.floor(message.data[1] / 12) - 2;
40
+ const dataByte = message.data[1];
41
+ if (dataByte === undefined) {
42
+ throw new Error("Invalid MIDI message: missing data byte");
43
+ }
44
+ const name = Notes[dataByte % 12];
45
+ if (!name) {
46
+ throw new Error(`Invalid MIDI note number: ${dataByte}`);
47
+ }
48
+ const octave = Math.floor(dataByte / 12) - 2;
42
49
 
43
50
  return new Note(`${name}${octave}`);
44
51
  }
@@ -95,9 +102,17 @@ export default class Note implements INote {
95
102
  }
96
103
 
97
104
  private fromString(string: string) {
98
- const matches = /(\w#?)(\d)?/.exec(string) ?? [];
105
+ const matches = /(\w#?)(\d)?/.exec(string);
106
+ if (!matches) {
107
+ throw new Error(`Invalid note string: ${string}`);
108
+ }
109
+
110
+ const name = matches[1];
111
+ if (!name) {
112
+ throw new Error(`Invalid note name in: ${string}`);
113
+ }
99
114
 
100
- this.name = matches[1];
115
+ this.name = name;
101
116
  this.octave = matches[2] ? parseInt(matches[2]) : 1;
102
117
  }
103
118
 
package/src/core/index.ts CHANGED
@@ -3,6 +3,7 @@ export type {
3
3
  IModule,
4
4
  IModuleSerialize,
5
5
  IPolyModuleSerialize,
6
+ IAnyModuleSerialize,
6
7
  SetterHooks,
7
8
  } from "./module";
8
9
 
@@ -15,6 +16,12 @@ export { default as MidiDeviceManager } from "./midi/MidiDeviceManager";
15
16
  export { default as MidiDevice, MidiPortState } from "./midi/MidiDevice";
16
17
  export type { IMidiDevice } from "./midi/MidiDevice";
17
18
  export { default as MidiEvent, MidiEventType } from "./midi/MidiEvent";
19
+ export {
20
+ normalizeDeviceName,
21
+ extractCoreTokens,
22
+ calculateSimilarity,
23
+ findBestMatch,
24
+ } from "./midi/deviceMatcher";
18
25
 
19
26
  export type {
20
27
  MidiOutput,
@@ -21,6 +21,7 @@ export default class Message {
21
21
  */
22
22
  get type(): string {
23
23
  const statusByte = this.data[0];
24
+ if (statusByte === undefined) return "unknown";
24
25
  const messageType = statusByte & 0xf0;
25
26
 
26
27
  switch (messageType) {
@@ -2,6 +2,7 @@ import { Context } from "@blibliki/utils";
2
2
  import ComputerKeyboardDevice from "./ComputerKeyboardDevice";
3
3
  import MidiDevice from "./MidiDevice";
4
4
  import { createMidiAdapter, type IMidiAccess } from "./adapters";
5
+ import { findBestMatch } from "./deviceMatcher";
5
6
 
6
7
  type ListenerCallback = (device: MidiDevice) => void;
7
8
 
@@ -33,6 +34,30 @@ export default class MidiDeviceManager {
33
34
  return Array.from(this.devices.values()).find((d) => d.name === name);
34
35
  }
35
36
 
37
+ /**
38
+ * Finds a device using fuzzy name matching
39
+ * Useful for matching devices across browser/Node.js environments where names differ
40
+ *
41
+ * @param targetName - The device name to match
42
+ * @param threshold - Minimum similarity score (0-1, default: 0.6)
43
+ * @returns The best matching device and confidence score, or null
44
+ */
45
+ findByFuzzyName(
46
+ targetName: string,
47
+ threshold = 0.6,
48
+ ): { device: MidiDevice | ComputerKeyboardDevice; score: number } | null {
49
+ const deviceEntries = Array.from(this.devices.values());
50
+ const candidateNames = deviceEntries.map((d) => d.name);
51
+
52
+ const match = findBestMatch(targetName, candidateNames, threshold);
53
+
54
+ if (!match) return null;
55
+
56
+ const device = deviceEntries.find((d) => d.name === match.name);
57
+
58
+ return device ? { device, score: match.score } : null;
59
+ }
60
+
36
61
  addListener(callback: ListenerCallback) {
37
62
  this.listeners.push(callback);
38
63
  }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Utilities for fuzzy matching MIDI device names across different platforms
3
+ *
4
+ * MIDI devices can have different names in browser vs Node.js:
5
+ * - Browser: "Launchkey Mini MK3 MIDI 1"
6
+ * - Node.js: "Launchkey Mini MK3:Launchkey Mini MK3 MIDI 1 20:0"
7
+ *
8
+ * This module provides fuzzy matching to find the same physical device
9
+ * across platforms based on name similarity.
10
+ */
11
+
12
+ /**
13
+ * Normalizes a MIDI device name by:
14
+ * - Converting to lowercase
15
+ * - Removing common suffixes/prefixes
16
+ * - Removing port numbers and ALSA identifiers
17
+ * - Removing extra whitespace
18
+ * - Removing special characters
19
+ */
20
+ export function normalizeDeviceName(name: string): string {
21
+ let normalized = name.toLowerCase();
22
+
23
+ // First, remove ALSA port numbers (e.g., "20:0" at the very end)
24
+ // Do this BEFORE splitting by colons
25
+ normalized = normalized.replace(/\s+\d+:\d+\s*$/g, "");
26
+
27
+ // Remove colon-separated duplicates (Node.js format: "Device:Device Port")
28
+ const parts = normalized.split(":");
29
+ if (parts.length > 1) {
30
+ // Take the longest part as it usually has more info
31
+ normalized = parts.reduce((longest, current) =>
32
+ current.length > longest.length ? current : longest,
33
+ );
34
+ }
35
+
36
+ // Remove common port descriptors (but keep the number if it's part of the device name)
37
+ // This regex only matches MIDI/Input/Output/Port at the END of the string
38
+ normalized = normalized.replace(
39
+ /\s+(midi|input|output|port)(\s+\d+)?$/gi,
40
+ "",
41
+ );
42
+
43
+ // Remove "device" prefix if present at the start
44
+ normalized = normalized.replace(/^device\s+/gi, "");
45
+
46
+ // Remove multiple spaces
47
+ normalized = normalized.replace(/\s+/g, " ").trim();
48
+
49
+ return normalized;
50
+ }
51
+
52
+ /**
53
+ * Extracts core device name tokens for matching
54
+ * Removes generic words and focuses on manufacturer/model identifiers
55
+ */
56
+ export function extractCoreTokens(name: string): string[] {
57
+ const normalized = normalizeDeviceName(name);
58
+
59
+ // Split into tokens
60
+ const tokens = normalized.split(/[\s\-_:]+/);
61
+
62
+ // Filter out very short tokens and common generic words
63
+ const genericWords = new Set([
64
+ "midi",
65
+ "input",
66
+ "output",
67
+ "port",
68
+ "device",
69
+ "in",
70
+ "out",
71
+ ]);
72
+
73
+ return tokens.filter((token) => token.length > 1 && !genericWords.has(token));
74
+ }
75
+
76
+ /**
77
+ * Calculates Levenshtein distance between two strings
78
+ * Used for fuzzy string matching
79
+ */
80
+ function levenshteinDistance(str1: string, str2: string): number {
81
+ const len1 = str1.length;
82
+ const len2 = str2.length;
83
+ const matrix: number[][] = [];
84
+
85
+ // Initialize matrix
86
+ for (let i = 0; i <= len1; i++) {
87
+ matrix[i] = [i];
88
+ }
89
+ for (let j = 0; j <= len2; j++) {
90
+ if (matrix[0]) {
91
+ matrix[0][j] = j;
92
+ }
93
+ }
94
+
95
+ // Fill matrix
96
+ for (let i = 1; i <= len1; i++) {
97
+ for (let j = 1; j <= len2; j++) {
98
+ const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
99
+ const prevRow = matrix[i - 1];
100
+ const currRow = matrix[i];
101
+ const prevCell = currRow?.[j - 1];
102
+ const diagCell = prevRow?.[j - 1];
103
+
104
+ const prevCellInRow = prevRow?.[j];
105
+ if (
106
+ currRow &&
107
+ prevCellInRow !== undefined &&
108
+ prevCell !== undefined &&
109
+ diagCell !== undefined
110
+ ) {
111
+ currRow[j] = Math.min(
112
+ prevCellInRow + 1, // deletion
113
+ prevCell + 1, // insertion
114
+ diagCell + cost, // substitution
115
+ );
116
+ }
117
+ }
118
+ }
119
+
120
+ const lastRow = matrix[len1];
121
+ return lastRow?.[len2] ?? 0;
122
+ }
123
+
124
+ /**
125
+ * Calculates similarity score between two device names
126
+ * Returns a score between 0 (no match) and 1 (perfect match)
127
+ *
128
+ * Uses multiple strategies:
129
+ * 1. Exact normalized match (score: 1.0)
130
+ * 2. Token overlap (Jaccard similarity)
131
+ * 3. String similarity (based on Levenshtein distance)
132
+ * 4. Substring matching
133
+ */
134
+ export function calculateSimilarity(name1: string, name2: string): number {
135
+ const normalized1 = normalizeDeviceName(name1);
136
+ const normalized2 = normalizeDeviceName(name2);
137
+
138
+ // Exact match after normalization
139
+ if (normalized1 === normalized2) {
140
+ return 1.0;
141
+ }
142
+
143
+ const tokens1 = new Set(extractCoreTokens(name1));
144
+ const tokens2 = new Set(extractCoreTokens(name2));
145
+
146
+ // If no tokens extracted, fall back to pure string similarity
147
+ if (tokens1.size === 0 || tokens2.size === 0) {
148
+ const maxLen = Math.max(normalized1.length, normalized2.length);
149
+ if (maxLen === 0) return 0;
150
+ const distance = levenshteinDistance(normalized1, normalized2);
151
+ return 1 - distance / maxLen;
152
+ }
153
+
154
+ // Calculate Jaccard similarity for tokens
155
+ const intersection = new Set([...tokens1].filter((x) => tokens2.has(x)));
156
+ const union = new Set([...tokens1, ...tokens2]);
157
+ const jaccardScore = intersection.size / union.size;
158
+
159
+ // Calculate string similarity
160
+ const maxLen = Math.max(normalized1.length, normalized2.length);
161
+ const distance = levenshteinDistance(normalized1, normalized2);
162
+ const stringScore = 1 - distance / maxLen;
163
+
164
+ // Check for substring matches
165
+ const substringScore =
166
+ normalized1.includes(normalized2) || normalized2.includes(normalized1)
167
+ ? 0.8
168
+ : 0;
169
+
170
+ // Weighted combination of scores
171
+ // Token overlap is most important, then string similarity, then substring
172
+ const finalScore =
173
+ jaccardScore * 0.5 + stringScore * 0.3 + substringScore * 0.2;
174
+
175
+ return finalScore;
176
+ }
177
+
178
+ /**
179
+ * Finds the best matching device name from a list of candidates
180
+ * Returns the best match and its confidence score
181
+ *
182
+ * @param targetName - The name to match against
183
+ * @param candidateNames - List of possible matches
184
+ * @param threshold - Minimum similarity score to consider a match (default: 0.6)
185
+ * @returns Best match and score, or null if no match above threshold
186
+ */
187
+ export function findBestMatch(
188
+ targetName: string,
189
+ candidateNames: string[],
190
+ threshold = 0.6,
191
+ ): { name: string; score: number } | null {
192
+ let bestMatch: { name: string; score: number } | null = null;
193
+
194
+ for (const candidateName of candidateNames) {
195
+ const score = calculateSimilarity(targetName, candidateName);
196
+
197
+ if (score >= threshold && (!bestMatch || score > bestMatch.score)) {
198
+ bestMatch = { name: candidateName, score };
199
+ }
200
+ }
201
+
202
+ return bestMatch;
203
+ }
@@ -1,5 +1,11 @@
1
1
  import { ContextTime } from "@blibliki/transport";
2
- import { Context, Optional, upperFirst, uuidv4 } from "@blibliki/utils";
2
+ import {
3
+ Context,
4
+ Optional,
5
+ upperFirst,
6
+ uuidv4,
7
+ requestAnimationFrame,
8
+ } from "@blibliki/utils";
3
9
  import { Engine } from "@/Engine";
4
10
  import { AnyModule, ModuleType, ModuleTypeToPropsMapping } from "@/modules";
5
11
  import {
@@ -1,5 +1,10 @@
1
1
  import { ContextTime } from "@blibliki/transport";
2
- import { deterministicId, Optional, uuidv4 } from "@blibliki/utils";
2
+ import {
3
+ deterministicId,
4
+ Optional,
5
+ uuidv4,
6
+ requestAnimationFrame,
7
+ } from "@blibliki/utils";
3
8
  import { Engine } from "@/Engine";
4
9
  import { ModuleType, ModuleTypeToPropsMapping } from "@/modules";
5
10
  import {
@@ -102,9 +102,15 @@ export default class VoiceScheduler extends PolyModule<ModuleType.VoiceScheduler
102
102
  let voice = this.audioModules.find((v) => !v.activeNote);
103
103
 
104
104
  // If no available voice, get the one with the lowest triggeredAt
105
- voice ??= this.audioModules.sort((a, b) => {
106
- return a.triggeredAt - b.triggeredAt;
107
- })[0];
105
+ if (!voice) {
106
+ const sorted = this.audioModules.sort((a, b) => {
107
+ return a.triggeredAt - b.triggeredAt;
108
+ });
109
+ voice = sorted[0];
110
+ if (!voice) {
111
+ throw new Error("No voices available in voice scheduler");
112
+ }
113
+ }
108
114
 
109
115
  return voice;
110
116
  }
@@ -1,3 +1,11 @@
1
+ import { ModuleType } from "@/modules";
2
+ import { IModuleSerialize } from "./Module";
3
+ import { IPolyModuleSerialize } from "./PolyModule";
4
+
1
5
  export { Module } from "./Module";
2
6
  export type { IModule, IModuleSerialize, SetterHooks } from "./Module";
3
7
  export type { IPolyModule, IPolyModuleSerialize } from "./PolyModule";
8
+
9
+ export type IAnyModuleSerialize =
10
+ | IModuleSerialize<ModuleType>
11
+ | IPolyModuleSerialize<ModuleType>;
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export type {
7
7
  IModule,
8
8
  IModuleSerialize,
9
9
  IPolyModuleSerialize,
10
+ IAnyModuleSerialize,
10
11
  IMidiDevice,
11
12
  ModulePropSchema,
12
13
  PropSchema,
@@ -60,7 +60,8 @@ export default class Inspector
60
60
  }
61
61
 
62
62
  getValue(): number {
63
- return this.getValues()[0];
63
+ const value = this.getValues()[0];
64
+ return value ?? 0;
64
65
  }
65
66
 
66
67
  getValues(): Float32Array {
@@ -125,6 +125,7 @@ export default class MidiMapper
125
125
  this.checkAutoAssign(event);
126
126
 
127
127
  const activePage = this.props.pages[this.props.activePage];
128
+ if (!activePage) return;
128
129
 
129
130
  [
130
131
  ...this.props.globalMappings.filter((m) => m.cc === event.cc),
@@ -245,6 +246,8 @@ export default class MidiMapper
245
246
  if (event.cc === undefined) return;
246
247
 
247
248
  const activePage = this.props.pages[this.props.activePage];
249
+ if (!activePage) return;
250
+
248
251
  const hasGlobalAutoAssign = this.props.globalMappings.some(
249
252
  ({ autoAssign }) => autoAssign,
250
253
  );
@@ -45,11 +45,32 @@ export default class MidiSelector
45
45
  props,
46
46
  });
47
47
 
48
- const midiDevice =
49
- (this.props.selectedId &&
50
- this.engine.findMidiDevice(this.props.selectedId)) ??
51
- (this.props.selectedName &&
52
- this.engine.findMidiDeviceByName(this.props.selectedName));
48
+ // Try to find device in order of preference:
49
+ // 1. By exact ID match
50
+ // 2. By exact name match
51
+ // 3. By fuzzy name match (for cross-platform compatibility)
52
+ let midiDevice =
53
+ this.props.selectedId &&
54
+ this.engine.findMidiDevice(this.props.selectedId);
55
+
56
+ if (!midiDevice && this.props.selectedName) {
57
+ midiDevice = this.engine.findMidiDeviceByName(this.props.selectedName);
58
+
59
+ // If exact name match fails, try fuzzy matching
60
+ if (!midiDevice) {
61
+ const fuzzyMatch = this.engine.findMidiDeviceByFuzzyName(
62
+ this.props.selectedName,
63
+ 0.6, // 60% similarity threshold
64
+ );
65
+
66
+ if (fuzzyMatch) {
67
+ midiDevice = fuzzyMatch.device;
68
+ console.log(
69
+ `MIDI device fuzzy matched: "${this.props.selectedName}" -> "${midiDevice.name}" (confidence: ${Math.round(fuzzyMatch.score * 100)}%)`,
70
+ );
71
+ }
72
+ }
73
+ }
53
74
 
54
75
  if (midiDevice) {
55
76
  this.addEventListener(midiDevice);
@@ -37,29 +37,39 @@ export const filterProcessorURL = URL.createObjectURL(
37
37
  ): boolean {
38
38
  const input = inputs[0];
39
39
  const output = outputs[0];
40
+ if (!input || !output) return true;
40
41
 
41
42
  const cutoff = parameters.cutoff;
42
43
  const resonance = parameters.resonance;
44
+ if (!cutoff || !resonance) return true;
43
45
 
44
46
  for (let channelNum = 0; channelNum < input.length; channelNum++) {
45
47
  const inputChannel = input[channelNum];
46
48
  const outputChannel = output[channelNum];
49
+ if (!inputChannel || !outputChannel) continue;
47
50
 
48
51
  for (let i = 0; i < inputChannel.length; i++) {
49
52
  const s = inputChannel[i];
53
+ if (s === undefined) continue;
54
+
50
55
  // Convert Hz to normalized frequency using logarithmic scale
51
56
  // This better matches human hearing perception
52
- const cutoffHz = cutoff.length > 1 ? cutoff[i] : cutoff[0];
57
+ const cutoffHz =
58
+ cutoff.length > 1 ? (cutoff[i] ?? cutoff[0]) : cutoff[0];
59
+ if (cutoffHz === undefined) continue;
60
+
53
61
  const clampedHz = Math.max(20, Math.min(20000, cutoffHz));
54
62
  const normalizedCutoff =
55
63
  Math.log(clampedHz / 20) / Math.log(20000 / 20);
56
64
  const c = Math.pow(0.5, (1 - normalizedCutoff) / 0.125);
57
- const r = Math.pow(
58
- 0.5,
59
- ((resonance.length > 1 ? resonance[i] : resonance[0]) +
60
- 0.125) /
61
- 0.125,
62
- );
65
+
66
+ const resonanceValue =
67
+ resonance.length > 1
68
+ ? (resonance[i] ?? resonance[0])
69
+ : resonance[0];
70
+ if (resonanceValue === undefined) continue;
71
+
72
+ const r = Math.pow(0.5, (resonanceValue + 0.125) / 0.125);
63
73
  const mrc = 1 - r * c;
64
74
 
65
75
  this.s0 = mrc * this.s0 - c * this.s1 + c * s;
@@ -28,17 +28,20 @@ export const scaleProcessorURL = URL.createObjectURL(
28
28
  ) {
29
29
  const input = inputs[0];
30
30
  const output = outputs[0];
31
+ if (!input || !output) return true;
31
32
 
32
33
  const minValues = parameters.min;
33
34
  const maxValues = parameters.max;
34
35
  const currentValues = parameters.current;
36
+ if (!minValues || !maxValues || !currentValues) return true;
35
37
 
36
- if (!input.length || input[0].length === 0) {
38
+ const firstInput = input[0];
39
+ if (!firstInput || firstInput.length === 0) {
37
40
  for (const outputChannel of output) {
38
41
  const current =
39
- parameters.current.length > 1
40
- ? parameters.current[0]
41
- : parameters.current[0];
42
+ currentValues.length > 1
43
+ ? (currentValues[0] ?? 0.5)
44
+ : (currentValues[0] ?? 0.5);
42
45
 
43
46
  outputChannel.fill(current);
44
47
  }
@@ -49,17 +52,32 @@ export const scaleProcessorURL = URL.createObjectURL(
49
52
  for (let channel = 0; channel < input.length; channel++) {
50
53
  const inputChannel = input[channel];
51
54
  const outputChannel = output[channel];
55
+ if (!inputChannel || !outputChannel) continue;
52
56
 
53
57
  for (let i = 0; i < inputChannel.length; i++) {
54
58
  const x = inputChannel[i];
59
+ if (x === undefined) continue;
55
60
 
56
- const min = minValues.length > 1 ? minValues[i] : minValues[0];
57
- const max = maxValues.length > 1 ? maxValues[i] : maxValues[0];
61
+ const min =
62
+ minValues.length > 1
63
+ ? (minValues[i] ?? minValues[0])
64
+ : minValues[0];
65
+ const max =
66
+ maxValues.length > 1
67
+ ? (maxValues[i] ?? maxValues[0])
68
+ : maxValues[0];
58
69
  const current =
59
70
  currentValues.length > 1
60
- ? currentValues[i]
71
+ ? (currentValues[i] ?? currentValues[0])
61
72
  : currentValues[0];
62
73
 
74
+ if (
75
+ min === undefined ||
76
+ max === undefined ||
77
+ current === undefined
78
+ )
79
+ continue;
80
+
63
81
  if (x < 0) {
64
82
  outputChannel[i] = current * Math.pow(min / current, -x);
65
83
  } else {