@libraz/libsonare 1.3.1 → 1.3.3

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": "@libraz/libsonare",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Audio analysis library for music information retrieval",
@@ -376,6 +376,22 @@ export function masteringInsertNames(): string[] {
376
376
  ).masteringInsertNames();
377
377
  }
378
378
 
379
+ /**
380
+ * Returns the camelCase parameter names a given insert / FX processor reads, for
381
+ * tooling/validation. Any key NOT in this list is silently ignored by the
382
+ * processor (and would be reported via {@link Mixer.sceneWarnings} when a scene
383
+ * carrying it is loaded). Band/sub-band processors enumerate their indexed
384
+ * `band{i}.<field>` keys. Returns an empty array for an unknown name (or one
385
+ * whose insert needs an unavailable build feature, e.g. FX).
386
+ *
387
+ * @param name - Insert processor name (see {@link masteringInsertNames}).
388
+ */
389
+ export function masteringInsertParamNames(name: string): string[] {
390
+ return (
391
+ requireModule() as unknown as { masteringInsertParamNames: (name: string) => string[] }
392
+ ).masteringInsertParamNames(name);
393
+ }
394
+
379
395
  export function masteringPairProcessorNames(): PairProcessor[] {
380
396
  return requireModule().masteringPairProcessorNames() as PairProcessor[];
381
397
  }
