@blibliki/engine 0.3.5 → 0.3.6

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.5",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "source": "src/index.ts",
6
6
  "main": "dist/index.cjs",
@@ -12,16 +12,16 @@
12
12
  "dist"
13
13
  ],
14
14
  "devDependencies": {
15
- "@types/audioworklet": "^0.0.87",
15
+ "@types/audioworklet": "^0.0.91",
16
16
  "vite-tsconfig-paths": "^5.1.4",
17
- "vitest": "^3.0.7"
17
+ "vitest": "^4.0.6"
18
18
  },
19
19
  "dependencies": {
20
- "@ircam/sc-scheduling": "^1.0.0",
20
+ "es-toolkit": "^1.41.0",
21
21
  "node-web-audio-api": "^1.0.3",
22
22
  "webmidi": "^3.1.14",
23
- "@blibliki/transport": "^0.3.4",
24
- "@blibliki/utils": "^0.3.4"
23
+ "@blibliki/transport": "^0.3.6",
24
+ "@blibliki/utils": "^0.3.6"
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsup",
package/src/Engine.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  ModuleTypeToModuleMapping,
21
21
  createModule,
22
22
  } from "@/modules";
23
- import { PolyModule } from "./core/module/PolyModule";
23
+ import { IPolyModule, PolyModule } from "./core/module/PolyModule";
24
24
  import { loadProcessors } from "./processors";
25
25
 
