@libraz/libsonare 1.3.0 → 1.3.2

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.0",
3
+ "version": "1.3.2",
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,
package/src/live_audio.ts CHANGED
@@ -16,11 +16,13 @@ export async function bindMicrophoneInput(
16
16
  engine: SonareRealtimeEngineNode | AudioWorkletNode,
17
17
  options: BindMicrophoneInputOptions = {},
18
18
  ): Promise<MicrophoneInputBinding> {
19
+ const { stream: providedStream, stopTracksOnClose = true, ...constraints } = options;
19
20
  const stream =
20
- options.stream ??
21
+ providedStream ??
21
22
  (await navigator.mediaDevices.getUserMedia({
22
- audio: options.audio ?? true,
23
- video: false,
23
+ ...constraints,
24
+ audio: constraints.audio ?? true,
25
+ video: constraints.video ?? false,
24
26
  }));
25
27
  const source = context.createMediaStreamSource(stream);
26
28
  const node = 'node' in engine ? engine.node : engine;
@@ -35,7 +37,7 @@ export async function bindMicrophoneInput(
35
37
  }
36
38
  closed = true;
37
39
  source.disconnect();
38
- if (options.stopTracksOnClose !== false) {
40
+ if (stopTracksOnClose) {
39
41
  for (const track of stream.getAudioTracks()) {
40
42
  track.stop();
41
43
  }
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,125 @@
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 convert = (error: unknown): never => {
74
+ const ptr = nativeExceptionPtr(error);
75
+ if (ptr !== null) {
76
+ throw makeSonareError(raw, ptr);
77
+ }
78
+ throw error;
79
+ };
80
+ return new Proxy(raw, {
81
+ get(target, prop, receiver) {
82
+ const value = Reflect.get(target, prop, receiver);
83
+ if (typeof value !== 'function') {
84
+ return value;
85
+ }
86
+ const cached = cache.get(prop);
87
+ if (cached) {
88
+ return cached;
89
+ }
90
+ // Wrap as a Proxy (not a plain function) so embind class constructors
91
+ // invoked via `new module.Foo(...)` keep their `[[Construct]]` behaviour
92
+ // and prototype while still converting thrown native pointers.
93
+ const fn = value as (...a: unknown[]) => unknown;
94
+ const wrapped = new Proxy(fn, {
95
+ apply(t, thisArg, args) {
96
+ try {
97
+ return Reflect.apply(t, thisArg, args as unknown[]);
98
+ } catch (error) {
99
+ return convert(error);
100
+ }
101
+ },
102
+ construct(t, args, newTarget) {
103
+ try {
104
+ return Reflect.construct(t, args as unknown[], newTarget) as object;
105
+ } catch (error) {
106
+ return convert(error) as object;
107
+ }
108
+ },
109
+ });
110
+ cache.set(prop, wrapped);
111
+ return wrapped;
112
+ },
113
+ }) as SonareModule;
114
+ }
4
115
 
5
116
  export function setSonareModule(module: SonareModule): void {
6
- wasmModule = module;
117
+ wrappedModule = wrapModuleErrors(module);
7
118
  }
8
119
 
9
120
  export function getSonareModule(): SonareModule {
10
- if (!wasmModule) {
121
+ if (!wrappedModule) {
11
122
  throw new Error('Module not initialized. Call init() first.');
12
123
  }
13
- return wasmModule;
124
+ return wrappedModule;
14
125
  }
@@ -55,12 +55,22 @@ self.onmessage = async (event) => {
55
55
  const frames = Math.min(pageFrames, numSamples - startFrame);
56
56
  const frameBytes = numChannels * 4;
57
57
  const bytes = new Uint8Array(frames * frameBytes);
58
- const bytesRead = access.read(bytes, { at: dataOffsetBytes + startFrame * frameBytes });
59
- const framesRead = Math.floor(bytesRead / frameBytes);
60
- if (framesRead <= 0) {
58
+ let bytesReadTotal = 0;
59
+ const readOffset = dataOffsetBytes + startFrame * frameBytes;
60
+ while (bytesReadTotal < bytes.byteLength) {
61
+ const bytesRead = access.read(bytes.subarray(bytesReadTotal), {
62
+ at: readOffset + bytesReadTotal,
63
+ });
64
+ if (bytesRead <= 0) {
65
+ break;
66
+ }
67
+ bytesReadTotal += bytesRead;
68
+ }
69
+ if (bytesReadTotal !== bytes.byteLength || bytesReadTotal % frameBytes !== 0) {
61
70
  self.postMessage({ type: 'sonare:clip-page', requestId, pageIndex, ok: false });
62
71
  return;
63
72
  }
73
+ const framesRead = bytesReadTotal / frameBytes;
64
74
  const view = new DataView(bytes.buffer, 0, framesRead * frameBytes);
65
75
  const channelBuffers = Array.from({ length: numChannels }, () => new ArrayBuffer(framesRead * 4));
66
76
  for (let ch = 0; ch < numChannels; ++ch) {
@@ -137,7 +147,12 @@ export function createOpfsClipPageProvider(
137
147
  entry.resolve(false);
138
148
  return;
139
149
  }
140
- provider.supply(response.pageIndex, channels);
150
+ try {
151
+ provider.supply(response.pageIndex, channels);
152
+ } catch {
153
+ entry.resolve(false);
154
+ return;
155
+ }
141
156
  entry.resolve(true);
142
157
  };
143
158
  worker.addEventListener('message', onMessage as EventListener);
@@ -1845,6 +1845,11 @@ export interface SonareModule {
1845
1845
 
1846
1846
  // Mixing - scene-based Mixer
1847
1847
  createMixerFromSceneJson: (json: string, sampleRate: number, blockSize: number) => WasmMixer;
1848
+
1849
+ // Decodes a thrown native exception-object pointer (emscripten classic EH
1850
+ // surfaces a C++ throw as the raw pointer number) into a structured error.
1851
+ // Consumed by the module-error wrapper in module_state.ts.
1852
+ sonareExceptionInfo: (ptr: number) => { code: number; codeName: string; message: string };
1848
1853
  }
1849
1854
 
1850
1855
  export interface WasmStreamingMasteringChain {
@@ -1947,6 +1952,7 @@ export interface WasmMixer {
1947
1952
  outputRightView: () => Float32Array;
1948
1953
  processPreparedStereo: (numSamples: number) => void;
1949
1954
  stripCount: () => number;
1955
+ sceneWarnings: () => string[];
1950
1956
  scheduleInsertAutomation: (
1951
1957
  stripIndex: number,
1952
1958
  insertIndex: number,
package/src/web_midi.ts CHANGED
@@ -105,9 +105,8 @@ export async function bindWebMidi(
105
105
  engine: RealtimeEngine,
106
106
  options: BindWebMidiOptions = {},
107
107
  ): Promise<WebMidiBinding> {
108
- const requestMIDIAccess = (globalThis.navigator as NavigatorWithMidi | undefined)
109
- ?.requestMIDIAccess;
110
- if (typeof requestMIDIAccess !== 'function') {
108
+ const navigatorWithMidi = globalThis.navigator as NavigatorWithMidi | undefined;
109
+ if (typeof navigatorWithMidi?.requestMIDIAccess !== 'function') {
111
110
  throw new Error('Web MIDI is not available in this environment');
112
111
  }
113
112
 
@@ -115,7 +114,9 @@ export async function bindWebMidi(
115
114
  assertNibble('bindWebMidi', group, 'group');
116
115
  const destinationId = options.destinationId ?? 0;
117
116
  const selectedIds = new Set(options.inputIds ?? []);
118
- const access = await requestMIDIAccess({
117
+ // Invoke through the navigator so the browser's native method keeps its
118
+ // required `this` binding (detached calls throw "Illegal invocation").
119
+ const access = await navigatorWithMidi.requestMIDIAccess({
119
120
  sysex: options.sysex ?? false,
120
121
  software: options.software ?? true,
121
122
  });
@@ -154,9 +155,7 @@ export async function bindWebMidi(
154
155
  runningStatus,
155
156
  options.timestampToSamples,
156
157
  );
157
- if (status !== 0) {
158
- runningStatus = status;
159
- }
158
+ runningStatus = status;
160
159
  };
161
160
  if (input.addEventListener) {
162
161
  input.addEventListener('midimessage', listener);
@@ -268,12 +267,12 @@ function dispatchMidiMessage(
268
267
  const message = status & 0xf0;
269
268
  const channel = status & 0x0f;
270
269
  if (message < 0x80 || message > 0xe0) {
271
- return status;
270
+ return status >= 0xf8 ? runningStatus : 0;
272
271
  }
273
272
 
274
273
  const a = readU7(data, offset);
275
274
  const b = readU7(data, offset + 1);
276
- if (a < 0) {
275
+ if (a < 0 || b < 0) {
277
276
  return status;
278
277
  }
279
278
 
@@ -282,9 +281,9 @@ function dispatchMidiMessage(
282
281
  : 0;
283
282
 
284
283
  if (message === 0x80) {
285
- engine.pushMidiInputNoteOff(group, channel, a, b < 0 ? 0 : b, portTimeSamples);
284
+ engine.pushMidiInputNoteOff(group, channel, a, b, portTimeSamples);
286
285
  } else if (message === 0x90) {
287
- if ((b < 0 ? 0 : b) === 0) {
286
+ if (b === 0) {
288
287
  engine.pushMidiInputNoteOff(group, channel, a, 0, portTimeSamples);
289
288
  } else {
290
289
  engine.pushMidiInputNoteOn(group, channel, a, b, portTimeSamples);