@onda-lang/webaudio 0.8.20 → 0.8.22

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
@@ -50,6 +50,24 @@ adapter clamps ranged scalar values and snaps stepped domains before posting the
50
50
  worklet only writes the resulting canonical value in the declared scalar representation. Unknown
51
51
  initial parameter names or indices are rejected before the worklet node is constructed.
52
52
 
53
+ The optional host setting `paramSmoothingSeconds` defaults to `0`. A positive value applies finite
54
+ linear ramps to continuous float parameters and array elements. Values advance once per logical
55
+ block, independently of Web Audio callback sizes; durations at most one block are bypassed.
56
+ Integer, boolean, and stepped controls update directly. Initial parameters and reinitialization
57
+ settle immediately. A new target restarts from the published value, while repeated writes of the
58
+ same target preserve its deadline. Host ramps are excluded from processor snapshots. The browser
59
+ playground defaults to `0.03` seconds (30 ms), with a live **Smooth** control in its Params header
60
+ that remembers the duration in the browser. Explicit DSP smoothing can interpolate within a block.
61
+
62
+ `setParamSmoothingSeconds(seconds)` changes the duration live without recompilation or
63
+ reinitialization. Active ramps restart from the current published value with the new duration.
64
+ Setting 0 (or a duration at most one block) settles active targets immediately and disables ramps;
65
+ smoothing can be enabled later. The duration must be finite and nonnegative, and affects continuous
66
+ float writes from UI controls and automation alike.
67
+
68
+ `resetParams()` restores all declared defaults immediately and cancels host ramps without resetting
69
+ DSP state.
70
+
53
71
  `setParamNormalized()` accepts a host value in `[0, 1]`. The adapter uses the artifact descriptor's
54
72
  linear or logarithmic scale and step metadata to convert it to a plain value before posting the
55
73
  write to the worklet. Boolean normalized values use the `0.5` threshold. For example:
package/api.md CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  const compiler = await createCompiler();
31
31
  const { artifact } = await compiler.compileSource(source, {
32
32
  sampleRate: audioContext.sampleRate,
33
- blockSize: 128,
33
+ blockSize: 512,
34
34
  });
35
35
 
36
36
  const print = ({ text }) => console.debug(text);
@@ -77,12 +77,8 @@ The returned instance provides:
77
77
  - `sendLspMessage(message)` and `setLspAnalysisOptions(options?)` for the embedded language server.
78
78
  - `dispose()`, an idempotent terminal release of compiler and worker resources.
79
79
 
80
- Compile options accept `sampleRate`, `blockSize`, `defaultParamSmoothingSeconds`, typed
81
- compile-constant overrides, and `codegen`. `defaultParamSmoothingSeconds` defaults to `0` and must
82
- be finite and non-negative. It applies to floating-point top-level parameters that omit `smooth`;
83
- explicit metadata, including `smooth = 0`, takes precedence. Positive values specify block-rate
84
- linear ramp durations, rounded up to at least one host sample; the sample count must fit signed
85
- 64-bit storage.
80
+ Compile options accept `sampleRate`, `blockSize`, typed compile-constant overrides, and `codegen`.
81
+ The sample rate defaults to 48000 Hz and the block size to 512 frames.
86
82
  Code generation can select optimization level `0..4`, shrink level `0..2`, strict or fast math,
87
83
  SIMD, loop-containing inlining, and optional WAT emission. A successful compilation returns an
88
84
  `OndaCompilationResult` containing the artifact, resolved source paths, and the exact source graph
@@ -211,12 +207,26 @@ the artifact metadata as the adapter constructor's second argument and
211
207
  `processorOptions.executionOutputRing` as its fourth argument. Event encoding and numeric
212
208
  unknown-index classification require that metadata.
213
209
 
210
+ `paramSmoothingSeconds` is an optional **host** setting on `OndaAudioProcessorOptions`, defaulting
211
+ to `0`. It applies finite linear ramps to continuous floating-point parameters and array elements;
212
+ integer, boolean, and stepped parameters update directly. Ramps publish once per logical compile
213
+ block, independent of Web Audio callback sizes. Durations round up to host samples and are bypassed
214
+ when they fit within one block. A changed target starts a fresh ramp from the published value;
215
+ repeated writes of the same target keep its deadline. Initial values and reinitialization settle
216
+ immediately, and processor snapshots do not contain the host ramps. The browser playground opts
217
+ into `0.03` seconds (30 ms). These block-level updates do not interpolate inside `sample:`.
218
+
214
219
  ### `OndaAudioProcessor`