26
26
  export type IUpdateModule<T extends ModuleType> = {
@@ -37,7 +37,7 @@ export class Engine {
37
37
  private static _engines = new Map<string, Engine>();
38
38
  private static _currentId: string | undefined;
39
39
  private propsUpdateCallbacks: (<T extends ModuleType>(
40
- params: IModule<T>,
40
+ params: IModule<T> | IPolyModule<T>,
41
41
  ) => void)[] = [];
42
42
 
43
43
  readonly id: string;
@@ -219,11 +219,17 @@ export class Engine {
219
219
  return this.midiDeviceManager.find(id);
220
220
  }
221
221
 
222
- onPropsUpdate(callback: <T extends ModuleType>(params: IModule<T>) => void) {
222
+ onPropsUpdate(
223
+ callback: <T extends ModuleType>(
224
+ params: IModule<T> | IPolyModule<T>,
225
+ ) => void,
226
+ ) {
223
227
  this.propsUpdateCallbacks.push(callback);
224
228
  }
225
229
 
226
- _triggerPropsUpdate<T extends ModuleType>(params: IModule<T>) {
230
+ _triggerPropsUpdate<T extends ModuleType>(
231
+ params: IModule<T> | IPolyModule<T>,
232
+ ) {
227
233
  this.propsUpdateCallbacks.forEach((callback) => {
228
234
  callback(params);
229
235
  });
@@ -1,4 +1,5 @@
1
1
  import { assertNever } from "@blibliki/utils";
2
+ import { sortBy } from "es-toolkit";
2
3
  import { ModuleType } from "@/modules";
3
4
  import { Module } from "../module";
4
5
  import { PolyModule } from "../module/PolyModule";
@@ -129,7 +130,9 @@ export default abstract class IOCollection<T extends CollectionType> {
129
130
  }
130
131
 
131
132
  serialize() {
132
- return this.collection.map((io) => io.serialize());
133
+ return sortBy(this.collection, [(io) => (io.isMidi() ? -1 : 1)]).map((io) =>
134
+ io.serialize(),
135
+ );
133
136
  }
134
137
 
135
138
  private validateUniqName(name: string) {
package/src/core/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export { Module } from "./module";
2
- export type { IModule, IModuleSerialize, IPolyModuleSerialize } from "./module";
2
+ export type {
3
+ IModule,
4
+ IModuleSerialize,
5
+ IPolyModuleSerialize,
6
+ SetterHooks,
7
+ } from "./module";
3
8
 
4
9
  export type IAnyAudioContext = AudioContext | OfflineAudioContext;
5
10
 
@@ -20,7 +25,7 @@ export type {
20
25
  } from "./IO";
21
26
 
22
27
  export type {
23
- PropDefinition,
28
+ ModulePropSchema,
24
29
  PropSchema,
25
30
  NumberProp,
26
31
  StringProp,
@@ -75,6 +75,7 @@ export default class MidiDevice implements IMidiDevice {
75
75
  switch (midiEvent.type) {
76
76
  case MidiEventType.noteOn:
77
77
  case MidiEventType.noteOff:
78
+ case MidiEventType.cc:
78
79
  this.eventListerCallbacks.forEach((callback) => {
79
80
  callback(midiEvent);
80
81
  });
@@ -5,7 +5,7 @@ import Note, { INote } from "../Note";
5
5
  export enum MidiEventType {
6
6
  noteOn = "noteon",
7
7
  noteOff = "noteoff",
8
- cc = "cc",
8
+ cc = "controlchange",
9
9
  }
10
10
 
11
11
  export default class MidiEvent {
@@ -51,6 +51,22 @@ export default class MidiEvent {
51
51
  );
52
52
  }
53
53
 
54
+ get isCC() {
55
+ return this.type === MidiEventType.cc;
56
+ }
57
+
58
+ get cc(): number | undefined {
59
+ if (!this.isCC) return;
60
+
61
+ return this.message.dataBytes[0];
62
+ }
63
+
64
+ get ccValue(): number | undefined {
65
+ if (!this.isCC) return;
66
+
67
+ return this.message.dataBytes[1];
68
+ }
69
+
54
70
  defineNotes() {
55
71
  if (!this.isNote) return;
56
72
  if (this.note) return;
@@ -35,6 +35,49 @@ export type IModuleConstructor<T extends ModuleType> = Optional<
35
35
  audioNodeConstructor?: (context: Context) => AudioNode;
36
36
  };
37
37
 
38
+ /**
39
+ * Helper type for type-safe property lifecycle hooks.
40
+ *
41
+ * Hooks are completely optional - only define the ones you need.
42
+ * Use explicit type annotation for automatic type inference.
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * export type IGainProps = {
47
+ * gain: number;
48
+ * muted: boolean;
49
+ * };
50
+ *
51
+ * export class MonoGain extends Module<ModuleType.Gain> {
52
+ * // ✅ Define only the hooks you need with type annotation
53
+ * // value type is automatically inferred as number!
54
+ * onSetGain: SetterHooks<IGainProps>["onSetGain"] = (value) => {
55
+ * this.audioNode.gain.value = value;
56
+ * return value; // optional: return modified value
57
+ * };
58
+ *
59
+ * // ✅ onAfterSet is called after prop is set
60
+ * onAfterSetMuted: SetterHooks<IGainProps>["onAfterSetMuted"] = (value) => {
61
+ * if (value) this.audioNode.gain.value = 0;
62
+ * };
63
+ *
64
+ * // ✅ You can omit hooks you don't need - they're optional!
65
+ * // No need to define onSetMuted if you don't need it
66
+ *
67
+ * // ❌ This would cause a type error:
68
+ * // onSetGain: SetterHooks<IGainProps>["onSetGain"] = (value: string) => value;
69
+ * // ^^^^^^ Error!
70
+ * }
71
+ * ```
72
+ */
73
+ export type SetterHooks<P> = {
74
+ [K in keyof P as `onSet${Capitalize<string & K>}`]: (value: P[K]) => P[K];
75
+ } & {
76
+ [K in keyof P as `onAfterSet${Capitalize<string & K>}`]: (
77
+ value: P[K],
78
+ ) => void;
79
+ };
80
+
38
81
  export abstract class Module<T extends ModuleType> implements IModule<T> {
39
82
  id: string;
40
83
  engineId: string;
@@ -47,6 +90,7 @@ export abstract class Module<T extends ModuleType> implements IModule<T> {
47
90
  protected _props!: ModuleTypeToPropsMapping[T];
48
91
  protected superInitialized = false;
49
92
  protected activeNotes: Note[];
93
+ private pendingUIUpdates = false;
50
94
 
51
95
  constructor(engineId: string, params: IModuleConstructor<T>) {
52
96
  const { id, name, moduleType, voiceNo, audioNodeConstructor, props } =
@@ -59,13 +103,17 @@ export abstract class Module<T extends ModuleType> implements IModule<T> {
59
103
  this.voiceNo = voiceNo ?? 0;
60
104
  this.activeNotes = [];
61
105
  this.audioNode = audioNodeConstructor?.(this.context);
62
- this._props = {} as ModuleTypeToPropsMapping[T];
63
- this.props = props;
106
+ this._props = props;
64
107
 
65
108
  this.inputs = new InputCollection(this);
66
109
  this.outputs = new OutputCollection(this);
67
110
 
68
111
  this.superInitialized = true;
112
+
113
+ // Defer hook calls until after subclass is fully initialized
114
+ queueMicrotask(() => {
115
+ this.props = props;
116
+ });
69
117
  }
70
118
 
71
119
  get props(): ModuleTypeToPropsMapping[T] {
@@ -73,25 +121,51 @@ export abstract class Module<T extends ModuleType> implements IModule<T> {
73
121
  }
74
122
 
75
123
  set props(value: Partial<ModuleTypeToPropsMapping[T]>) {
76
- Object.keys(value).forEach((key) => {
77
- const onSetAttr = `onSet${upperFirst(key)}`;
78
-
79
- // @ts-expect-error TS7053 ignore this error
80
- // eslint-disable-next-line
81
- this[onSetAttr]?.(value[key]);
82
- });
83
-
84
- this._props = { ...this._props, ...value };
124
+ const updatedValue = { ...value };
125
+
126
+ (Object.keys(value) as (keyof ModuleTypeToPropsMapping[T])[]).forEach(
127
+ (key) => {
128
+ const propValue = value[key];
129
+ if (propValue !== undefined) {
130
+ const result = this.callPropHook("onSet", key, propValue);
131
+ if (result !== undefined) {
132
+ updatedValue[key] = result;
133
+ }
134
+ }
135
+ },
136
+ );
85
137
 
86
- Object.keys(value).forEach((key) => {
87
- const onSetAttr = `onAfterSet${upperFirst(key)}`;
138
+ this._props = { ...this._props, ...updatedValue };
88
139
 
89
- // @ts-expect-error TS7053 ignore this error
90
- // eslint-disable-next-line
91
- this[onSetAttr]?.(value[key]);
140
+ (
141
+ Object.keys(updatedValue) as (keyof ModuleTypeToPropsMapping[T])[]
142
+ ).forEach((key) => {
143
+ const propValue = updatedValue[key];
144
+ if (propValue !== undefined) {
145
+ this.callPropHook("onAfterSet", key, propValue);
146
+ }
92
147
  });
93
148
  }
94
149
 
150
+ private callPropHook<K extends keyof ModuleTypeToPropsMapping[T]>(
151
+ hookType: "onSet" | "onAfterSet",
152
+ key: K,
153
+ value: ModuleTypeToPropsMapping[T][K],
154
+ ): ModuleTypeToPropsMapping[T][K] | undefined {
155
+ const hookName = `${hookType}${upperFirst(key as string)}`;
156
+ const hook = this[hookName as keyof this];
157
+
158
+ if (typeof hook === "function") {
159
+ const result = (
160
+ hook as (
161
+ value: ModuleTypeToPropsMapping[T][K],
162
+ ) => ModuleTypeToPropsMapping[T][K] | undefined
163
+ ).call(this, value);
164
+ return result;
165
+ }
166
+ return undefined;
167
+ }
168
+
95
169
  serialize(): IModuleSerialize<T> {
96
170
  return {
97
171
  id: this.id,
@@ -149,6 +223,10 @@ export abstract class Module<T extends ModuleType> implements IModule<T> {
149
223
  );
150
224
  }
151
225
 
226
+ handleCC(_event: MidiEvent, _triggeredAt: ContextTime): void {
227
+ // Optional implementation in modules
228
+ }
229
+
152
230
  onMidiEvent = (midiEvent: MidiEvent) => {
153
231
  const { note, triggeredAt } = midiEvent;
154
232
 
@@ -160,18 +238,31 @@ export abstract class Module<T extends ModuleType> implements IModule<T> {
160
238
  case MidiEventType.noteOff:
161
239
  this.triggerRelease(note!, triggeredAt);
162
240
  break;
241
+ case MidiEventType.cc:
242
+ this.handleCC(midiEvent, triggeredAt);
243
+ break;
163
244
  default:
164
245
  throw Error("This type is not a note");
165
246
  }
166
247
  };
167
248
 
168
- protected triggerPropsUpdate() {
169
- this.engine._triggerPropsUpdate({
170
- id: this.id,
171
- moduleType: this.moduleType,
172
- voiceNo: this.voiceNo,
173
- name: this.name,
174
- props: this.props,
249
+ triggerPropsUpdate = () => {
250
+ if (this.pendingUIUpdates) return;
251
+
252
+ this.pendingUIUpdates = true;
253
+ this.sheduleTriggerUpdate();
254
+ };
255
+
256
+ private sheduleTriggerUpdate() {
257
+ requestAnimationFrame(() => {
258
+ this.engine._triggerPropsUpdate({
259
+ id: this.id,
260
+ moduleType: this.moduleType,
261
+ voiceNo: this.voiceNo,
262
+ name: this.name,
263
+ props: this.props,
264
+ });
265
+ this.pendingUIUpdates = false;
175
266
  });
176
267
  }
177
268
 
@@ -47,6 +47,7 @@ export abstract class PolyModule<T extends ModuleType>
47
47
  protected superInitialized = false;
48
48
  private _voices!: number;
49
49
  private _name!: string;
50
+ private pendingUIUpdates = false;
50
51
 
51
52
  constructor(engineId: string, params: IPolyModuleConstructor<T>) {
52
53
  const { id, name, moduleType, voices, monoModuleConstructor, props } =
@@ -60,8 +61,7 @@ export abstract class PolyModule<T extends ModuleType>
60
61
  this.name = name;
61
62
  this.moduleType = moduleType;
62
63
  this.voices = voices || 1;
63
- this._props = {} as ModuleTypeToPropsMapping[T];
64
- this.props = props;
64
+ this._props = props;
65
65
 
66
66
  this.inputs = new InputCollection(
67
67
  this as unknown as PolyModule<ModuleType>,
@@ -71,6 +71,11 @@ export abstract class PolyModule<T extends ModuleType>
71
71
  );
72
72
 
73
73
  this.superInitialized = true;
74
+
75
+ // Defer hook calls until after subclass is fully initialized
76
+ queueMicrotask(() => {
77
+ this.props = props;
78
+ });
74
79
  }
75
80
 
76
81
  get name() {
@@ -166,6 +171,26 @@ export abstract class PolyModule<T extends ModuleType>
166
171
  audioModule.onMidiEvent(midiEvent);
167
172
  };
168
173
 
174
+ triggerPropsUpdate = () => {
175
+ if (this.pendingUIUpdates) return;
176
+
177
+ this.pendingUIUpdates = true;
178
+ this.sheduleTriggerUpdate();
179
+ };
180
+
181
+ private sheduleTriggerUpdate() {
182
+ requestAnimationFrame(() => {
183
+ this.engine._triggerPropsUpdate({
184
+ id: this.id,
185
+ moduleType: this.moduleType,
186
+ voices: this.voices,
187
+ name: this.name,
188
+ props: this.props,
189
+ });
190
+ this.pendingUIUpdates = false;
191
+ });
192
+ }
193
+
169
194
  findVoice(voiceNo: number) {
170
195
  const moduleByVoice = this.audioModules.find((m) => m.voiceNo === voiceNo);
171
196
  if (!moduleByVoice)
@@ -3,12 +3,13 @@ import { EmptyObject } from "@blibliki/utils";
3
3
  import { ICreateModule, ModuleType } from "@/modules";
4
4
  import { MidiOutput } from "../IO";
5
5
  import MidiEvent, { MidiEventType } from "../midi/MidiEvent";
6
- import { PropSchema } from "../schema";
6
+ import { ModulePropSchema } from "../schema";
7
7
  import { IModuleConstructor, Module } from "./Module";
8
8
  import { IPolyModuleConstructor, PolyModule } from "./PolyModule";
9
9
 
10
10
  export type IVoiceSchedulerProps = EmptyObject;
11
- export const voiceSchedulerPropSchema: PropSchema<IVoiceSchedulerProps> = {};
11
+ export const voiceSchedulerPropSchema: ModulePropSchema<IVoiceSchedulerProps> =
12
+ {};
12
13
  const DEFAULT_PROPS = {};
13
14
 
14
15
  class Voice extends Module<ModuleType.VoiceScheduler> {
@@ -1,3 +1,3 @@
1
1
  export { Module } from "./Module";
2
- export type { IModule, IModuleSerialize } from "./Module";
2
+ export type { IModule, IModuleSerialize, SetterHooks } from "./Module";
3
3
  export type { IPolyModule, IPolyModuleSerialize } from "./PolyModule";
@@ -1,3 +1,5 @@
1
+ import { EmptyObject } from "@blibliki/utils";
2
+
1
3
  type BasePropType = {
2
4
  label?: string;
3
5
  description?: string;
@@ -8,6 +10,7 @@ export type NumberProp = BasePropType & {
8
10
  min?: number;
9
11
  max?: number;
10
12
  step?: number;
13
+ exp?: number;
11
14
  };
12
15
 
13
16
  export type EnumProp<T extends string | number> = BasePropType & {
@@ -28,14 +31,56 @@ export type ArrayProp = BasePropType & {
28
31
  kind: "array";
29
32
  };
30
33
 
31
- export type PropDefinition<T> = T extends number
32
- ? NumberProp | EnumProp<number>
33
- : T extends boolean
34
- ? BooleanProp
35
- : T extends string
36
- ? StringProp | EnumProp<string>
37
- : T extends (string | number)[]
34
+ // Union of all possible prop schema types
35
+ export type PropSchema =
36
+ | NumberProp
37
+ | EnumProp<string>
38
+ | EnumProp<number>
39
+ | StringProp
40
+ | BooleanProp
41
+ | ArrayProp;
42
+
43
+ // Utility type to map TypeScript types to their primary schema types
44
+ type PrimarySchemaForType<T> = T extends boolean
45
+ ? BooleanProp
46
+ : T extends string
47
+ ? StringProp
48
+ : T extends number
49
+ ? NumberProp
50
+ : T extends unknown[]
38
51
  ? ArrayProp
39
52
  : never;
40
53
 
41
- export type PropSchema<T> = { [K in keyof T]: PropDefinition<T[K]> };
54
+ /**
55
+ * Schema type that maps each property to its primary schema type, with optional overrides.
56
+ * This provides excellent IntelliSense for both simple and complex cases.
57
+ *
58
+ * Basic usage:
59
+ * ```typescript
60
+ * type MyProps = { count: number; name: string; enabled: boolean };
61
+ * const mySchema: ModulePropSchema<MyProps> = {
62
+ * count: { kind: "number", min: 0, max: 100 },
63
+ * name: { kind: "string" },
64
+ * enabled: { kind: "boolean" }
65
+ * };
66
+ * ```
67
+ *
68
+ * With overrides for custom schema types:
69
+ * ```typescript
70
+ * type MyProps = { wave: OscillatorWave; frequency: number };
71
+ * const mySchema: ModulePropSchema<MyProps, {
72
+ * wave: EnumProp<OscillatorWave>
73
+ * }> = {
74
+ * wave: { kind: "enum", options: Object.values(OscillatorWave) },
75
+ * frequency: { kind: "number", min: 0, max: 1000 }
76
+ * };
77
+ * ```
78
+ */
79
+ export type ModulePropSchema<
80
+ T,
81
+ TOverrides extends Partial<Record<keyof T, PropSchema>> = EmptyObject,
82
+ > = {
83
+ [K in keyof T]: K extends keyof TOverrides
84
+ ? TOverrides[K]
85
+ : PrimarySchemaForType<T[K]>;
86
+ };
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ export type {
8
8
  IModuleSerialize,
9
9
  IPolyModuleSerialize,
10
10
  IMidiDevice,
11
- PropDefinition,
11
+ ModulePropSchema,
12
12
  PropSchema,
13
13
  StringProp,
14
14
  NumberProp,
@@ -24,7 +24,12 @@ export type { TimeSignature, Position } from "@blibliki/transport";
24
24
 
25
25
  export { Context } from "@blibliki/utils";
26
26
 
27
- export { ModuleType, moduleSchemas, OscillatorWave } from "./modules";
27
+ export {
28
+ ModuleType,
29
+ moduleSchemas,
30
+ OscillatorWave,
31
+ MidiMappingMode,
32
+ } from "./modules";
28
33
  export type {
29
34
  IOscillator,
30
35
  IGain,
@@ -35,4 +40,7 @@ export type {
35
40
  ModuleTypeToPropsMapping,
36
41
  ICreateModule,
37
42
  ModuleParams,
43
+ IMidiMapper,
44
+ IMidiMapperProps,
45
+ MidiMapping,
38
46
  } from "./modules";
@@ -1,8 +1,7 @@
1
1
  import { ContextTime } from "@blibliki/transport";
2
2
  import { Context } from "@blibliki/utils";
3
- import { IModule, Module } from "@/core";
3
+ import { IModule, Module, ModulePropSchema, SetterHooks } from "@/core";
4
4
  import Note from "@/core/Note";
5
- import { PropSchema } from "@/core/schema";
6
5
  import { ICreateModule, ModuleType } from ".";
7
6
 
8
7
  export type IConstant = IModule<ModuleType.Constant>;
@@ -10,7 +9,7 @@ export type IConstantProps = {
10
9
  value: number;
11
10
  };
12
11
 
13
- export const constantPropSchema: PropSchema<IConstantProps> = {
12
+ export const constantPropSchema: ModulePropSchema<IConstantProps> = {
14
13
  value: {
15
14
  kind: "number",
16
15
  min: -Infinity,
@@ -22,7 +21,10 @@ export const constantPropSchema: PropSchema<IConstantProps> = {
22
21
 
23
22
  const DEFAULT_PROPS: IConstantProps = { value: 1 };
24
23
 
25
- export default class Constant extends Module<ModuleType.Constant> {
24
+ export default class Constant
25
+ extends Module<ModuleType.Constant>
26
+ implements Pick<SetterHooks<IConstantProps>, "onAfterSetValue">
27
+ {
26
28
  declare audioNode: ConstantSourceNode;
27
29
  isStated = false;
28
30
 
@@ -40,9 +42,9 @@ export default class Constant extends Module<ModuleType.Constant> {
40
42
  this.registerDefaultIOs("out");
41
43
  }
42
44
 
43
- protected onSetValue(value: IConstantProps["value"]) {
45
+ onAfterSetValue: SetterHooks<IConstantProps>["onAfterSetValue"] = (value) => {
44
46
  this.audioNode.offset.value = value;
45
- }
47
+ };
46
48
 
47
49
  start(time: ContextTime) {
48
50
  if (this.isStated) return;
@@ -52,6 +54,8 @@ export default class Constant extends Module<ModuleType.Constant> {
52
54
  }
53
55
 
54
56
  stop(time: ContextTime) {
57
+ if (!this.isStated) return;
58
+
55
59
  this.audioNode.stop(time);
56
60
  this.rePlugAll(() => {
57
61
  this.audioNode = new ConstantSourceNode(this.context.audioContext, {