@effetune/dsp 0.4.0 → 0.6.0

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/dist/semantics.js CHANGED
@@ -1,7 +1,13 @@
1
1
  import { DSP_PARAM_PACKERS } from './internal/dsp-params.generated.js';
2
2
  import { getEffectDefinition, getEffectImplementation } from './catalog.js';
3
3
  import { Effect, validateChannel, validateParameterValue } from './effect.js';
4
- import { AssetError, EffeTuneError, EffectError, ValidationError } from './errors.js';
4
+ import {
5
+ AssetError,
6
+ EffeTuneError,
7
+ EffectError,
8
+ ValidationError,
9
+ withValidationDetail
10
+ } from './errors.js';
5
11
 
6
12
  const DOCUMENT_KEYS = new Set(['version', 'chain']);
7
13
  const EFFECT_KEYS = new Set(['id', 'type', 'enabled', 'channel', 'parameters', 'assets']);
@@ -9,6 +15,7 @@ const STREAM_RECONFIGURATION_PARAMETERS = new Map([
9
15
  ['FIRCrossover', new Set(['bandCount', 'latencyMode', 'filterDelaySamples'])],
10
16
  ['FiveBandFIRPEQ', new Set(['latencyMode', 'filterDelaySamples'])],
11
17
  ['GroupDelayEQ', new Set(['latencyMode', 'filterDelaySamples'])],
18
+ ['GroupDelayPEQ', new Set(['latencyMode', 'filterDelaySamples'])],
12
19
  ['IRReverb', new Set(['channelMode', 'latency', 'convolutionRate'])],
13
20
  ['RoomEQ', new Set(['latencyMode', 'filterDelaySamples'])]
14
21
  ]);
@@ -51,34 +58,47 @@ function cloneEffect(effect) {
51
58
  };
52
59
  }
53
60
 
