@onda-lang/webaudio 0.7.5 → 0.8.1

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/src/index.js CHANGED
@@ -1,8 +1,20 @@
1
1
  import {
2
2
  createParamControl,
3
+ decodeDelegateRecords,
4
+ formatPrintRecords,
3
5
  validateProcessorArtifact,
4
6
  validateProcessorModule,
5
7
  } from "@onda-lang/processor-abi";
8
+ import {
9
+ EXECUTION_OPERATION_EVENT,
10
+ EXECUTION_OPERATION_INIT,
11
+ EXECUTION_OPERATION_PROCESS,
12
+ EXECUTION_OPERATION_TRANSPORT,
13
+ EXECUTION_OUTPUT_RING_WAKE_INDEX,
14
+ createExecutionOutputRing,
15
+ drainExecutionOutputRing,
16
+ openExecutionOutputRing,
17
+ } from "./execution-output-ring.js";
6
18
 
7
19
  export {
8
20
  createParamDomain,
@@ -13,8 +25,12 @@ export {
13
25
  } from "@onda-lang/processor-abi";
14
26
 
15
27
  export const ONDA_AUDIO_WORKLET_PROCESSOR_NAME = "onda-wasm-processor";
28
+ export const ONDA_INIT_PRESERVE_PINNED = 0;
29
+ export const ONDA_INIT_FULL = 1;
16
30
 
17
31
  const registrationByContext = new WeakMap();
32
+ const DEFAULT_DELEGATE_CAPACITY_BYTES = 64 * 1024;
33
+ const DEFAULT_PRINT_CAPACITY_BYTES = 64 * 1024;
18
34
 
19
35
  export function flattenedAudioChannelCount(ports = []) {
20
36
  if (!Array.isArray(ports)) {
@@ -48,6 +64,9 @@ function audioWorkletNodeOptionsFromValidated(
48
64
  options,
49
65
  validateCompiledModule,
50
66
  ) {
67
+ if (options.onPrint !== undefined && typeof options.onPrint !== "function") {
68
+ throw new TypeError("initial print listener must be a function");
69
+ }
51
70
  const inputChannels = flattenedAudioChannelCount(metadata.metadata.inputs);
52
71
  const outputChannels = flattenedAudioChannelCount(metadata.metadata.outputs);
53
72
  if (inputChannels > 32 || outputChannels > 32) {
@@ -61,6 +80,27 @@ function audioWorkletNodeOptionsFromValidated(
61
80
  if (validateCompiledModule) {
62
81
  validateProcessorModule(options.compiledModule, metadata);
63
82
  }
83
+ const delegateCapacityBytes = configuredExecutionOutputCapacity(
84
+ metadata.metadata?.delegates,
85
+ options.delegateCapacityBytes,
86
+ DEFAULT_DELEGATE_CAPACITY_BYTES,
87
+ "delegate",
88
+ );
89
+ const printCapacityBytes = configuredExecutionOutputCapacity(
90
+ metadata.metadata?.log_sites,
91
+ options.printCapacityBytes,
92
+ DEFAULT_PRINT_CAPACITY_BYTES,
93
+ "print",
94
+ );
95
+ const executionOutputRing = createExecutionOutputRing(
96
+ delegateCapacityBytes,
97
+ printCapacityBytes,
98
+ );
99
+ if (options.onPrint !== undefined && executionOutputRing === null) {
100
+ throw new Error(
101
+ "print delivery requires SharedArrayBuffer; enable cross-origin isolation",
102
+ );
103
+ }
64
104
  const nodeOptions = {
65
105
  ...options.nodeOptions,
66
106
  numberOfInputs: inputChannels ? 1 : 0,
@@ -80,6 +120,12 @@ function audioWorkletNodeOptionsFromValidated(
80
120
  ),
81
121
  buffers: options.buffers ?? {},
82
122
  eventPayloadCapacityBytes: options.eventPayloadCapacityBytes,
123
+ delegateCapacityBytes,
124
+ printCapacityBytes,
125
+ executionOutputRing,
126
+ printCollectionEnabled: options.onPrint !== undefined,
127
+ printSubscriptionId: options.onPrint === undefined ? 0 : 1,
128
+ initialize: options.initialize === true,
83
129
  },
84
130
  };
85
131
  if (outputChannels) {
@@ -90,6 +136,22 @@ function audioWorkletNodeOptionsFromValidated(
90
136
  return nodeOptions;
91
137
  }
92
138
 
139
+ function configuredExecutionOutputCapacity(
140
+ descriptors,
141
+ configured,
142
+ defaultCapacity,
143
+ name,
144
+ ) {
145
+ const capacity = configured
146
+ ?? (Array.isArray(descriptors) && descriptors.length ? defaultCapacity : 0);
147
+ if (!Number.isSafeInteger(capacity) || capacity < 0 || capacity > 0x7fff_ffff) {
148
+ throw new Error(
149
+ `${name} capacity must be an integer from 0 through 2147483647 bytes`,
150
+ );
151
+ }
152
+ return capacity;
153
+ }
154
+
93
155
  function paramInfoFor(paramInfo, selector) {
94
156
  const info = Number.isInteger(selector)
95
157
  ? paramInfo[selector]
@@ -155,6 +217,18 @@ export async function registerOndaAudioWorklet(
155
217
  }
156
218
 
157
219
  export async function createOndaAudioProcessor(context, artifact, options = {}) {
220
+ return createOndaAudioProcessorImpl(context, artifact, options, false);
221
+ }
222
+
223
+ export async function createOndaAudioProcessorInitialized(
224
+ context,
225
+ artifact,
226
+ options = {},
227
+ ) {
228
+ return createOndaAudioProcessorImpl(context, artifact, options, true);
229
+ }
230
+
231
+ async function createOndaAudioProcessorImpl(context, artifact, options, initialize) {
158
232
  const validated = validateExecutableArtifact(artifact, false);
159
233
  validateContextSampleRate(context, validated.metadata);
160
234
  if (options.compiledModule !== undefined) {
@@ -170,16 +244,22 @@ export async function createOndaAudioProcessor(context, artifact, options = {})
170
244
  if (typeof NodeConstructor !== "function") {
171
245
  throw new Error("AudioWorkletNode is not available in this environment");
172
246
  }
247
+ const nodeOptions = audioWorkletNodeOptionsFromValidated(
248
+ validated,
249
+ { ...options, compiledModule, initialize },
250
+ false,
251
+ );
173
252
  const node = new NodeConstructor(
174
253
  context,
175
254
  ONDA_AUDIO_WORKLET_PROCESSOR_NAME,
176
- audioWorkletNodeOptionsFromValidated(
177
- validated,
178
- { ...options, compiledModule },
179
- false,
180
- ),
255
+ nodeOptions,
256
+ );
257
+ return new OndaAudioProcessor(
258
+ node,
259
+ validated.metadata,
260
+ options.onPrint,
261
+ nodeOptions.processorOptions.executionOutputRing,
181
262
  );
182
- return new OndaAudioProcessor(node, validated.metadata);
183
263
  }
184
264
 
185
265
  export async function compileOndaProcessorModule(artifact) {
@@ -195,13 +275,43 @@ async function compileValidatedProcessorModule({ wasm, metadata }) {
195
275
  }
196
276
 
197
277
  export class OndaAudioProcessor {
198
- constructor(node, metadata = null) {
278
+ constructor(
279
+ node,
280
+ metadata = null,
281
+ initialPrintListener = undefined,
282
+ executionOutputRing = null,
283
+ ) {
284
+ if (
285
+ initialPrintListener !== undefined
286
+ && typeof initialPrintListener !== "function"
287
+ ) {
288
+ throw new TypeError("initial print listener must be a function");
289
+ }
199
290
  this.node = node;
200
291
  this.metadata = metadata;
201
292
  this.paramInfo = metadata?.metadata?.params ?? null;
202
293
  this.paramControls = new WeakMap();
203
294
  this.nextRequestId = 1;
204
295
  this.pending = new Map();
296
+ this.delegateListeners = new Set();
297
+ this.delegateSubscriptionId = 0;
298
+ this.printListeners = new Set(
299
+ initialPrintListener === undefined ? [] : [initialPrintListener],
300
+ );
301
+ this.printSubscriptionId = initialPrintListener === undefined ? 0 : 1;
302
+ this.executionOutputRing = executionOutputRing === null
303
+ ? null
304
+ : openExecutionOutputRing(executionOutputRing);
305
+ if (initialPrintListener !== undefined && !this.executionOutputRing) {
306
+ throw new Error(
307
+ "print delivery requires SharedArrayBuffer; enable cross-origin isolation",
308
+ );
309
+ }
310
+ this.executionOutputWaitGeneration = 0;
311
+ this.executionOutputPoll = null;
312
+ this.executionOutputDrainActive = false;
313
+ this.closed = false;
314
+ this.closeReason = null;
205
315
  this.handleMessage = (event) => {
206
316
  const message = event.data ?? {};
207
317
  if (message.requestId === undefined) return;
@@ -216,9 +326,243 @@ export class OndaAudioProcessor {
216
326
  };
217
327
  node.port.addEventListener("message", this.handleMessage);
218
328
  node.port.start?.();
329
+ if (this.printListeners.size !== 0) this.startExecutionOutputDrain();
330
+ }
331
+
332
+ startExecutionOutputDrain() {
333
+ if (!this.executionOutputRing || this.executionOutputDrainActive) return;
334
+ this.executionOutputDrainActive = true;
335
+ this.drainExecutionOutput();
336
+ if (typeof Atomics.waitAsync !== "function") {
337
+ this.executionOutputPoll = setInterval(() => {
338
+ if (!this.closed) this.drainExecutionOutput();
339
+ }, 16);
340
+ return;
341
+ }
342
+ const generation = ++this.executionOutputWaitGeneration;
343
+ const wait = async () => {
344
+ while (!this.closed && generation === this.executionOutputWaitGeneration) {
345
+ const expected = Atomics.load(
346
+ this.executionOutputRing.control,
347
+ EXECUTION_OUTPUT_RING_WAKE_INDEX,
348
+ );
349
+ this.drainExecutionOutput();
350
+ if (this.closed || generation !== this.executionOutputWaitGeneration) return;
351
+ const result = Atomics.waitAsync(
352
+ this.executionOutputRing.control,
353
+ EXECUTION_OUTPUT_RING_WAKE_INDEX,
354
+ expected,
355
+ );
356
+ if (result.async) await result.value;
357
+ }
358
+ };
359
+ void wait().catch((error) => this.reportExecutionOutputError(error));
360
+ }
361
+
362
+ stopExecutionOutputDrain() {
363
+ if (!this.executionOutputDrainActive) return;
364
+ this.executionOutputDrainActive = false;
365
+ this.executionOutputWaitGeneration += 1;
366
+ if (this.executionOutputPoll !== null) {
367
+ clearInterval(this.executionOutputPoll);
368
+ this.executionOutputPoll = null;
369
+ }
370
+ if (!this.executionOutputRing) return;
371
+ Atomics.add(
372
+ this.executionOutputRing.control,
373
+ EXECUTION_OUTPUT_RING_WAKE_INDEX,
374
+ 1,
375
+ );
376
+ Atomics.notify(
377
+ this.executionOutputRing.control,
378
+ EXECUTION_OUTPUT_RING_WAKE_INDEX,
379
+ );
380
+ }
381
+
382
+ reportExecutionOutputError(error) {
383
+ queueMicrotask(() => {
384
+ throw error;
385
+ });
386
+ }
387
+
388
+ notifyExecutionOutputListeners(listeners, batch) {
389
+ for (const listener of listeners) {
390
+ try {
391
+ listener(batch);
392
+ } catch (error) {
393
+ this.reportExecutionOutputError(error);
394
+ }
395
+ }
396
+ }
397
+
398
+ drainExecutionOutput() {
399
+ if (!this.executionOutputRing) return 0;
400
+ return drainExecutionOutputRing(
401
+ this.executionOutputRing,
402
+ (entry) => {
403
+ try {
404
+ this.consumeExecutionOutput(entry);
405
+ } catch (error) {
406
+ this.reportExecutionOutputError(error);
407
+ }
408
+ },
409
+ );
410
+ }
411
+
412
+ consumeExecutionOutput(entry) {
413
+ const operation = this.executionOperationName(
414
+ entry.operation,
415
+ entry.operationIndex,
416
+ );
417
+ const records = [];
418
+ let print = null;
419
+ let delegate = null;
420
+ if (
421
+ entry.printSubscriptionId === this.printSubscriptionId
422
+ && this.printListeners.size !== 0
423
+ && (
424
+ entry.printUsed
425
+ || entry.printRecordCount
426
+ || entry.printOverflowCount
427
+ || entry.printTransportDropCount
428
+ )
429
+ ) {
430
+ const formatted = formatPrintRecords(
431
+ entry.printStorage,
432
+ entry.printUsed,
433
+ this.metadata,
434
+ entry.printOverflowCount,
435
+ );
436
+ if (formatted.entries.length !== entry.printRecordCount) {
437
+ throw new Error("processor print count does not match packed storage");
438
+ }
439
+ const lines = formatted.text.match(/.*\n/g) ?? [];
440
+ if (lines.length !== formatted.entries.length) {
441
+ throw new Error("formatted print output does not match its decoded entries");
442
+ }
443
+ print = {
444
+ overflowCount: formatted.overflowCount,
445
+ transportDropCount: entry.printTransportDropCount,
446
+ };
447
+ formatted.entries.forEach((decoded, index) => records.push({
448
+ kind: "print",
449
+ sequence: decoded.sequence,
450
+ entry: decoded,
451
+ line: lines[index],
452
+ }));
453
+ }
454
+ if (
455
+ entry.delegateSubscriptionId === this.delegateSubscriptionId
456
+ && this.delegateListeners.size !== 0
457
+ && (
458
+ entry.delegateUsed
459
+ || entry.delegateRecordCount
460
+ || entry.delegateOverflowCount
461
+ || entry.delegateTransportDropCount
462
+ )
463
+ ) {
464
+ const decodedRecords = decodeDelegateRecords(
465
+ entry.delegateStorage,
466
+ entry.delegateUsed,
467
+ this.metadata?.metadata?.delegates,
468
+ this.metadata?.target?.byte_order,
469
+ );
470
+ if (decodedRecords.length !== entry.delegateRecordCount) {
471
+ throw new Error("processor delegate count does not match packed storage");
472
+ }
473
+ delegate = {
474
+ overflowCount: entry.delegateOverflowCount,
475
+ transportDropCount: entry.delegateTransportDropCount,
476
+ };
477
+ decodedRecords.forEach((record) => {
478
+ const occurrence = {
479
+ sequence: record.sequence,
480
+ index: record.delegateIndex,
481
+ name: record.name,
482
+ values: record.values,
483
+ };
484
+ records.push({
485
+ kind: "delegate",
486
+ sequence: occurrence.sequence,
487
+ occurrence,
488
+ });
489
+ });
490
+ }
491
+ this.deliverExecutionOutput(records, print, delegate, operation);
492
+ }
493
+
494
+ executionOperationName(operation, operationIndex) {
495
+ if (operation === EXECUTION_OPERATION_INIT) return "processor init";
496
+ if (operation === EXECUTION_OPERATION_PROCESS) return "process";
497
+ if (operation === EXECUTION_OPERATION_TRANSPORT) return "transport";
498
+ if (operation === EXECUTION_OPERATION_EVENT) {
499
+ const event = this.metadata?.metadata?.events?.[operationIndex];
500
+ if (!event) throw new Error("execution-output ring references an unknown event");
501
+ return `event '${event.name}'`;
502
+ }
503
+ throw new Error("execution-output ring contains an unknown operation");
504
+ }
505
+
506
+ deliverExecutionOutput(records, print, delegate, operation) {
507
+ records.sort((lhs, rhs) => lhs.sequence - rhs.sequence);
508
+ let cursor = 0;
509
+ while (cursor < records.length) {
510
+ const kind = records[cursor].kind;
511
+ let end = cursor + 1;
512
+ while (end < records.length && records[end].kind === kind) end += 1;
513
+ const batchRecords = records.slice(cursor, end);
514
+ if (kind === "print") {
515
+ const meta = print ?? { overflowCount: 0, transportDropCount: 0 };
516
+ print = null;
517
+ const batch = {
518
+ type: "onda-print",
519
+ operation,
520
+ text: batchRecords.map((record) => record.line).join(""),
521
+ entries: batchRecords.map((record) => record.entry),
522
+ ...meta,
523
+ };
524
+ this.notifyExecutionOutputListeners(this.printListeners, batch);
525
+ } else {
526
+ const meta = delegate ?? { overflowCount: 0, transportDropCount: 0 };
527
+ delegate = null;
528
+ const batch = {
529
+ type: "onda-delegates",
530
+ operation,
531
+ occurrences: batchRecords.map((record) => record.occurrence),
532
+ ...meta,
533
+ };
534
+ this.notifyExecutionOutputListeners(this.delegateListeners, batch);
535
+ }
536
+ cursor = end;
537
+ }
538
+ if (print && (print.overflowCount || print.transportDropCount)) {
539
+ const batch = {
540
+ type: "onda-print",
541
+ operation,
542
+ text: "",
543
+ entries: [],
544
+ ...print,
545
+ };
546
+ this.notifyExecutionOutputListeners(this.printListeners, batch);
547
+ }
548
+ if (
549
+ delegate
550
+ && (delegate.overflowCount || delegate.transportDropCount)
551
+ ) {
552
+ const batch = {
553
+ type: "onda-delegates",
554
+ operation,
555
+ occurrences: [],
556
+ ...delegate,
557
+ };
558
+ this.notifyExecutionOutputListeners(this.delegateListeners, batch);
559
+ }
219
560
  }
220
561
 
221
562
  request(type, fields = {}, transfer = []) {
563
+ if (this.closed) {
564
+ return Promise.reject(this.closeReason);
565
+ }
222
566
  const requestId = this.nextRequestId++;
223
567
  return new Promise((resolve, reject) => {
224
568
  this.pending.set(requestId, { resolve, reject });
@@ -271,8 +615,87 @@ export class OndaAudioProcessor {
271
615
  return this.request("event", { event, values });
272
616
  }
273
617
 
274
- reset() {
275
- return this.request("reset");
618
+ onDelegates(listener) {
619
+ this.assertOpen();
620
+ if (typeof listener !== "function") {
621
+ throw new TypeError("delegate listener must be a function");
622
+ }
623
+ this.requireExecutionOutputRing("delegate");
624
+ const wasEmpty = this.delegateListeners.size === 0;
625
+ this.delegateListeners.add(listener);
626
+ if (wasEmpty) {
627
+ this.delegateSubscriptionId = (this.delegateSubscriptionId + 1) >>> 0;
628
+ if (this.delegateSubscriptionId === 0) this.delegateSubscriptionId = 1;
629
+ try {
630
+ this.startExecutionOutputDrain();
631
+ this.node.port.postMessage({
632
+ type: "delegate-subscription",
633
+ enabled: true,
634
+ subscriptionId: this.delegateSubscriptionId,
635
+ });
636
+ } catch (error) {
637
+ this.delegateListeners.delete(listener);
638
+ if (this.printListeners.size === 0) this.stopExecutionOutputDrain();
639
+ throw error;
640
+ }
641
+ }
642
+ return () => {
643
+ const removed = this.delegateListeners.delete(listener);
644
+ if (removed && this.delegateListeners.size === 0) {
645
+ this.node.port.postMessage({
646
+ type: "delegate-subscription",
647
+ enabled: false,
648
+ subscriptionId: this.delegateSubscriptionId,
649
+ });
650
+ if (this.printListeners.size === 0) this.stopExecutionOutputDrain();
651
+ }
652
+ return removed;
653
+ };
654
+ }
655
+
656
+ onPrint(listener) {
657
+ this.assertOpen();
658
+ if (typeof listener !== "function") {
659
+ throw new TypeError("print listener must be a function");
660
+ }
661
+ this.requireExecutionOutputRing("print");
662
+ const wasEmpty = this.printListeners.size === 0;
663
+ this.printListeners.add(listener);
664
+ if (wasEmpty) {
665
+ this.printSubscriptionId = (this.printSubscriptionId + 1) >>> 0;
666
+ if (this.printSubscriptionId === 0) this.printSubscriptionId = 1;
667
+ try {
668
+ this.startExecutionOutputDrain();
669
+ this.node.port.postMessage({
670
+ type: "print-subscription",
671
+ enabled: true,
672
+ subscriptionId: this.printSubscriptionId,
673
+ });
674
+ } catch (error) {
675
+ this.printListeners.delete(listener);
676
+ if (this.delegateListeners.size === 0) this.stopExecutionOutputDrain();
677
+ throw error;
678
+ }
679
+ }
680
+ return () => {
681
+ const removed = this.printListeners.delete(listener);
682
+ if (removed && this.printListeners.size === 0) {
683
+ this.node.port.postMessage({
684
+ type: "print-subscription",
685
+ enabled: false,
686
+ subscriptionId: this.printSubscriptionId,
687
+ });
688
+ if (this.delegateListeners.size === 0) this.stopExecutionOutputDrain();
689
+ }
690
+ return removed;
691
+ };
692
+ }
693
+
694
+ init(mode) {
695
+ if (mode !== ONDA_INIT_PRESERVE_PINNED && mode !== ONDA_INIT_FULL) {
696
+ return Promise.reject(new Error(`invalid Onda init mode '${String(mode)}'`));
697
+ }
698
+ return this.request("init", { mode });
276
699
  }
277
700
 
278
701
  async snapshot() {
@@ -295,9 +718,48 @@ export class OndaAudioProcessor {
295
718
  }
296
719
 
297
720
  close(reason = new Error("Onda AudioWorklet processor closed")) {
721
+ if (this.closed) return;
722
+ this.closed = true;
723
+ this.closeReason = reason instanceof Error ? reason : new Error(String(reason));
724
+ if (this.delegateListeners.size !== 0) {
725
+ try {
726
+ this.node.port.postMessage({
727
+ type: "delegate-subscription",
728
+ enabled: false,
729
+ subscriptionId: this.delegateSubscriptionId,
730
+ });
731
+ } catch {
732
+ // The underlying node may already be gone; local closure still proceeds.
733
+ }
734
+ }
735
+ if (this.printListeners.size !== 0) {
736
+ try {
737
+ this.node.port.postMessage({
738
+ type: "print-subscription",
739
+ enabled: false,
740
+ subscriptionId: this.printSubscriptionId,
741
+ });
742
+ } catch {
743
+ // The underlying node may already be gone; local closure still proceeds.
744
+ }
745
+ }
298
746
  this.node.port.removeEventListener("message", this.handleMessage);
299
- for (const pending of this.pending.values()) pending.reject(reason);
747
+ this.stopExecutionOutputDrain();
748
+ for (const pending of this.pending.values()) pending.reject(this.closeReason);
300
749
  this.pending.clear();
750
+ this.delegateListeners.clear();
751
+ this.printListeners.clear();
752
+ }
753
+
754
+ assertOpen() {
755
+ if (this.closed) throw this.closeReason;
756
+ }
757
+
758
+ requireExecutionOutputRing(kind) {
759
+ if (this.executionOutputRing) return;
760
+ throw new Error(
761
+ `${kind} delivery requires SharedArrayBuffer; enable cross-origin isolation`,
762
+ );
301
763
  }
302
764
  }
303
765