215
220
 
216
221
  The adapter exposes its `node` and validated `metadata`, plus these operations:
217
222
 
218
223
  - `setParam(nameOrIndex, plain)` and `setParamNormalized(nameOrIndex, normalized)` for whole
219
224
  parameters.
225
+ - `resetParams()` to restore parameter defaults immediately and cancel host ramps, retaining DSP state.
226
+ - `setParamSmoothingSeconds(seconds)` to change the host duration live without recompiling or
227
+ reinitializing DSP state. Finite, nonnegative durations are required. Active ramps restart from
228
+ their current values using the new duration; 0 (or a duration at most one block) settles their
229
+ targets immediately. Smoothing can be enabled after starting with 0.
220
230
  - `setParamElement(nameOrIndex, element, plain)` and
221
231
  `setParamElementNormalized(nameOrIndex, element, normalized)` for one scalar or fixed-array
222
232
  element.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onda-lang/webaudio",
3
- "version": "0.8.20",
3
+ "version": "0.8.22",
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.20"
37
+ "@onda-lang/processor-abi": "0.8.22"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"
package/src/index.d.ts CHANGED
@@ -52,6 +52,12 @@ export interface OndaAudioProcessorOptions {
52
52
  workletUrl?: string | URL;
53
53
  /** Initial plain Onda parameter values, keyed or ordered by descriptor parameter. */
54
54
  params?: Record<string, unknown> | unknown[];
55
+ /**
56
+ * Host-owned linear ramp duration in seconds for continuous float parameters.
57
+ * Defaults to 0. Ramps advance once per logical block and are bypassed when
58
+ * the duration is at most one block. Initial values and reinitialization settle immediately.
59
+ */
60
+ paramSmoothingSeconds?: number;
55
61
  /**
56
62
  * Initial external-buffer bindings keyed by physical name, grouped by logical array name, or
57
63
  * ordered by physical descriptor slot. Missing and null slots use neutral one-frame storage.
@@ -126,6 +132,10 @@ export class OndaAudioProcessor {
126
132
  request(type: string, fields?: Record<string, unknown>, transfer?: Transferable[]): Promise<any>;
127
133
  /** Set a plain Onda value; ranged scalar values are clamped and snapped. */
128
134
  setParam(param: string | number, value: unknown): Promise<any>;
135
+ /** Restore all parameter defaults immediately, cancelling host ramps and retaining DSP state. */
136
+ resetParams(): Promise<any>;
137
+ /** Change host smoothing live, restarting active ramps from their current values; 0 settles them immediately. */
138
+ setParamSmoothingSeconds(seconds: number): Promise<any>;
129
139
  /** Set one scalar or fixed-array parameter element without modifying its siblings. */
130
140
  setParamElement(param: string | number, element: number, value: unknown): Promise<any>;
131
141
  /** Map a host value in [0, 1] through the descriptor and set the resulting plain value. */
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { paramAddress, paramElementAddress } from "./param-metadata.js";
1
+ import { paramAddress, paramElementAddress, paramSmoothingSamples } from "./param-metadata.js";
2
2
  import {
3
3
  PayloadPlan,
4
4
  createParamControl,
@@ -86,6 +86,7 @@ function audioWorkletNodeOptionsFromValidated(
86
86
  if (options.onPrint !== undefined && typeof options.onPrint !== "function") {
87
87
  throw new TypeError("initial print listener must be a function");
88
88
  }
89
+ paramSmoothingSamples(options.paramSmoothingSeconds ?? 0, metadata.compile.sample_rate, metadata.compile.block_size);
89
90
  const inputChannels = flattenedAudioChannelCount(metadata.metadata.inputs);
90
91
  const outputChannels = flattenedAudioChannelCount(metadata.metadata.outputs);
91
92
  if (inputChannels > 32 || outputChannels > 32) {
@@ -133,6 +134,7 @@ function audioWorkletNodeOptionsFromValidated(
133
134
  ? { wasmBytes: wasm }
134
135
  : { wasmModule: options.compiledModule }),
135
136
  metadata,
137
+ paramSmoothingSeconds: options.paramSmoothingSeconds ?? 0,
136
138
  params: constrainInitialParamValues(
137
139
  metadata.metadata.params,
138
140
  options.params ?? {},
@@ -658,6 +660,20 @@ export class OndaAudioProcessor {
658
660
  }
659
661
  }
660
662
 
663
+ setParamSmoothingSeconds(seconds) {
664
+ try {
665
+ paramSmoothingSamples(seconds, this.metadata?.compile?.sample_rate ?? 1,
666
+ this.metadata?.compile?.block_size ?? 0);
667
+ return this.request("set-param-smoothing", { seconds });
668
+ } catch (error) {
669
+ return Promise.reject(error);
670
+ }
671
+ }
672
+
673
+ resetParams() {
674
+ return this.request("reset-params");
675
+ }
676
+
661
677
  setParamElement(param, element, value) {
662
678
  try {
663
679
  if (!Array.isArray(this.paramInfo)) {
@@ -31,3 +31,143 @@ export function paramElementAddress(params, selector, element) {
31
31
  }
32
32
  return { info: address.info, element };
33
33
  }
34
+
35
+ export function paramSmoothingSamples(seconds, sampleRate, blockSize) {
36
+ const samples = Math.ceil(seconds * sampleRate);
37
+ if (!Number.isFinite(seconds) || seconds < 0 || !Number.isSafeInteger(samples)) {
38
+ throw new Error("paramSmoothingSeconds must be finite, non-negative, and fit the host sample counter");
39
+ }
40
+ return samples > blockSize ? samples : 0;
41
+ }
42
+
43
+ // Private host state: descriptors and processor snapshots contain no ramps.
44
+ export class ParamSmoothing {
45
+ constructor(params, samples, view, paramsPtr) {
46
+ this.samples = samples;
47
+ this.inverseSamples = 1 / Math.max(samples, 1);
48
+ this.started = false;
49
+ this.addresses = new Map();
50
+ this.entries = [];
51
+ for (const param of params) {
52
+ if ((param.scalar !== "f32" && param.scalar !== "f64")
53
+ || param.param_control?.step_count != null) continue;
54
+ const elements = [];
55
+ const f32 = param.scalar === "f32";
56
+ for (let element = 0; element < param.array_len; element += 1) {
57
+ const offset = Number(param.byte_offset) + element * (f32 ? 4 : 8);
58
+ const value = f32 ? view.getFloat32(paramsPtr + offset, true)
59
+ : view.getFloat64(paramsPtr + offset, true);
60
+ elements.push(this.entries.length);
61
+ this.entries.push({ offset, f32, current: value, start: value,
62
+ target: value, elapsed: samples, active: false });
63
+ }
64
+ this.addresses.set(param, elements);
65
+ }
66
+ this.active = new Uint32Array(this.entries.length);
67
+ this.activeCount = 0;
68
+ }
69
+
70
+ isEnabledFor(param) {
71
+ return this.samples > 0 && this.addresses.has(param);
72
+ }
73
+
74
+ setDuration(samples, view, paramsPtr) {
75
+ if (samples === this.samples) return;
76
+ // Bulk writes bypass ramp state while disabled; read the actual DSP values.
77
+ if (this.samples === 0) this.readTargets(view, paramsPtr);
78
+ const previousSamples = this.samples;
79
+ this.samples = samples;
80
+ this.inverseSamples = 1 / Math.max(samples, 1);
81
+ for (let cursor = 0; cursor < this.activeCount; cursor += 1) {
82
+ const entry = this.entries[this.active[cursor]];
83
+ if (samples === 0) {
84
+ this.write(entry, entry.target, view, paramsPtr);
85
+ entry.active = false;
86
+ }
87
+ entry.start = entry.current;
88
+ entry.elapsed = entry.elapsed === previousSamples ? samples : 0;
89
+ }
90
+ if (samples === 0) this.activeCount = 0;
91
+ }
92
+
93
+ readTargets(view, paramsPtr) {
94
+ for (const entry of this.entries) {
95
+ entry.current = entry.target = entry.f32
96
+ ? view.getFloat32(paramsPtr + entry.offset, true)
97
+ : view.getFloat64(paramsPtr + entry.offset, true);
98
+ entry.start = entry.current;
99
+ }
100
+ }
101
+
102
+ setTarget(param, element, value, initialized) {
103
+ const id = this.addresses.get(param)?.[element];
104
+ if (id === undefined) return false;
105
+ const entry = this.entries[id];
106
+ const target = entry.f32 ? Math.fround(Number(value)) : Number(value);
107
+ if (this.samples === 0 || !initialized || !this.started || !Number.isFinite(target) || !Number.isFinite(entry.current)) {
108
+ entry.current = target;
109
+ entry.target = target;
110
+ entry.elapsed = this.samples;
111
+ return false;
112
+ }
113
+ if (target === entry.target) return true;
114
+ entry.start = entry.current;
115
+ entry.target = target;
116
+ entry.elapsed = target === entry.current ? this.samples : 0;
117
+ if (!entry.active) {
118
+ entry.active = true;
119
+ this.active[this.activeCount++] = id;
120
+ }
121
+ return true;
122
+ }
123
+
124
+ beginBlock(frames, view, paramsPtr) {
125
+ this.started = true;
126
+ let cursor = 0;
127
+ while (cursor < this.activeCount) {
128
+ const entry = this.entries[this.active[cursor]];
129
+ entry.elapsed += Math.min(frames, this.samples - entry.elapsed);
130
+ let value = entry.target;
131
+ if (entry.elapsed !== this.samples) {
132
+ const weight = entry.elapsed * this.inverseSamples;
133
+ if ((entry.start < 0) !== (entry.target < 0)) {
134
+ value = entry.start * (1 - weight) + entry.target * weight;
135
+ } else if (weight <= 0.5) {
136
+ value = entry.start + (entry.target - entry.start) * weight;
137
+ } else {
138
+ value = entry.target + (entry.start - entry.target) * (1 - weight);
139
+ }
140
+ }
141
+ this.write(entry, value, view, paramsPtr);
142
+ if (entry.elapsed === this.samples) {
143
+ entry.active = false;
144
+ this.active[cursor] = this.active[--this.activeCount];
145
+ } else {
146
+ cursor += 1;
147
+ }
148
+ }
149
+ }
150
+
151
+ settle(view, paramsPtr) {
152
+ if (this.samples === 0) this.readTargets(view, paramsPtr);
153
+ this.activeCount = 0;
154
+ this.started = false;
155
+ for (const entry of this.entries) {
156
+ this.write(entry, entry.target, view, paramsPtr);
157
+ entry.start = entry.target;
158
+ entry.elapsed = this.samples;
159
+ entry.active = false;
160
+ }
161
+ }
162
+
163
+ reset(view, paramsPtr) {
164
+ this.readTargets(view, paramsPtr);
165
+ this.settle(view, paramsPtr);
166
+ }
167
+
168
+ write(entry, value, view, paramsPtr) {
169
+ entry.current = entry.f32 ? Math.fround(value) : value;
170
+ if (entry.f32) view.setFloat32(paramsPtr + entry.offset, entry.current, true);
171
+ else view.setFloat64(paramsPtr + entry.offset, entry.current, true);
172
+ }
173
+ }
package/src/worklet.js CHANGED
@@ -1,4 +1,4 @@
1
- import { paramAddress, paramElementAddress } from "./param-metadata.js";
1
+ import { paramAddress, paramElementAddress, paramSmoothingSamples, ParamSmoothing } from "./param-metadata.js";
2
2
  import {
3
3
  EXECUTION_OPERATION_EVENT,
4
4
  EXECUTION_OPERATION_INIT,
@@ -144,7 +144,7 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
144
144
  "the Onda Web Audio processor requires at least one audio input or output",
145
145
  );
146
146
  }
147
- this.blockSize = Number(metadata.compile?.block_size ?? 128);
147
+ this.blockSize = Number(metadata.compile?.block_size ?? 512);
148
148
  this.compileSampleRate = Number(metadata.compile?.sample_rate ?? sampleRate);
149
149
  if (!Number.isInteger(this.blockSize) || this.blockSize <= 0) {
150
150
  throw new Error(`invalid compile-time block size: ${this.blockSize}`);
@@ -292,6 +292,12 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
292
292
  };
293
293
  this.writeParamDefaults();
294
294
  this.writeInitialParams(processorOptions.params ?? {});
295
+ this.paramSmoothing = new ParamSmoothing(
296
+ this.paramInfo,
297
+ paramSmoothingSamples(processorOptions.paramSmoothingSeconds ?? 0, this.compileSampleRate, this.blockSize),
298
+ this.memoryView(),
299
+ this.paramsPtr,
300
+ );
295
301
  this.ensureInputCapacity(this.blockSize);
296
302
  this.ensureOutputCapacity(this.blockSize);
297
303
  this.viewsReady = true;
@@ -517,14 +523,26 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
517
523
  throw new Error(`Onda parameter '${param.name}' has invalid storage metadata`);
518
524
  }
519
525
  if (element !== null) {
520
- this.writeScalar(this.paramsPtr + offset + element * this.scalarByteSize(param.scalar), param.scalar, value, this.memoryView());
526
+ this.setParamScalar(param, element, value);
521
527
  return;
522
528
  }
523
- this.writeStorage(
524
- this.paramsPtr + offset,
525
- param,
526
- value,
527
- );
529
+ if (!this.paramSmoothing?.isEnabledFor(param)) {
530
+ this.writeStorage(this.paramsPtr + offset, param, value);
531
+ return;
532
+ }
533
+ this.validateStorageValue(param, value);
534
+ if (this.isFixedArray(param)) {
535
+ for (let index = 0; index < length; index += 1) {
536
+ this.setParamScalar(param, index, value[index]);
537
+ }
538
+ } else {
539
+ this.setParamScalar(param, 0, value);
540
+ }
541
+ }
542
+
543
+ setParamScalar(param, element, value) {
544
+ if (this.paramSmoothing?.setTarget(param, element, value, this.initialized)) return;
545
+ this.writeScalar(this.paramsPtr + Number(param.byte_offset) + element * this.scalarByteSize(param.scalar), param.scalar, value);
528
546
  }
529
547
 
530
548
  init(mode) {
@@ -560,6 +578,7 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
560
578
  this.invalidateState();
561
579
  this.refreshMemoryCache();
562
580
  if (collectOutput) this.prepareExecutionOutput();
581
+ this.paramSmoothing.settle(this.memoryView(), this.paramsPtr);
563
582
  const status = this.exports.onda_processor_init(
564
583
  this.paramsPtr,
565
584
  this.statePtr,
@@ -729,6 +748,14 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
729
748
  message.element ?? null,
730
749
  );
731
750
  this.postResponse(message, { type: "onda-ok", operation: message.type });
751
+ } else if (message.type === "set-param-smoothing") {
752
+ const samples = paramSmoothingSamples(message.seconds, this.compileSampleRate, this.blockSize);
753
+ this.paramSmoothing.setDuration(samples, this.memoryView(), this.paramsPtr);
754
+ this.postResponse(message, { type: "onda-ok", operation: message.type });
755
+ } else if (message.type === "reset-params") {
756
+ this.writeParamDefaults();
757
+ this.paramSmoothing.reset(this.memoryView(), this.paramsPtr);
758
+ this.postResponse(message, { type: "onda-ok", operation: message.type });
732
759
  } else if (message.type === "init") {
733
760
  this.init(message.mode);
734
761
  this.postResponse(message, { type: "onda-ok", operation: message.type });
@@ -1587,6 +1614,10 @@ class OndaWasmProcessor extends AudioWorkletProcessor {
1587
1614
  const flags = (startFrame === 0 ? PROCESSOR_BEGIN_BLOCK : 0)
1588
1615
  | (endsBlock ? PROCESSOR_END_BLOCK : 0);
1589
1616
 
1617
+ if (startFrame === 0) {
1618
+ this.paramSmoothing.beginBlock(this.blockSize, this.dataViewCache, this.paramsPtr);
1619
+ }
1620
+
1590
1621
  this.marshalInputSegment(
1591
1622
  inputs,
1592
1623
  frames,