54
- function fromPlainEffect(value, index) {
55
- if (!isRecord(value)) throw new ValidationError(`Chain entry ${index} must be an object.`);
61
+ function fromPlainEffect(value, index, label = `Chain entry ${index}`) {
62
+ if (!isRecord(value)) throw new ValidationError(`${label} must be an object.`);
56
63
  for (const key of Object.keys(value)) {
57
64
  if (!EFFECT_KEYS.has(key)) {
58
- throw new ValidationError(`Chain entry ${index} has an unsupported field: ${key}`);
65
+ throw new ValidationError(`${label} has an unsupported field: ${key}`);
59
66
  }
60
67
  }
61
68
  if (typeof value.type !== 'string') {
62
- throw new ValidationError(`Chain entry ${index} requires an effect type.`);
69
+ throw new ValidationError(`${label} requires an effect type.`);
63
70
  }
64
71
  if (!isRecord(value.parameters)) {
65
- throw new ValidationError(`Chain entry ${index} requires a parameters object.`);
72
+ throw new ValidationError(`${label} requires a parameters object.`);
73
+ }
74
+ let definition;
75
+ try {
76
+ definition = getEffectDefinition(value.type);
77
+ } catch (error) {
78
+ throw withValidationDetail(error, { kind: 'type' });
66
79
  }
67
- const definition = getEffectDefinition(value.type);
68
80
  const id = value.id;
69
81
  if (id !== undefined &&
70
82
  (typeof id !== 'string' || id.length < 1 || id.length > 128)) {
71
- throw new ValidationError(`Chain entry ${index} has an invalid effect id.`);
83
+ throw new ValidationError(`${label} has an invalid effect id.`);
72
84
  }
73
85
  const enabled = value.enabled ?? true;
74
86
  if (typeof enabled !== 'boolean') {
75
- throw new ValidationError(`Chain entry ${index} enabled must be boolean.`);
87
+ throw new ValidationError(`${label} enabled must be boolean.`);
88
+ }
89
+ let channel;
90
+ try {
91
+ channel = validateChannel(value.channel ?? 'all');
92
+ } catch (error) {
93
+ throw withValidationDetail(error, { kind: 'channel' });
76
94
  }
77
- const channel = validateChannel(value.channel ?? 'all');
78
95
  const parameterByName = new Map(definition.parameters.map(parameter => [parameter.name, parameter]));
79
96
  for (const key of Object.keys(value.parameters)) {
80
97
  if (!parameterByName.has(key)) {
81
- throw new ValidationError(`Unknown parameter ${value.type}.${key}.`);
98
+ throw withValidationDetail(
99
+ new ValidationError(`Unknown parameter ${value.type}.${key}.`),
100
+ { kind: 'parameter', parameter: key }
101
+ );
82
102
  }
83
103
  }
84
104
  const parameters = {};
@@ -86,26 +106,34 @@ function fromPlainEffect(value, index) {
86
106
  const supplied = Object.hasOwn(value.parameters, parameter.name)
87
107
  ? value.parameters[parameter.name]
88
108
  : parameter.default;
89
- parameters[parameter.name] = validateParameterValue(value.type, parameter, supplied);
109
+ try {
110
+ parameters[parameter.name] = validateParameterValue(value.type, parameter, supplied);
111
+ } catch (error) {
112
+ throw withValidationDetail(error, { kind: 'parameter', parameter: parameter.name });
113
+ }
90
114
  }
91
115
  const declaredAssets = definition.assets ?? [];
92
116
  let assets;
93
- if (declaredAssets.length === 0) {
94
- if (value.assets !== undefined) throw new AssetError(`${value.type} does not accept external assets.`);
95
- } else {
96
- if (!isRecord(value.assets)) throw new AssetError(`${value.type} requires an assets object.`);
97
- const allowed = new Set(declaredAssets.map(asset => asset.name));
98
- for (const name of Object.keys(value.assets)) {
99
- if (!allowed.has(name)) throw new AssetError(`${value.type} has no asset named ${name}.`);
100
- }
101
- assets = {};
102
- for (const asset of declaredAssets) {
103
- const reference = value.assets[asset.name];
104
- if (asset.required && (typeof reference !== 'string' || reference.length < 1 || reference.length > 128)) {
105
- throw new AssetError(`${value.type}.${asset.name} requires a non-empty asset reference.`);
117
+ try {
118
+ if (declaredAssets.length === 0) {
119
+ if (value.assets !== undefined) throw new AssetError(`${value.type} does not accept external assets.`);
120
+ } else {
121
+ if (!isRecord(value.assets)) throw new AssetError(`${value.type} requires an assets object.`);
122
+ const allowed = new Set(declaredAssets.map(asset => asset.name));
123
+ for (const name of Object.keys(value.assets)) {
124
+ if (!allowed.has(name)) throw new AssetError(`${value.type} has no asset named ${name}.`);
125
+ }
126
+ assets = {};
127
+ for (const asset of declaredAssets) {
128
+ const reference = value.assets[asset.name];
129
+ if (asset.required && (typeof reference !== 'string' || reference.length < 1 || reference.length > 128)) {
130
+ throw new AssetError(`${value.type}.${asset.name} requires a non-empty asset reference.`);
131
+ }
132
+ if (reference !== undefined) assets[asset.name] = reference;
106
133
  }
107
- if (reference !== undefined) assets[asset.name] = reference;
108
134
  }
135
+ } catch (error) {
136
+ throw withValidationDetail(error, { kind: 'assets' });
109
137
  }
110
138
  return { id, type: value.type, enabled, channel, parameters, ...(assets ? { assets } : {}) };
111
139
  }
@@ -134,7 +162,7 @@ function assignIds(effects) {
134
162
  });
135
163
  }
136
164
 
137
- export function normalizeChainDocument(input) {
165
+ export function normalizeChainDocument(input, { entryLabel } = {}) {
138
166
  let entries;
139
167
  if (Array.isArray(input)) {
140
168
  entries = input;
@@ -149,7 +177,11 @@ export function normalizeChainDocument(input) {
149
177
  entries = input.chain;
150
178
  }
151
179
  const effects = entries.map((entry, index) =>
152
- fromPlainEffect(entry instanceof Effect ? entry.toJSON() : entry, index)
180
+ fromPlainEffect(
181
+ entry instanceof Effect ? entry.toJSON() : entry,
182
+ index,
183
+ entryLabel ?? `Chain entry ${index}`
184
+ )
153
185
  );
154
186
  return {
155
187
  version: 1,
@@ -250,14 +282,41 @@ export function packEffect(effect) {
250
282
  export function setEffectParameter(effect, parameterName, value) {
251
283
  const definition = getEffectDefinition(effect.type);
252
284
  const parameter = definition.parameters.find(entry => entry.name === parameterName);
253
- if (!parameter) throw new ValidationError(`Unknown parameter ${effect.type}.${parameterName}.`);
254
- const validated = validateParameterValue(effect.type, parameter, value);
285
+ if (!parameter) {
286
+ throw withValidationDetail(
287
+ new ValidationError(`Unknown parameter ${effect.type}.${parameterName}.`),
288
+ { kind: 'parameter', parameter: parameterName }
289
+ );
290
+ }
291
+ let validated;
292
+ try {
293
+ validated = validateParameterValue(effect.type, parameter, value);
294
+ } catch (error) {
295
+ throw withValidationDetail(error, { kind: 'parameter', parameter: parameterName });
296
+ }
255
297
  return {
256
298
  ...cloneEffect(effect),
257
299
  parameters: { ...effect.parameters, [parameterName]: validated }
258
300
  };
259
301
  }
260
302
 
303
+ export function canonicalizeEffectForProcessing(effect) {
304
+ const canonical = cloneEffect(effect);
305
+ const parameters = canonical.parameters;
306
+ if (effect.type === 'AutoFilter' &&
307
+ parameters.minimumFrequency > parameters.maximumFrequency) {
308
+ [parameters.minimumFrequency, parameters.maximumFrequency] =
309
+ [parameters.maximumFrequency, parameters.minimumFrequency];
310
+ } else if (effect.type === 'Chorus' && parameters.depth > parameters.delay) {
311
+ parameters.depth = parameters.delay;
312
+ } else if (effect.type === 'FrequencyShifter' &&
313
+ parameters.minimumShift > parameters.maximumShift) {
314
+ [parameters.minimumShift, parameters.maximumShift] =
315
+ [parameters.maximumShift, parameters.minimumShift];
316
+ }
317
+ return canonical;
318
+ }
319
+
261
320
  export function validateStreamParameterUpdate(effect, parameterName) {
262
321
  if (STREAM_RECONFIGURATION_PARAMETERS.get(effect.type)?.has(parameterName)) {
263
322
  throw new ValidationError(
@@ -8,6 +8,7 @@ class EffeTuneDspProcessor extends AudioWorkletProcessor {
8
8
  this.session = null;
9
9
  this.ready = false;
10
10
  this.closed = false;
11
+ this.latencySamples = null;
11
12
  this.channels = 0;
12
13
  this.pendingCommands = [];
13
14
  this.sourceChannels = [];
@@ -66,7 +67,8 @@ class EffeTuneDspProcessor extends AudioWorkletProcessor {
66
67
  );
67
68
  }
68
69
  this.ready = true;
69
- this.port.postMessage({ type: 'ready' });
70
+ this.latencySamples = this.session?.latencySamples ?? 0;
71
+ this.port.postMessage({ type: 'ready', latencySamples: this.latencySamples });
70
72
  } catch (error) {
71
73
  this.port.postMessage({
72
74
  type: 'initializationError',
@@ -95,6 +97,11 @@ class EffeTuneDspProcessor extends AudioWorkletProcessor {
95
97
  this.session?.setTelemetryEnabled(this.telemetryEnabled);
96
98
  continue;
97
99
  }
100
+ const latencySamples = this.session?.latencySamples ?? 0;
101
+ if (latencySamples !== this.latencySamples) {
102
+ this.latencySamples = latencySamples;
103
+ this.port.postMessage({ type: 'latency', latencySamples });
104
+ }
98
105
  this.port.postMessage({ type: 'commandResult', commandId: command.commandId, ok: true });
99
106
  } catch (error) {
100
107
  this.port.postMessage({
package/dist/worklet.d.ts CHANGED
@@ -23,6 +23,7 @@ export declare class EffeTuneNode extends AudioWorkletNode {
23
23
  readonly (Effect | ChainEffectInput)[],
24
24
  options?: EffeTuneNodeOptions
25
25
  ): Promise<EffeTuneNode>;
26
+ readonly latencySamples: number;
26
27
  readonly droppedTelemetryFrames: number;
27
28
  subscribe(callback: TelemetryCallback): () => void;
28
29
  unsubscribe(callback: TelemetryCallback): boolean;
package/dist/worklet.js CHANGED
@@ -184,6 +184,7 @@ export class EffeTuneNode extends AudioWorkletNodeBase {
184
184
  this._seed = seed;
185
185
  this._closed = false;
186
186
  this._runtimeError = null;
187
+ this._latencySamples = 0;
187
188
  this._nextCommandId = 1;
188
189
  this._pending = new Map();
189
190
  this._telemetryCallbacks = new Set();
@@ -237,9 +238,18 @@ export class EffeTuneNode extends AudioWorkletNodeBase {
237
238
  return;
238
239
  }
239
240
  if (message?.type === 'ready') {
241
+ this._latencySamples = Number.isInteger(message.latencySamples) && message.latencySamples >= 0
242
+ ? message.latencySamples
243
+ : 0;
240
244
  this._resolveReady();
241
245
  return;
242
246
  }
247
+ if (message?.type === 'latency') {
248
+ this._latencySamples = Number.isInteger(message.latencySamples) && message.latencySamples >= 0
249
+ ? message.latencySamples
250
+ : 0;
251
+ return;
252
+ }
243
253
  if (message?.type === 'initializationError') {
244
254
  this._rejectReady(errorFromMessage(
245
255
  message.errorType,
@@ -315,6 +325,11 @@ export class EffeTuneNode extends AudioWorkletNodeBase {
315
325
  return this._droppedTelemetryFrames;
316
326
  }
317
327
 
328
+ get latencySamples() {
329
+ this._assertOpen();
330
+ return this._latencySamples;
331
+ }
332
+
318
333
  subscribe(callback) {
319
334
  this._assertOpen();
320
335
  if (typeof callback !== 'function') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effetune/dsp",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "EffeTune's deterministic WebAssembly audio effects for JavaScript and AudioWorklet",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,7 @@
37
37
  },
38
38
  "./processor": "./dist/worklet-processor.js",
39
39
  "./schemas/chain-v1.json": "./dist/schemas/chain-v1.schema.json",
40
+ "./schemas/graph-v1.json": "./dist/schemas/graph-v1.schema.json",
40
41
  "./schemas/bundle-v1.json": "./dist/schemas/bundle-v1.schema.json",
41
42
  "./catalog": {
42
43
  "types": "./dist/catalog-entry.d.ts",