package/src/errors.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Numeric error codes carried by a {@link SonareError}. Mirrors the C ABI
3
+ * `SonareError` enum (and the Node / Python surfaces), so the same failure
4
+ * reports the same numeric code on every binding.
5
+ */
6
+ export enum ErrorCode {
7
+ Ok = 0,
8
+ FileNotFound = 1,
9
+ InvalidFormat = 2,
10
+ DecodeFailed = 3,
11
+ InvalidParameter = 4,
12
+ OutOfMemory = 5,
13
+ NotSupported = 6,
14
+ InvalidState = 7,
15
+ Unknown = 99,
16
+ }
17
+
18
+ /**
19
+ * Error thrown by libsonare on a native (C++) failure. Carries a numeric
20
+ * {@link ErrorCode} `code` plus its canonical `codeName`, so callers can branch
21
+ * on the cause instead of matching message text.
22
+ */
23
+ export class SonareError extends Error {
24
+ /** Numeric error code, equal to an {@link ErrorCode} value. */
25
+ readonly code: number;
26
+ /** Canonical name of `code`, e.g. `'InvalidParameter'`. */
27
+ readonly codeName: string;
28
+
29
+ constructor(code: number, codeName: string, message: string) {
30
+ super(message);
31
+ this.name = 'SonareError';
32
+ this.code = code;
33
+ this.codeName = codeName;
34
+ }
35
+ }
36
+
37
+ /** Type guard: whether a caught value is a libsonare {@link SonareError}. */
38
+ export function isSonareError(value: unknown): value is SonareError {
39
+ return (
40
+ value instanceof Error &&
41
+ (value as { name?: unknown }).name === 'SonareError' &&
42
+ typeof (value as { code?: unknown }).code === 'number'
43
+ );
44
+ }
package/src/index.ts CHANGED
@@ -67,6 +67,7 @@ export {
67
67
  masteringDynamicsGate,
68
68
  masteringDynamicsTransientShaper,
69
69
  masteringInsertNames,
70
+ masteringInsertParamNames,
70
71
  masteringPairAnalysisNames,
71
72
  masteringPairAnalyze,
72
73
  masteringPairProcess,
@@ -98,6 +99,7 @@ export {
98
99
  voiceChange,
99
100
  voiceChangeRealtime,
100
101
  } from './effects_mastering';
102
+ export { ErrorCode, isSonareError, SonareError } from './errors';
101
103
  export type { MelodyOptions } from './feature_music';
102
104
  export {
103
105
  amplitudeToDb,
@@ -399,6 +401,7 @@ export type {
399
401
  EngineAutomationPoint,
400
402
  EngineBounceOptions,
401
403
  EngineBounceResult,
404
+ EngineBus,
402
405
  EngineCapabilities,
403
406
  EngineCaptureStatus,
404
407
  EngineClip,
@@ -408,8 +411,14 @@ export type {
408
411
  EngineMarker,
409
412
  EngineMeterTelemetry,
410
413
  EngineMetronomeConfig,
414
+ EngineMidiClipSchedule,
415
+ EngineMidiEvent,
411
416
  EngineParameterInfo,
412
417
  EngineTelemetry,
418
+ EngineTempoSegment,
419
+ EngineTimeSignatureSegment,
420
+ EngineTrackLane,
421
+ EngineTrackSend,
413
422
  EngineTransportState,
414
423
  MidiCcBindOptions,
415
424
  } from './realtime_engine';
@@ -482,6 +491,11 @@ let initPromise: Promise<void> | null = null;
482
491
  */
483
492
  export async function init(options?: {
484
493
  locateFile?: (path: string, prefix: string) => string;
494
+ wasmBinary?: ArrayBuffer | Uint8Array;
495
+ moduleFactory?: (options?: {
496
+ locateFile?: (path: string, prefix: string) => string;
497
+ wasmBinary?: ArrayBuffer | Uint8Array;
498
+ }) => Promise<SonareModule>;
485
499
  }): Promise<void> {
486
500
  if (module) {
487
501
  return;
@@ -493,7 +507,7 @@ export async function init(options?: {
493
507
 
494
508
  initPromise = (async () => {
495
509
  try {
496
- const createModule = (await import('./sonare.js')).default;
510
+ const createModule = options?.moduleFactory ?? (await import('./sonare.js')).default;
497
511
  module = await createModule(options);
498
512
  setSonareModule(module);
499
513
  } catch (error) {
package/src/mixer.ts CHANGED
@@ -76,6 +76,17 @@ export class Mixer {
76
76
  this.mixer.compile();
77
77
  }
78
78
 
79
+ /**
80
+ * Non-fatal warnings captured when this mixer was built from scene JSON: one
81
+ * entry per channel-strip insert that was handed param keys it does not read
82
+ * (a likely typo, or a key meant for a different processor). The scene still
83
+ * loaded; these keys simply took no effect. Empty when every key was consumed.
84
+ * Use {@link masteringInsertParamNames} to discover the keys an insert accepts.
85
+ */
86
+ sceneWarnings(): string[] {
87
+ return this.mixer.sceneWarnings();
88
+ }
89
+
79
90
  /**
80
91
  * Mix one block of per-strip stereo audio into the stereo master.
81
92
  *
@@ -1,14 +1,190 @@
1
+ import { ErrorCode, SonareError } from './errors';
1
2
  import type { SonareModule } from './sonare.js';
2
3
 
3
- let wasmModule: SonareModule | null = null;
4
+ let wrappedModule: SonareModule | null = null;
5
+
6
+ /**
7
+ * Shape of the structured info the native `sonareExceptionInfo(ptr)` returns.
8
+ */
9
+ interface NativeExceptionInfo {
10
+ code: number;
11
+ codeName: string;
12
+ message: string;
13
+ }
14
+
15
+ /**
16
+ * Recover the native exception-object pointer from a value thrown across the
17
+ * WASM boundary. emscripten surfaces a C++ throw in two shapes depending on the
18
+ * toolchain/exception mode:
19
+ * - a raw pointer number (older / classic surfacing), or
20
+ * - a `CppException` object exposing the pointer as `excPtr` (emscripten with
21
+ * `-fexceptions`).
22
+ * Returns null when the thrown value is neither (a genuine JS error), so the
23
+ * caller rethrows it unchanged.
24
+ */
25
+ function nativeExceptionPtr(error: unknown): number | null {
26
+ if (typeof error === 'number') {
27
+ return error;
28
+ }
29
+ if (error !== null && typeof error === 'object') {
30
+ const ptr = (error as { excPtr?: unknown }).excPtr;
31
+ if (typeof ptr === 'number') {
32
+ return ptr;
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Turn a thrown native exception pointer into a {@link SonareError}. The bound
40
+ * `sonareExceptionInfo` decodes the pointer back into { code, codeName,
41
+ * message }.
42
+ */
43
+ function makeSonareError(raw: SonareModule, thrown: number): SonareError {
44
+ let code: number = ErrorCode.Unknown;
45
+ let codeName = 'Unknown';
46
+ let message = `libsonare native exception (${thrown})`;
47
+ try {
48
+ const info = (
49
+ raw as unknown as { sonareExceptionInfo?: (ptr: number) => NativeExceptionInfo }
50
+ ).sonareExceptionInfo?.(thrown);
51
+ if (info) {
52
+ code = info.code ?? code;
53
+ codeName = info.codeName ?? codeName;
54
+ message = info.message || message;
55
+ }
56
+ } catch {
57
+ // Fall back to the generic message if decoding fails.
58
+ }
59
+ return new SonareError(code, codeName, message);
60
+ }
61
+
62
+ /**
63
+ * Wrap the embind module so a native C++ exception (which surfaces as a raw
64
+ * pointer number or a `CppException` carrying one) is rethrown as a
65
+ * {@link SonareError}. Only function-valued
66
+ * members are wrapped, and the wrapper is cached per member so repeated access
67
+ * stays cheap; non-function members (typed-array heap views, etc.) pass through
68
+ * unchanged. The dedicated realtime `sonare-rt` module is separate and is not
69
+ * affected by this wrapper.
70
+ */
71
+ function wrapModuleErrors(raw: SonareModule): SonareModule {
72
+ const cache = new Map<PropertyKey, unknown>();
73
+ const objectCache = new WeakMap<object, unknown>();
74
+ const convert = (error: unknown): never => {
75
+ const ptr = nativeExceptionPtr(error);
76
+ if (ptr !== null) {
77
+ throw makeSonareError(raw, ptr);
78
+ }
79
+ throw error;
80
+ };
81
+
82
+ const wrapNativeObject = (value: unknown): unknown => {
83
+ if (value === null || typeof value !== 'object') {
84
+ return value;
85
+ }
86
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer || value instanceof Promise) {
87
+ return value;
88
+ }
89
+ const objectValue = value as object;
90
+ const cached = objectCache.get(objectValue);
91
+ if (cached) {
92
+ return cached;
93
+ }
94
+ const methodCache = new Map<PropertyKey, unknown>();
95
+ const wrapped = new Proxy(objectValue, {
96
+ get(target, prop, receiver) {
97
+ const member = Reflect.get(target, prop, receiver);
98
+ if (typeof member !== 'function') {
99
+ return member;
100
+ }
101
+ const cachedMethod = methodCache.get(prop);
102
+ if (cachedMethod) {
103
+ return cachedMethod;
104
+ }
105
+ const method = member as (...a: unknown[]) => unknown;
106
+ const wrappedMethod = (...args: unknown[]) => {
107
+ try {
108
+ return wrapNativeObject(Reflect.apply(method, target, args));
109
+ } catch (error) {
110
+ return convert(error);
111
+ }
112
+ };
113
+ methodCache.set(prop, wrappedMethod);
114
+ return wrappedMethod;
115
+ },
116
+ });
117
+ objectCache.set(objectValue, wrapped);
118
+ return wrapped;
119
+ };
120
+
121
+ const wrapFunction = (value: (...a: unknown[]) => unknown): unknown => {
122
+ const fnCache = new Map<PropertyKey, unknown>();
123
+ return new Proxy(value, {
124
+ get(target, prop, receiver) {
125
+ const member = Reflect.get(target, prop, receiver);
126
+ if (typeof member !== 'function') {
127
+ return member;
128
+ }
129
+ const cachedMember = fnCache.get(prop);
130
+ if (cachedMember) {
131
+ return cachedMember;
132
+ }
133
+ const fn = member as (...a: unknown[]) => unknown;
134
+ const wrappedMember = (...args: unknown[]) => {
135
+ try {
136
+ return wrapNativeObject(Reflect.apply(fn, target, args));
137
+ } catch (error) {
138
+ return convert(error);
139
+ }
140
+ };
141
+ fnCache.set(prop, wrappedMember);
142
+ return wrappedMember;
143
+ },
144
+ apply(t, thisArg, args) {
145
+ try {
146
+ return wrapNativeObject(Reflect.apply(t, thisArg, args as unknown[]));
147
+ } catch (error) {
148
+ return convert(error);
149
+ }
150
+ },
151
+ construct(t, args, newTarget) {
152
+ try {
153
+ return wrapNativeObject(Reflect.construct(t, args as unknown[], newTarget));
154
+ } catch (error) {
155
+ return convert(error) as object;
156
+ }
157
+ },
158
+ });
159
+ };
160
+
161
+ return new Proxy(raw, {
162
+ get(target, prop, receiver) {
163
+ const value = Reflect.get(target, prop, receiver);
164
+ if (typeof value !== 'function') {
165
+ return value;
166
+ }
167
+ const cached = cache.get(prop);
168
+ if (cached) {
169
+ return cached;
170
+ }
171
+ // Wrap as a Proxy (not a plain function) so embind class constructors
172
+ // invoked via `new module.Foo(...)` keep their `[[Construct]]` behaviour
173
+ // and prototype while still converting thrown native pointers.
174
+ const wrapped = wrapFunction(value as (...a: unknown[]) => unknown);
175
+ cache.set(prop, wrapped);
176
+ return wrapped;
177
+ },
178
+ }) as SonareModule;
179
+ }
4
180
 
5
181
  export function setSonareModule(module: SonareModule): void {
6
- wasmModule = module;
182
+ wrappedModule = wrapModuleErrors(module);
7
183
  }
8
184
 
9
185
  export function getSonareModule(): SonareModule {
10
- if (!wasmModule) {
186
+ if (!wrappedModule) {
11
187
  throw new Error('Module not initialized. Call init() first.');
12
188
  }
13
- return wasmModule;
189
+ return wrappedModule;
14
190
  }
@@ -29,10 +29,25 @@ interface PageResponse {
29
29
  }
30
30
 
31
31
  export const opfsClipPageWorkerSource = `
32
+ const sonareClipPageReadQueues = new Map();
33
+
34
+ function sonareEnqueueClipPageRead(key, task) {
35
+ const previous = sonareClipPageReadQueues.get(key) || Promise.resolve();
36
+ const next = previous.catch(() => undefined).then(task);
37
+ const queued = next.finally(() => {
38
+ if (sonareClipPageReadQueues.get(key) === queued) {
39
+ sonareClipPageReadQueues.delete(key);
40
+ }
41
+ });
42
+ sonareClipPageReadQueues.set(key, queued);
43
+ return next;
44
+ }
45
+
32
46
  self.onmessage = async (event) => {
33
47
  const message = event.data;
34
48
  if (!message || message.type !== 'sonare:read-clip-page') return;
35
49
  const { requestId, path, pageIndex, numChannels, numSamples, pageFrames, dataOffsetBytes = 0 } = message;
50
+ await sonareEnqueueClipPageRead(String(path), async () => {
36
51
  try {
37
52
  if (pageIndex < 0) {
38
53
  self.postMessage({ type: 'sonare:clip-page', requestId, pageIndex, ok: false });
@@ -95,6 +110,7 @@ self.onmessage = async (event) => {
95
110
  error: error instanceof Error ? error.message : String(error),
96
111
  });
97
112
  }
113
+ });
98
114
  };
99
115
  `;
100
116
 
@@ -119,6 +135,7 @@ export function createOpfsClipPageProvider(
119
135
  const ownsWorker = options.worker === undefined || options.terminateWorkerOnClose === true;
120
136
  let nextRequestId = 1;
121
137
  let closed = false;
138
+ let readQueue: Promise<void> = Promise.resolve();
122
139
  const pending = new Map<
123
140
  number,
124
141
  { resolve: (value: boolean) => void; reject: (reason: unknown) => void }
@@ -165,15 +182,32 @@ export function createOpfsClipPageProvider(
165
182
  const promise = new Promise<boolean>((resolve, reject) => {
166
183
  pending.set(requestId, { resolve, reject });
167
184
  });
168
- worker.postMessage({
169
- type: 'sonare:read-clip-page',
170
- requestId,
171
- path: options.path,
172
- pageIndex,
173
- numChannels: options.numChannels,
174
- numSamples: options.numSamples,
175
- pageFrames: options.pageFrames,
176
- dataOffsetBytes: options.dataOffsetBytes ?? 0,
185
+ readQueue = readQueue
186
+ .catch(() => undefined)
187
+ .then(() => {
188
+ if (closed) {
189
+ const entry = pending.get(requestId);
190
+ pending.delete(requestId);
191
+ entry?.reject(new Error('OpfsClipPageProvider is closed'));
192
+ return;
193
+ }
194
+ worker.postMessage({
195
+ type: 'sonare:read-clip-page',
196
+ requestId,
197
+ path: options.path,
198
+ pageIndex,
199
+ numChannels: options.numChannels,
200
+ numSamples: options.numSamples,
201
+ pageFrames: options.pageFrames,
202
+ dataOffsetBytes: options.dataOffsetBytes ?? 0,
203
+ });
204
+ return promise.then(
205
+ () => undefined,
206
+ () => undefined,
207
+ );
208
+ });
209
+ readQueue.catch(() => {
210
+ // The per-request promise carries the user-visible failure.
177
211
  });
178
212
  return promise;
179
213
  };