@onda-lang/webaudio 0.8.2 → 0.8.4

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/README.md CHANGED
@@ -75,15 +75,15 @@ const sliderValueFor440Hz = paramPlainToNormalized(cutoff, 440);
75
75
  ```
76
76
 
77
77
  The helpers preserve exact endpoints, clamp and snap consistently with adapter writes, and reject
78
- arrays or numeric parameters without a host-control domain. `scale`, `curve`, `unit`, `step_repr`,
78
+ numeric parameters without a host-control domain. Select array elements with indexed names such
79
+ as `offsets[0]`, or supply an exact-length array to `setParam("offsets", values)`. `scale`, `curve`, `unit`, `step_repr`,
79
80
  and `step_count` remain available on each parameter's descriptor metadata for control construction
80
81
  and formatting. For repeated UI conversion, `createParamControl(param)` prepares and validates the
81
- descriptor once and returns bound conversion methods.
82
+ descriptor once and returns bound conversion methods; pass an element index as its second argument
83
+ for an array.
82
84
 
83
85
  Controlled `i64` domains use the descriptor's exact binary64 integer range. Full-width unranged
84
86
  `i64` values continue to use `bigint` when written directly.
85
- Event `i64` values accept bigint, safe integers, or decimal integer strings and reject values
86
- outside the signed 64-bit range.
87
87
 
88
88
  The artifact must be compiled for exactly `audioContext.sampleRate`; the adapter rejects a mismatch
89
89
  before registering the node so sample-rate-derived language semantics cannot silently drift. A Web
@@ -95,6 +95,41 @@ Call `processor.close()` when the adapter is no longer used. Closing is idempote
95
95
  rejects pending requests, removes listeners, and makes subsequent operations fail immediately. The
96
96
  wrapped `AudioWorkletNode` and `AudioContext` remain caller-owned.
97
97
 
98
+ ## Events
99
+
100
+ `trigger(nameOrIndex, values?)` accepts event parameters as an ordered array or an object keyed by
101
+ parameter name. Nominal structs are objects containing exactly their declared fields; tuples, fixed
102
+ arrays, and runtime slices are sequences. The adapter validates and encodes the complete logical
103
+ value on the main thread before transferring its packed bytes to the worklet.
104
+
105
+ For this Onda declaration:
106
+
107
+ ```onda
108
+ struct Note:
109
+ frequency = 440.0
110
+ velocity = 1.0
111
+
112
+ init:
113
+ current_note: Note
114
+
115
+ event note_on(note: Note):
116
+ current_note = note
117
+ ```
118
+
119
+ the corresponding host call is:
120
+
121
+ ```js
122
+ await processor.trigger("note_on", {
123
+ note: { frequency: 523.25, velocity: 0.7 },
124
+ });
125
+ ```
126
+
127
+ Fixed arrays must have their declared length. Slice lengths may vary up to the configured event
128
+ payload capacity. `i64` leaves accept `bigint`, exact safe integers, or decimal integer strings and
129
+ reject values outside the signed 64-bit range. Missing top-level arguments use declared defaults;
130
+ structs and struct arrays have no event defaults and must be supplied in full. Input rejected during
131
+ worklet preflight leaves processor state usable and rejects the returned promise.
132
+
98
133
  ## Prints
99
134
 
100
135
  Authored `print(...)` occurrences leave generated execution as bounded typed records. The worklet
package/api.md CHANGED
@@ -119,7 +119,11 @@ versions rather than guessing layout compatibility.
119
119
  ### Parameter controls
120
120
 
121
121
  `createParamControl(metadata)` validates one scalar parameter descriptor and returns a reusable
122
- `OndaPreparedParamControl`. `createParamDomain(domain)` does the same from already-decoded values.
122
+ `OndaPreparedParamControl`. For an array, `createParamControl(metadata, index)` prepares one element;
123
+ `paramElementMetadata(metadata, index)` projects its scalar descriptor, default, and storage offset.
124
+ The Web Audio adapter accepts indexed names such as `offsets[1]` in `setParam` and
125
+ `setParamNormalized`, and exact-length arrays in `setParam("offsets", values)`.
126
+ `createParamDomain(domain)` does the same from already-decoded values.
123
127
  Prepared controls expose `constrainPlain`, `normalizedToPlain`, and `plainToNormalized` methods.
124
128
 
125
129
  The one-shot `constrainParamPlain`, `paramNormalizedToPlain`, and `paramPlainToNormalized`
@@ -135,7 +139,8 @@ real-time execution.
135
139
 
136
140
  `writeDelegateBatch` and `writePrintBatch` initialize reusable batch descriptors.
137
141
  `writeExecutionOutput` connects their addresses. Call `resetExecutionOutput` immediately before
138
- every generated init, process, or event entry. After a successful generated call,
142
+ every generated init or process entry. Generated event entries reset after successful input
143
+ preflight instead, so rejected input preserves existing records. After a successful generated call,
139
144
  `readDelegateBatch` or `readPrintBatch` validates the result counters.
140
145
  `decodeDelegateRecords` converts delegate payloads using `metadata.delegates`.
141
146
  `decodePrintRecords` preserves primitive types and source sites; `formatPrintRecords` and
@@ -271,6 +276,7 @@ PROCESSOR_INIT_PRESERVE_PINNED
271
276
  PROCESSOR_SNAPSHOT_FORMAT_VERSION
272
277
  constrainParamPlain
273
278
  createParamControl
279
+ paramElementMetadata
274
280
  createParamDomain
275
281
  createProcessorArtifactFiles
276
282
  decodeDelegateRecords
@@ -291,6 +297,17 @@ validateProcessorModule
291
297
  writeDelegateBatch
292
298
  writeExecutionOutput
293
299
  writePrintBatch
300
+ OndaPayloadType
301
+ OndaPayloadField
302
+ OndaPayloadSchema
303
+ OndaPayloadDefault
304
+ OndaPayloadValue
305
+ OndaPayloadSizes
306
+ PayloadPlan
307
+ canonicalF32Number
308
+ EVENT_INPUT_SIZE_BYTES
309
+ PROCESSOR_EXECUTION_INPUT_REJECTED
310
+ writeEventInput
294
311
  <!-- END WEB API onda_processor_abi -->
295
312
 
296
313
  ### `@onda-lang/binaryen-web`
@@ -405,3 +422,21 @@ paramNormalizedToPlain
405
422
  paramPlainToNormalized
406
423
  registerOndaAudioWorklet
407
424
  <!-- END WEB API onda_webaudio -->
425
+
426
+ ### Structured event and delegate values
427
+
428
+ `PayloadPlan` prepares a recursive message schema once. `encode(values)` accepts ordered parameter
429
+ arrays or objects keyed by parameter name; structs are objects containing exactly their declared
430
+ own fields, and arrays/tuples are sequences. Unknown named parameters and excess positional values
431
+ are rejected. Missing event arguments use declared constant defaults.
432
+ Supply `i64` as `bigint`, an exact safe integer, or a decimal string. `decode(bytes)` returns named
433
+ nested values with `bigint` for `i64`. Plans own immutable schema snapshots.
434
+
435
+ `requiredWorkspace(bytes)` preflights packed little-endian input. `sizes(lengths)` reports wire
436
+ and aligned workspace sizes without allocating payload storage. Low-level wasm hosts provision
437
+ both regions and a 16-byte descriptor, then call `writeEventInput` before invoking the event.
438
+ Rejected input returns `PROCESSOR_EXECUTION_INPUT_REJECTED` (2) and leaves processor state usable.
439
+ `OndaAudioProcessor.trigger` performs logical encoding before transferring bytes to the worklet.
440
+ Decoded `f32` leaves use `canonicalF32Number(value)`: the returned JavaScript number has the
441
+ shortest decimal representation that round-trips to the same `f32`, so JSON and controls do not
442
+ expose an irrelevant widened binary tail.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onda-lang/webaudio",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "type": "module",
5
5
  "description": "Optional Web Audio adapter for Onda processor WebAssembly artifacts",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  "prepack": "node ../../scripts/stage-web-api-docs.mjs"
35
35
  },
36
36
  "dependencies": {
37
- "@onda-lang/processor-abi": "0.8.2"
37
+ "@onda-lang/processor-abi": "0.8.4"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"
package/src/index.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { paramAddress } from "./param-metadata.js";
1
2
  import {
3
+ PayloadPlan,
2
4
  createParamControl,
5
+ paramElementMetadata,
3
6
  decodeDelegateRecords,
4
7
  formatPrintRecords,
5
8
  validateProcessorArtifact,
@@ -152,18 +155,24 @@ function configuredExecutionOutputCapacity(
152
155
  return capacity;
153
156
  }
154
157
 
155
- function paramInfoFor(paramInfo, selector) {
156
- const info = Number.isInteger(selector)
157
- ? paramInfo[selector]
158
- : paramInfo.find((candidate) => candidate.name === selector);
159
- if (!info) {
160
- throw new Error(`unknown Onda parameter '${String(selector)}'`);
158
+ function paramElementInfo(info, element, cache = null) {
159
+ if (!cache) return paramElementMetadata(info, element);
160
+ let elements = cache.get(info);
161
+ if (!elements) {
162
+ elements = new Map();
163
+ cache.set(info, elements);
161
164
  }
162
- return info;
165
+ if (!elements.has(element)) elements.set(element, paramElementMetadata(info, element));
166
+ return elements.get(element);
167
+ }
168
+
169
+ function paramInfoFor(params, selector, cache = null) {
170
+ const { info, element } = paramAddress(params, selector);
171
+ return element === null ? info : paramElementInfo(info, element, cache);
163
172
  }
164
173
 
165
174
  function preparedParamControl(info, cache = null) {
166
- if (Number(info.array_len) !== 1) return null;
175
+ if (info.type_repr !== info.scalar) return null;
167
176
  if (info.scalar !== "bool" && info.param_control === null) return null;
168
177
  let control = cache?.get(info);
169
178
  if (!control) {
@@ -173,7 +182,15 @@ function preparedParamControl(info, cache = null) {
173
182
  return control;
174
183
  }
175
184
 
176
- function constrainParamValue(info, value, cache = null) {
185
+ function constrainParamValue(info, value, cache = null, elements = null) {
186
+ if (info.type_repr !== info.scalar) {
187
+ if ((!Array.isArray(value) && !ArrayBuffer.isView(value)) || value.length !== info.array_len) {
188
+ throw new Error(`Onda parameter '${info.name}' requires exactly ${info.array_len} values`);
189
+ }
190
+ return Array.from(value, (item, index) => constrainParamValue(
191
+ paramElementInfo(info, index, elements), item, cache, elements,
192
+ ));
193
+ }
177
194
  return preparedParamControl(info, cache)?.constrainPlain(value) ?? value;
178
195
  }
179
196
 
@@ -290,7 +307,9 @@ export class OndaAudioProcessor {
290
307
  this.node = node;
291
308
  this.metadata = metadata;
292
309
  this.paramInfo = metadata?.metadata?.params ?? null;
310
+ this.eventPlans = new WeakMap();
293
311
  this.paramControls = new WeakMap();
312
+ this.paramElements = new WeakMap();
294
313
  this.nextRequestId = 1;
295
314
  this.pending = new Map();
296
315
  this.delegateListeners = new Set();
@@ -580,10 +599,10 @@ export class OndaAudioProcessor {
580
599
  if (!Array.isArray(this.paramInfo)) {
581
600
  return this.request("set-param", { param, value });
582
601
  }
583
- const info = paramInfoFor(this.paramInfo ?? [], param);
602
+ const info = paramInfoFor(this.paramInfo ?? [], param, this.paramElements);
584
603
  return this.request("set-param", {
585
604
  param,
586
- value: constrainParamValue(info, value, this.paramControls),
605
+ value: constrainParamValue(info, value, this.paramControls, this.paramElements),
587
606
  });
588
607
  } catch (error) {
589
608
  return Promise.reject(error);
@@ -597,7 +616,7 @@ export class OndaAudioProcessor {
597
616
  "setParamNormalized requires processor metadata; construct the adapter with createOndaAudioProcessor()",
598
617
  );
599
618
  }
600
- const info = paramInfoFor(this.paramInfo, param);
619
+ const info = paramInfoFor(this.paramInfo, param, this.paramElements);
601
620
  const control = preparedParamControl(info, this.paramControls);
602
621
  if (!control) {
603
622
  throw new Error(`Onda parameter '${info.name}' has no scalar host-control domain`);
@@ -612,7 +631,16 @@ export class OndaAudioProcessor {
612
631
  }
613
632
 
614
633
  trigger(event, values = {}) {
615
- return this.request("event", { event, values });
634
+ try {
635
+ this.assertOpen();
636
+ const events = this.metadata?.metadata?.events;
637
+ const info = Number.isInteger(event) ? events?.[event] : events?.find((entry) => entry.name === event);
638
+ if (!info) throw new Error(`unknown Onda event '${String(event)}'`);
639
+ let plan = this.eventPlans.get(info);
640
+ if (!plan) { plan = new PayloadPlan(info.schema); this.eventPlans.set(info, plan); }
641
+ const payload = plan.encode(values);
642
+ return this.request("event", { event, payload }, [payload.buffer]);
643
+ } catch (error) { return Promise.reject(error); }
616
644
  }
617
645
 
618
646
  onDelegates(listener) {
@@ -0,0 +1,18 @@
1
+ // Shared by the adapter and worklet; keep this module independent of package
2
+ // imports so worklets also load from a plain static HTTP directory.
3
+ export function paramAddress(params, selector) {
4
+ const direct = Number.isInteger(selector) ? params[selector] : params.find((param) => param.name === selector);
5
+ if (direct) return { info: direct, element: null };
6
+ if (typeof selector === "string") {
7
+ const match = /^(.*)\[(0|[1-9][0-9]*)\]$/.exec(selector);
8
+ const root = match && params.find((param) => param.name === match[1] && param.type_repr !== param.scalar);
9
+ if (root) {
10
+ const element = Number(match[2]);
11
+ if (!Number.isSafeInteger(element) || element >= root.array_len) {
12
+ throw new Error(`Onda parameter '${root.name}' element index is out of bounds`);
13
+ }
14
+ return { info: root, element };
15
+ }
16
+ }
17
+ throw new Error(`unknown Onda parameter '${String(selector)}'`);
18
+ }
package/src/worklet.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { paramAddress } from "./param-metadata.js";
1
2
  import {
2
3
  EXECUTION_OPERATION_EVENT,
3
4
  EXECUTION_OPERATION_INIT,
@@ -11,6 +12,7 @@ const ONDA_PROCESS_BEGIN_BLOCK = 1 << 0;
11
12
  const ONDA_PROCESS_END_BLOCK = 1 << 1;
12
13
  const ONDA_INIT_PRESERVE_PINNED = 0;
13
14
  const ONDA_INIT_FULL = 1;
15
+ const PROCESSOR_EXECUTION_INPUT_REJECTED = 2;
14
16
  const ONDA_AUDIO_WORKLET_PROCESSOR_NAME = "onda-wasm-processor";
15
17
  const DEFAULT_EVENT_PAYLOAD_CAPACITY_BYTES = 64 * 1024;
16
18
  const DEFAULT_DELEGATE_CAPACITY_BYTES = 64 * 1024;
@@ -188,6 +190,12 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
188
190
  this.eventPayloadPtr = this.eventPayloadCapacity
189
191
  ? this.alloc(this.eventPayloadCapacity, 8)
190
192
  : 0;
193
+ // Each tensor and dynamic prefix adds at most seven alignment bytes.
194
+ // Reserve before rendering; dispatch never grows memory for preparation.
195
+ this.eventWorkspaceCapacity = this.eventPayloadCapacity + 7 * Math.max(0,
196
+ ...this.eventInfo.map((event) => event.params.length * 2));
197
+ this.eventWorkspacePtr = this.eventWorkspaceCapacity ? this.alloc(this.eventWorkspaceCapacity, 8) : 0;
198
+ this.eventInputPtr = this.eventInfo.length ? this.alloc(16, 4) : 0;
191
199
  this.delegateCapacity = this.configuredDelegateCapacity(
192
200
  processorOptions.delegateCapacityBytes,
193
201
  );
@@ -340,6 +348,7 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
340
348
  this.outputCapacityFrames,
341
349
  );
342
350
  });
351
+ this.eventPayloadView = new Uint8Array(buffer, this.eventPayloadPtr, this.eventPayloadCapacity);
343
352
  this.delegateRecordView = this.delegateStoragePtr
344
353
  ? new Uint8Array(buffer, this.delegateStoragePtr, this.delegateCapacity)
345
354
  : null;
@@ -477,13 +486,7 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
477
486
  }
478
487
 
479
488
  setParam(selector, value) {
480
- const paramId = Number.isInteger(selector)
481
- ? selector
482
- : this.paramInfo.findIndex((param) => param.name === selector);
483
- const param = this.paramInfo[paramId];
484
- if (!param) {
485
- throw new Error(`unknown Onda parameter '${String(selector)}'`);
486
- }
489
+ const { info: param, element } = paramAddress(this.paramInfo, selector);
487
490
  if (value === undefined) {
488
491
  throw new Error(`Onda parameter '${param.name}' requires a value`);
489
492
  }
@@ -502,6 +505,10 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
502
505
  ) {
503
506
  throw new Error(`Onda parameter '${param.name}' has invalid storage metadata`);
504
507
  }
508
+ if (element !== null) {
509
+ this.writeScalar(this.paramsPtr + offset + element * this.scalarByteSize(param.scalar), param.scalar, value, this.memoryView());
510
+ return;
511
+ }
505
512
  this.writeStorage(
506
513
  this.paramsPtr + offset,
507
514
  param,
@@ -711,7 +718,7 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
711
718
  } else if (message.type === "event") {
712
719
  this.dispatchEvent(
713
720
  message.event ?? message.name ?? message.index,
714
- message.values ?? message.args ?? {},
721
+ message.payload,
715
722
  );
716
723
  this.postResponse(message, { type: "onda-ok", operation: message.type });
717
724
  } else if (message.type === "read-control-outputs") {
@@ -753,7 +760,7 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
753
760
  }
754
761
  }
755
762
 
756
- dispatchEvent(selector, values) {
763
+ dispatchEvent(selector, payload) {
757
764
  this.requireInitialized("event dispatch");
758
765
  const eventId = Number.isInteger(selector)
759
766
  ? selector
@@ -762,61 +769,23 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
762
769
  if (!event) {
763
770
  throw new Error(`unknown Onda event '${String(selector)}'`);
764
771
  }
765
- let payloadSize = 0;
766
- for (let paramId = 0; paramId < event.params.length; paramId += 1) {
767
- const param = event.params[paramId];
768
- const value = this.eventValue(event, param, paramId, values);
769
- if (param.is_slice) {
770
- const length = this.sequenceLength(
771
- value,
772
- `event '${event.name}' slice '${param.name}'`,
773
- );
774
- payloadSize = this.checkedEventPayloadSize(
775
- payloadSize,
776
- 4 + length * this.scalarByteSize(param.scalar),
777
- event.name,
778
- );
779
- } else {
780
- this.validateStorageValue(param, value);
781
- payloadSize = this.checkedEventPayloadSize(
782
- payloadSize,
783
- Number(param.byte_size ?? 0),
784
- event.name,
785
- );
786
- }
787
- }
772
+ if (!(payload instanceof Uint8Array)) throw new TypeError("event payload must be encoded bytes");
773
+ const payloadSize = payload.byteLength;
788
774
  if (payloadSize > this.eventPayloadCapacity) {
789
- throw new Error(
790
- `event '${event.name}' requires ${payloadSize} payload bytes; configured capacity is ${this.eventPayloadCapacity}`,
791
- );
775
+ throw new Error(`event '${event.name}' requires ${payloadSize} payload bytes; configured capacity is ${this.eventPayloadCapacity}`);
792
776
  }
793
-
794
- let offset = 0;
795
777
  const view = this.memoryView();
796
- for (let paramId = 0; paramId < event.params.length; paramId += 1) {
797
- const param = event.params[paramId];
798
- const value = this.eventValue(event, param, paramId, values);
799
- const address = this.eventPayloadPtr + offset;
800
- if (param.is_slice) {
801
- const length = this.sequenceLength(
802
- value,
803
- `event '${event.name}' slice '${param.name}'`,
804
- );
805
- view.setInt32(address, length, true);
806
- this.writeScalarValues(address + 4, param.scalar, value, length, view);
807
- offset += 4 + length * this.scalarByteSize(param.scalar);
808
- } else {
809
- this.writeStorage(address, param, value, view);
810
- offset += Number(param.byte_size ?? 0);
811
- }
812
- }
778
+ this.eventPayloadView.set(payload);
813
779
  const handler = this.exports[event.export];
814
780
  if (typeof handler !== "function") {
815
781
  throw new Error(`missing WebAssembly export '${event.export}'`);
816
782
  }
817
- this.prepareExecutionOutput();
783
+ view.setUint32(this.eventInputPtr, this.eventPayloadPtr, true);
784
+ view.setUint32(this.eventInputPtr + 4, payloadSize, true);
785
+ view.setUint32(this.eventInputPtr + 8, this.eventWorkspacePtr, true);
786
+ view.setUint32(this.eventInputPtr + 12, this.eventWorkspaceCapacity, true);
818
787
  const status = handler(
819
- this.eventPayloadPtr,
788
+ this.eventInputPtr,
820
789
  this.paramsPtr,
821
790
  this.statePtr,
822
791
  this.bufferPointersPtr,
@@ -825,6 +794,7 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
825
794
  this.bufferSampleRatesPtr,
826
795
  this.executionOutputPtr,
827
796
  );
797
+ if (status === PROCESSOR_EXECUTION_INPUT_REJECTED) throw new Error(`event '${event.name}' input rejected: invalid payload or insufficient workspace`);
828
798
  this.publishExecutionOutput(
829
799
  EXECUTION_OPERATION_EVENT,
830
800
  eventId,
@@ -832,34 +802,6 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
832
802
  this.checkExecutionStatus(status, `event '${event.name}'`);
833
803
  }
834
804
 
835
- eventValue(event, param, paramId, values) {
836
- const supplied = Array.isArray(values)
837
- ? values[paramId]
838
- : values?.[param.name];
839
- const value = supplied === undefined
840
- ? this.metadataDefaultValue(param)
841
- : supplied;
842
- if (value === undefined) {
843
- throw new Error(
844
- `event '${event.name}' requires parameter '${param.name}'`,
845
- );
846
- }
847
- return value;
848
- }
849
-
850
- checkedEventPayloadSize(current, additional, eventName) {
851
- const next = current + additional;
852
- if (
853
- !Number.isSafeInteger(additional)
854
- || additional < 0
855
- || !Number.isSafeInteger(next)
856
- || next > 0x7fff_ffff
857
- ) {
858
- throw new Error(`event '${eventName}' payload exceeds the 32-bit ABI limit`);
859
- }
860
- return next;
861
- }
862
-
863
805
  readControlOutputs() {
864
806
  this.requireInitialized("reading control outputs");
865
807
  return Object.fromEntries(