@onda-lang/webaudio 0.5.4 → 0.7.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/README.md CHANGED
@@ -13,6 +13,7 @@ const processor = await createOndaAudioProcessor(audioContext, artifact, {
13
13
  });
14
14
  processor.node.connect(audioContext.destination);
15
15
  await processor.setParam("gain", 0.75);
16
+ await processor.setParamNormalized("cutoff", 0.5);
16
17
  const snapshot = await processor.snapshot();
17
18
  ```
18
19
 
@@ -21,6 +22,46 @@ metadata, marshals declared scalar widths, schedules arbitrary render quanta acr
21
22
  blocks, and provides request/response helpers for parameters, events, buffers, control outputs,
22
23
  reset, and portable snapshots.
23
24
 
25
+ ## Parameters
26
+
27
+ `params` passed during construction and values passed to `setParam()` are plain Onda values. The
28
+ adapter clamps ranged scalar values and snaps stepped domains before posting them; the real-time
29
+ worklet only writes the resulting canonical value in the declared scalar representation. Unknown
30
+ initial parameter names or indices are rejected before the worklet node is constructed.
31
+
32
+ `setParamNormalized()` accepts a host value in `[0, 1]`. The adapter uses the artifact descriptor's
33
+ linear or logarithmic scale and step metadata to convert it to a plain value before posting the
34
+ write to the worklet. Boolean normalized values use the `0.5` threshold. For example:
35
+
36
+ ```js
37
+ await processor.setParam("cutoff", 440); // exactly 440 Hz
38
+ await processor.setParamNormalized("cutoff", 0.5); // midpoint in its declared control scale
39
+ ```
40
+
41
+ The same synchronous conversion helpers are re-exported for UI display and typed entry:
42
+
43
+ ```js
44
+ import {
45
+ paramNormalizedToPlain,
46
+ paramPlainToNormalized,
47
+ } from "@onda-lang/webaudio";
48
+
49
+ const cutoff = processor.metadata.metadata.params.find(
50
+ (param) => param.name === "cutoff",
51
+ );
52
+ const displayValue = paramNormalizedToPlain(cutoff, sliderValue);
53
+ const sliderValueFor440Hz = paramPlainToNormalized(cutoff, 440);
54
+ ```
55
+
56
+ The helpers preserve exact endpoints, clamp and snap consistently with adapter writes, and reject
57
+ arrays or numeric parameters without a host-control domain. `scale`, `curve`, `unit`, `step_repr`,
58
+ and `step_count` remain available on each parameter's descriptor metadata for control construction
59
+ and formatting. For repeated UI conversion, `createParamControl(param)` prepares and validates the
60
+ descriptor once and returns bound conversion methods.
61
+
62
+ Controlled `i64` domains use the descriptor's exact binary64 integer range. Full-width unranged
63
+ `i64` values continue to use `bigint` when written directly.
64
+
24
65
  The artifact must be compiled for exactly `audioContext.sampleRate`; the adapter rejects a mismatch
25
66
  before registering the node so sample-rate-derived language semantics cannot silently drift. A Web
26
67
  Audio processor must expose at least one audio input or output, because an empty callback surface
@@ -49,12 +90,35 @@ After construction, the normal f32 render callback reuses cached Wasm-memory vie
49
90
  host-side allocation or memory growth. Full-block f32 inputs and outputs use typed-array bulk copies;
50
91
  segmented callbacks and other ABI scalar widths use preallocated typed views with conversion loops
51
92
  (i64 input conversion necessarily creates JavaScript `BigInt` values). External buffers are copied
52
- into Wasm with typed-array bulk operations during construction. Every declared external buffer must
53
- be supplied with nonempty data; the adapter rejects missing or empty bindings before creating the
54
- rendering node.
93
+ into Wasm with typed-array bulk operations during construction. Missing or `null` bindings install
94
+ neutral one-frame descriptors: reads return zero, writes are discarded, exact channel counts are
95
+ retained, and dynamic-channel buffers report one channel. Supplied bindings must still contain
96
+ nonempty, correctly shaped data.
97
+
98
+ Fixed buffer arrays may be supplied under their logical group name. Each slot is independent:
99
+
100
+ ```js
101
+ const processor = await createOndaAudioProcessor(context, artifact, {
102
+ buffers: {
103
+ impulse: { data: impulseSamples, channels: 1, sampleRate: 48_000 },
104
+ bank: [
105
+ null,
106
+ { data: secondSamples, channels: 1, sampleRate: 48_000 },
107
+ // Remaining slots are neutral.
108
+ ],
109
+ },
110
+ });
111
+ ```
112
+
113
+ Hosts may instead pass a flat array in physical descriptor order or key individual physical names
114
+ such as `"bank[1]"`. Logical group metadata determines the contiguous slot range; no sample data is
115
+ copied when Onda selects a slot while processing.
55
116
 
56
117
  Artifact descriptors and module exports are validated by the shared, compiler-free
57
118
  `@onda-lang/processor-abi` package before anything reaches the rendering thread.
119
+ If generated init, event, or process code returns a nonzero execution status, the adapter reports
120
+ an `onda-error`, latches the failed state, and emits silence without re-entering processing. A
121
+ successful reset clears the latch after reinitializing state.
58
122
 
59
123
  Dynamic event storage is also allocated before rendering. Its default capacity is 64 KiB per
60
124
  processor with dynamic events and can be changed explicitly:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onda-lang/webaudio",
3
- "version": "0.5.4",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Optional Web Audio adapter for Onda processor WebAssembly artifacts",
6
6
  "license": "MIT",
@@ -32,7 +32,7 @@
32
32
  "test": "node --test"
33
33
  },
34
34
  "dependencies": {
35
- "@onda-lang/processor-abi": "0.5.4"
35
+ "@onda-lang/processor-abi": "0.7.0"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
package/src/index.d.ts CHANGED
@@ -1,11 +1,31 @@
1
1
  export const ONDA_AUDIO_WORKLET_PROCESSOR_NAME: "onda-wasm-processor";
2
2
 
3
- export type { OndaProcessorArtifact, OndaProcessorMetadata } from "@onda-lang/processor-abi";
4
- import type { OndaProcessorArtifact } from "@onda-lang/processor-abi";
3
+ export type {
4
+ OndaParamDomain,
5
+ OndaPreparedParamControl,
6
+ OndaProcessorArtifact,
7
+ OndaProcessorMetadata,
8
+ } from "@onda-lang/processor-abi";
9
+ export {
10
+ createParamDomain,
11
+ createParamControl,
12
+ constrainParamPlain,
13
+ paramNormalizedToPlain,
14
+ paramPlainToNormalized,
15
+ } from "@onda-lang/processor-abi";
16
+ import type {
17
+ OndaProcessorArtifact,
18
+ OndaProcessorMetadata,
19
+ } from "@onda-lang/processor-abi";
5
20
 
6
21
  export interface OndaAudioProcessorOptions {
7
22
  workletUrl?: string | URL;
23
+ /** Initial plain Onda parameter values, keyed or ordered by descriptor parameter. */
8
24
  params?: Record<string, unknown> | unknown[];
25
+ /**
26
+ * Initial external-buffer bindings keyed by physical name, grouped by logical array name, or
27
+ * ordered by physical descriptor slot. Missing and null slots use neutral one-frame storage.
28
+ */
9
29
  buffers?: Record<string, unknown> | unknown[];
10
30
  /** Preallocated capacity for dynamic event payloads. Defaults to 64 KiB. */
11
31
  eventPayloadCapacityBytes?: number;
@@ -34,10 +54,14 @@ export function compileOndaProcessorModule(
34
54
  ): Promise<WebAssembly.Module>;
35
55
 
36
56
  export class OndaAudioProcessor {
37
- constructor(node: AudioWorkletNode);
57
+ constructor(node: AudioWorkletNode, metadata?: OndaProcessorMetadata | null);
38
58
  readonly node: AudioWorkletNode;
59
+ readonly metadata: OndaProcessorMetadata | null;
39
60
  request(type: string, fields?: Record<string, unknown>, transfer?: Transferable[]): Promise<any>;
61
+ /** Set a plain Onda value; ranged scalar values are clamped and snapped. */
40
62
  setParam(param: string | number, value: unknown): Promise<any>;
63
+ /** Map a host value in [0, 1] through the descriptor and set the resulting plain value. */
64
+ setParamNormalized(param: string | number, value: number): Promise<any>;
41
65
  trigger(event: string | number, values?: Record<string, unknown> | unknown[]): Promise<any>;
42
66
  reset(): Promise<any>;
43
67
  snapshot(): Promise<Uint8Array>;
package/src/index.js CHANGED
@@ -1,8 +1,17 @@
1
1
  import {
2
+ createParamControl,
2
3
  validateProcessorArtifact,
3
4
  validateProcessorModule,
4
5
  } from "@onda-lang/processor-abi";
5
6
 
7
+ export {
8
+ createParamDomain,
9
+ createParamControl,
10
+ constrainParamPlain,
11
+ paramNormalizedToPlain,
12
+ paramPlainToNormalized,
13
+ } from "@onda-lang/processor-abi";
14
+
6
15
  export const ONDA_AUDIO_WORKLET_PROCESSOR_NAME = "onda-wasm-processor";
7
16
 
8
17
  const registrationByContext = new WeakMap();
@@ -65,7 +74,10 @@ function audioWorkletNodeOptionsFromValidated(
65
74
  ? { wasmBytes: wasm }
66
75
  : { wasmModule: options.compiledModule }),
67
76
  metadata,
68
- params: options.params ?? {},
77
+ params: constrainInitialParamValues(
78
+ metadata.metadata.params,
79
+ options.params ?? {},
80
+ ),
69
81
  buffers: options.buffers ?? {},
70
82
  eventPayloadCapacityBytes: options.eventPayloadCapacityBytes,
71
83
  },
@@ -78,6 +90,50 @@ function audioWorkletNodeOptionsFromValidated(
78
90
  return nodeOptions;
79
91
  }
80
92
 
93
+ function paramInfoFor(paramInfo, selector) {
94
+ const info = Number.isInteger(selector)
95
+ ? paramInfo[selector]
96
+ : paramInfo.find((candidate) => candidate.name === selector);
97
+ if (!info) {
98
+ throw new Error(`unknown Onda parameter '${String(selector)}'`);
99
+ }
100
+ return info;
101
+ }
102
+
103
+ function preparedParamControl(info, cache = null) {
104
+ if (Number(info.array_len) !== 1) return null;
105
+ if (info.scalar !== "bool" && info.param_control === null) return null;
106
+ let control = cache?.get(info);
107
+ if (!control) {
108
+ control = createParamControl(info);
109
+ cache?.set(info, control);
110
+ }
111
+ return control;
112
+ }
113
+
114
+ function constrainParamValue(info, value, cache = null) {
115
+ return preparedParamControl(info, cache)?.constrainPlain(value) ?? value;
116
+ }
117
+
118
+ function constrainInitialParamValues(paramInfo, values) {
119
+ if (Array.isArray(values)) {
120
+ return values.map((value, index) => (
121
+ value === undefined
122
+ ? value
123
+ : constrainParamValue(paramInfoFor(paramInfo, index), value)
124
+ ));
125
+ }
126
+ if (values && typeof values === "object") {
127
+ return Object.fromEntries(
128
+ Object.entries(values).map(([name, value]) => [
129
+ name,
130
+ constrainParamValue(paramInfoFor(paramInfo, name), value),
131
+ ]),
132
+ );
133
+ }
134
+ throw new Error("params must be an array or object");
135
+ }
136
+
81
137
  export async function registerOndaAudioWorklet(
82
138
  context,
83
139
  workletUrl = new URL("./worklet.js", import.meta.url),
@@ -123,7 +179,7 @@ export async function createOndaAudioProcessor(context, artifact, options = {})
123
179
  false,
124
180
  ),
125
181
  );
126
- return new OndaAudioProcessor(node);
182
+ return new OndaAudioProcessor(node, validated.metadata);
127
183
  }
128
184
 
129
185
  export async function compileOndaProcessorModule(artifact) {
@@ -139,8 +195,11 @@ async function compileValidatedProcessorModule({ wasm, metadata }) {
139
195
  }
140
196
 
141
197
  export class OndaAudioProcessor {
142
- constructor(node) {
198
+ constructor(node, metadata = null) {
143
199
  this.node = node;
200
+ this.metadata = metadata;
201
+ this.paramInfo = metadata?.metadata?.params ?? null;
202
+ this.paramControls = new WeakMap();
144
203
  this.nextRequestId = 1;
145
204
  this.pending = new Map();
146
205
  this.handleMessage = (event) => {
@@ -173,7 +232,39 @@ export class OndaAudioProcessor {
173
232
  }
174
233
 
175
234
  setParam(param, value) {
176
- return this.request("set-param", { param, value });
235
+ try {
236
+ if (!Array.isArray(this.paramInfo)) {
237
+ return this.request("set-param", { param, value });
238
+ }
239
+ const info = paramInfoFor(this.paramInfo ?? [], param);
240
+ return this.request("set-param", {
241
+ param,
242
+ value: constrainParamValue(info, value, this.paramControls),
243
+ });
244
+ } catch (error) {
245
+ return Promise.reject(error);
246
+ }
247
+ }
248
+
249
+ setParamNormalized(param, value) {
250
+ try {
251
+ if (!Array.isArray(this.paramInfo)) {
252
+ throw new Error(
253
+ "setParamNormalized requires processor metadata; construct the adapter with createOndaAudioProcessor()",
254
+ );
255
+ }
256
+ const info = paramInfoFor(this.paramInfo, param);
257
+ const control = preparedParamControl(info, this.paramControls);
258
+ if (!control) {
259
+ throw new Error(`Onda parameter '${info.name}' has no scalar host-control domain`);
260
+ }
261
+ return this.request("set-param", {
262
+ param,
263
+ value: control.normalizedToPlain(value),
264
+ });
265
+ } catch (error) {
266
+ return Promise.reject(error);
267
+ }
177
268
  }
178
269
 
179
270
  trigger(event, values = {}) {
package/src/worklet.js CHANGED
@@ -70,6 +70,9 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
70
70
  this.bufferInfo = Array.isArray(metadata.metadata?.buffers)
71
71
  ? metadata.metadata.buffers
72
72
  : [];
73
+ this.bufferArrayInfo = Array.isArray(metadata.metadata?.buffer_arrays)
74
+ ? metadata.metadata.buffer_arrays
75
+ : [];
73
76
  this.inputInfo = Array.isArray(metadata.metadata?.inputs)
74
77
  ? metadata.metadata.inputs
75
78
  : [];
@@ -112,6 +115,7 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
112
115
  this.outputPtrs = [];
113
116
  this.outputCapacityFrames = 0;
114
117
  this.blockCursor = 0;
118
+ this.executionFailed = false;
115
119
 
116
120
  const paramBytes = Number(metadata.runtime?.param_size_bytes ?? 0);
117
121
  if (!Number.isInteger(paramBytes) || paramBytes < 0) {
@@ -338,14 +342,28 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
338
342
  ) {
339
343
  throw new Error(`Onda parameter '${param.name}' has invalid storage metadata`);
340
344
  }
341
- this.writeStorage(this.paramsPtr + offset, param, value);
345
+ this.writeStorage(
346
+ this.paramsPtr + offset,
347
+ param,
348
+ value,
349
+ );
342
350
  }
343
351
 
344
352
  reset() {
345
353
  this.refreshMemoryCache();
346
354
  this.stateBytes.fill(0);
347
355
  this.blockCursor = 0;
348
- this.exports.onda_init(this.paramsPtr, this.statePtr);
356
+ this.executionFailed = false;
357
+ this.checkExecutionStatus(
358
+ this.exports.onda_init(this.paramsPtr, this.statePtr),
359
+ "processor init",
360
+ );
361
+ }
362
+
363
+ checkExecutionStatus(status, operation) {
364
+ if (status === 0) return;
365
+ this.executionFailed = true;
366
+ throw new Error(`${operation} failed with Onda execution status ${String(status)}`);
349
367
  }
350
368
 
351
369
  createSnapshot() {
@@ -536,14 +554,17 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
536
554
  if (typeof handler !== "function") {
537
555
  throw new Error(`missing WebAssembly export '${event.export}'`);
538
556
  }
539
- handler(
540
- this.eventPayloadPtr,
541
- this.paramsPtr,
542
- this.statePtr,
543
- this.bufferPointersPtr,
544
- this.bufferFramesPtr,
545
- this.bufferChannelsPtr,
546
- this.bufferSampleRatesPtr,
557
+ this.checkExecutionStatus(
558
+ handler(
559
+ this.eventPayloadPtr,
560
+ this.paramsPtr,
561
+ this.statePtr,
562
+ this.bufferPointersPtr,
563
+ this.bufferFramesPtr,
564
+ this.bufferChannelsPtr,
565
+ this.bufferSampleRatesPtr,
566
+ ),
567
+ `event '${event.name}'`,
547
568
  );
548
569
  }
549
570
 
@@ -589,11 +610,21 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
589
610
 
590
611
  bindInitialBuffers(options) {
591
612
  this.bufferInfo.forEach((buffer, bufferId) => {
592
- const supplied = Array.isArray(options)
593
- ? options[bufferId]
594
- : options[buffer.name];
595
- if (supplied === undefined) {
596
- throw new Error(`Onda buffer '${buffer.name}' is not bound`);
613
+ const supplied = this.initialBufferOption(options, buffer, bufferId);
614
+ const declaredChannels = Number(buffer.static_channels ?? 0);
615
+ const fallbackChannels = declaredChannels || 1;
616
+ if (supplied === undefined || supplied === null) {
617
+ const sampleRate = Math.fround(this.compileSampleRate);
618
+ this.writeBufferDescriptor(bufferId, 0, 1, fallbackChannels, sampleRate);
619
+ this.bufferBindings[bufferId] = {
620
+ ...buffer,
621
+ pointer: 0,
622
+ frames: 1,
623
+ channels: fallbackChannels,
624
+ sampleRate,
625
+ bound: false,
626
+ };
627
+ return;
597
628
  }
598
629
  const descriptor =
599
630
  Array.isArray(supplied) || ArrayBuffer.isView(supplied)
@@ -614,7 +645,6 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
614
645
  `Onda buffer '${buffer.name}' requires non-empty bound data`,
615
646
  );
616
647
  }
617
- const declaredChannels = Number(buffer.static_channels ?? 0);
618
648
  const channels = Number(descriptor.channels ?? declaredChannels);
619
649
  if (!Number.isInteger(channels) || channels <= 0) {
620
650
  throw new Error(`Onda buffer '${buffer.name}' requires channels > 0`);
@@ -637,25 +667,54 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
637
667
  const elementSize = this.scalarByteSize(buffer.scalar);
638
668
  const pointer = this.alloc(length * elementSize, elementSize);
639
669
  this.writeScalarValues(pointer, buffer.scalar, data, length);
640
- const view = this.memoryView();
641
- view.setUint32(this.bufferPointersPtr + bufferId * 4, pointer, true);
642
- view.setInt32(this.bufferFramesPtr + bufferId * 4, frames, true);
643
- view.setInt32(this.bufferChannelsPtr + bufferId * 4, channels, true);
644
- view.setFloat32(
645
- this.bufferSampleRatesPtr + bufferId * 4,
646
- sampleRate,
647
- true,
648
- );
670
+ this.writeBufferDescriptor(bufferId, pointer, frames, channels, sampleRate);
649
671
  this.bufferBindings[bufferId] = {
650
672
  ...buffer,
651
673
  pointer,
652
674
  frames,
653
675
  channels,
654
676
  sampleRate,
677
+ bound: true,
655
678
  };
656
679
  });
657
680
  }
658
681
 
682
+ writeBufferDescriptor(bufferId, pointer, frames, channels, sampleRate) {
683
+ const view = this.memoryView();
684
+ view.setUint32(this.bufferPointersPtr + bufferId * 4, pointer, true);
685
+ view.setInt32(this.bufferFramesPtr + bufferId * 4, frames, true);
686
+ view.setInt32(this.bufferChannelsPtr + bufferId * 4, channels, true);
687
+ view.setFloat32(this.bufferSampleRatesPtr + bufferId * 4, sampleRate, true);
688
+ }
689
+
690
+ initialBufferOption(options, buffer, bufferId) {
691
+ if (Array.isArray(options)) {
692
+ return options[bufferId];
693
+ }
694
+ if (!options || typeof options !== "object") {
695
+ throw new Error("processorOptions.buffers must be an array or object");
696
+ }
697
+ if (Object.prototype.hasOwnProperty.call(options, buffer.name)) {
698
+ return options[buffer.name];
699
+ }
700
+ const group = this.bufferArrayInfo.find((candidate) => {
701
+ const first = Number(candidate.first_buffer);
702
+ const len = Number(candidate.len);
703
+ return bufferId >= first && bufferId < first + len;
704
+ });
705
+ if (!group || !Object.prototype.hasOwnProperty.call(options, group.name)) {
706
+ return undefined;
707
+ }
708
+ const supplied = options[group.name];
709
+ if (supplied === undefined || supplied === null) {
710
+ return undefined;
711
+ }
712
+ if (!Array.isArray(supplied)) {
713
+ throw new Error(`Onda buffer array '${group.name}' must be an array`);
714
+ }
715
+ return supplied[bufferId - Number(group.first_buffer)];
716
+ }
717
+
659
718
  readBuffer(selector) {
660
719
  const bufferId = Number.isInteger(selector)
661
720
  ? selector
@@ -1110,8 +1169,18 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
1110
1169
  );
1111
1170
  }
1112
1171
 
1172
+ clearOutputs(outputs) {
1173
+ for (const bus of outputs) {
1174
+ for (const channel of bus) channel.fill(0);
1175
+ }
1176
+ }
1177
+
1113
1178
  process(inputs, outputs) {
1114
1179
  this.refreshMemoryCache();
1180
+ if (this.executionFailed) {
1181
+ this.clearOutputs(outputs);
1182
+ return true;
1183
+ }
1115
1184
  const frames = this.audioFrameCount(inputs, outputs);
1116
1185
 
1117
1186
  let callbackOffset = 0;
@@ -1132,11 +1201,21 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
1132
1201
  startFrame,
1133
1202
  segmentFrames,
1134
1203
  );
1135
- this.invokeProcessSegment(
1204
+ const status = this.invokeProcessSegment(
1136
1205
  startFrame,
1137
1206
  segmentFrames,
1138
1207
  flags,
1139
1208
  );
1209
+ if (status !== 0) {
1210
+ this.executionFailed = true;
1211
+ this.clearOutputs(outputs);
1212
+ this.port.postMessage({
1213
+ type: "onda-error",
1214
+ operation: "process",
1215
+ error: `processor process failed with Onda execution status ${String(status)}`,
1216
+ });
1217
+ return true;
1218
+ }
1140
1219
  this.marshalOutputSegment(
1141
1220
  outputs,
1142
1221
  frames,