@libraz/libsonare 1.4.1 → 1.5.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.
Files changed (66) hide show
  1. package/README.md +51 -20
  2. package/dist/index.d.ts +5416 -1
  3. package/dist/index.js +938 -583
  4. package/dist/index.js.map +1 -1
  5. package/dist/sonare.js +2 -2
  6. package/dist/sonare.wasm +0 -0
  7. package/dist/worklet.d.ts +1083 -5227
  8. package/dist/worklet.js +2683 -2451
  9. package/dist/worklet.js.map +1 -1
  10. package/package.json +4 -9
  11. package/src/clip_page_streamer.ts +298 -0
  12. package/src/effects_mastering.ts +85 -1089
  13. package/src/effects_transform.ts +286 -0
  14. package/src/effects_voice_change.ts +118 -0
  15. package/src/feature_music.ts +5 -2
  16. package/src/feature_spectrogram.ts +42 -2
  17. package/src/features.ts +1 -0
  18. package/src/index.ts +23 -0
  19. package/src/mastering_chain.ts +200 -0
  20. package/src/mastering_core.ts +248 -0
  21. package/src/mastering_dynamics.ts +105 -0
  22. package/src/mastering_repair.ts +161 -0
  23. package/src/mixer.ts +8 -0
  24. package/src/mixing_oneshot.ts +54 -0
  25. package/src/module_state.ts +1 -2
  26. package/src/project.ts +71 -1712
  27. package/src/project_class.ts +871 -0
  28. package/src/project_internal.ts +333 -0
  29. package/src/project_synth.ts +43 -0
  30. package/src/project_types.ts +570 -0
  31. package/src/public_types.ts +6 -1221
  32. package/src/public_types_acoustic.ts +115 -0
  33. package/src/public_types_mastering.ts +333 -0
  34. package/src/public_types_mixing.ts +97 -0
  35. package/src/public_types_music.ts +352 -0
  36. package/src/public_types_realtime.ts +163 -0
  37. package/src/public_types_spectral.ts +194 -0
  38. package/src/realtime_engine.ts +94 -0
  39. package/src/sonare.js.d.ts +117 -38
  40. package/src/stream_analyzer.ts +3 -0
  41. package/src/stream_types.ts +4 -0
  42. package/src/worklet/engine-automation.ts +73 -0
  43. package/src/worklet/engine-capture-facade.ts +80 -0
  44. package/src/worklet/engine-clips.ts +71 -0
  45. package/src/worklet/engine-markers.ts +93 -0
  46. package/src/worklet/engine-mixer-facade.ts +186 -0
  47. package/src/worklet/engine-node.ts +451 -0
  48. package/src/worklet/engine-offline.ts +162 -0
  49. package/src/worklet/engine-options.ts +13 -0
  50. package/src/worklet/engine-parameter-facade.ts +172 -0
  51. package/src/worklet/engine-processor.ts +764 -0
  52. package/src/worklet/engine-register.ts +136 -0
  53. package/src/worklet/engine-strips.ts +315 -0
  54. package/src/worklet/engine-sync.ts +94 -0
  55. package/src/worklet/engine-tempo-facade.ts +141 -0
  56. package/src/worklet/engine.ts +998 -0
  57. package/src/worklet/guards.ts +14 -1
  58. package/src/worklet/messages.ts +60 -20
  59. package/src/worklet/mixer-processor.ts +368 -0
  60. package/src/worklet/protocol.ts +3 -0
  61. package/src/worklet/voice-changer-processor.ts +246 -0
  62. package/src/worklet.ts +20 -3549
  63. package/dist/sonare-rt-module.js +0 -2
  64. package/dist/sonare-rt.js +0 -2
  65. package/dist/sonare-rt.wasm +0 -0
  66. package/src/sonare-rt.d.ts +0 -93
package/src/worklet.ts CHANGED
@@ -1,129 +1,19 @@
1
- import { panLawCode, panModeCode } from './codes';
2
- import type {
3
- EngineAutomationPoint,
4
- EngineBus,
5
- EngineCaptureStatus,
6
- EngineClip,
7
- EngineMarker,
8
- EngineMetronomeConfig,
9
- EngineMidiClipSchedule,
10
- EngineParameterInfo,
11
- EngineScopeTelemetry,
12
- EngineTempoSegment,
13
- EngineTimeSignatureSegment,
14
- EngineTrackLane,
15
- EngineTrackSend,
16
- EngineTransportState,
17
- EqBand,
18
- MixerRealtimeBuffer,
19
- PanLaw,
20
- PanMode,
21
- } from './index';
22
- import {
23
- engineCapabilities,
24
- init as initSonareModule,
25
- isInitialized,
26
- Mixer,
27
- RealtimeEngine,
28
- RealtimeVoiceChanger,
29
- } from './index';
30
- import type { SonareModule } from './sonare.js';
31
- import type { SonareRtModule } from './sonare-rt';
1
+ // Public entry for the AudioWorklet surface. The implementation is split into
2
+ // focused modules under ./worklet/*; tsup bundles them back into a single
3
+ // self-contained dist/worklet.js (code-splitting is disabled because a real
4
+ // AudioWorkletGlobalScope cannot resolve sibling chunks), so the public surface
5
+ // is unchanged.
32
6
 
33
7
  // With code-splitting disabled, the worklet bundle carries its own copy of the
34
- // module singleton (a real AudioWorkletGlobalScope cannot resolve sibling
35
- // chunks, so the bundle must be self-contained). Re-export the lifecycle so that
36
- // realm can initialize its own wasm instance, independent of the main-thread
37
- // `index` module.
8
+ // module singleton. Re-export the lifecycle so that realm can initialize its
9
+ // own wasm instance, independent of the main-thread `index` module.
38
10
  export { init, isInitialized } from './index';
11
+ export { SonareEngine } from './worklet/engine';
12
+ export { SonareRealtimeEngineNode } from './worklet/engine-node';
39
13
 
40
- import type { WorkletInput, WorkletOutput } from './worklet/audio_types';
41
- import {
42
- isEngineCaptureRequestMessage,
43
- isEngineCaptureResponseMessage,
44
- isEngineCommandRecord,
45
- isEngineSyncMessage,
46
- isEngineTelemetryRecord,
47
- isEngineTransportRequestMessage,
48
- isEngineTransportResponseMessage,
49
- isMeterSnapshot,
50
- isRealtimeVoiceChangerMessage,
51
- isWorkletMessage,
52
- } from './worklet/guards';
53
- import {
54
- DEFAULT_METRONOME_CONFIG,
55
- type ResolvedMetronomeConfig,
56
- resolveMetronomeConfig,
57
- type SonareEngineCaptureRequestMessage,
58
- type SonareEngineCaptureResponseMessage,
59
- type SonareEngineInstrumentSyncMessage,
60
- type SonareEngineSyncCaptureMessage,
61
- type SonareEngineSyncMessage,
62
- type SonareEngineTransportFacade,
63
- type SonareEngineTransportRequestMessage,
64
- type SonareEngineTransportResponseMessage,
65
- type SonareRealtimeEngineNodeCapabilities,
66
- type SonareRealtimeEngineNodeOptions,
67
- type SonareRealtimeEngineWorkletProcessorOptions,
68
- type SonareRealtimeVoiceChangerMessage,
69
- type SonareRealtimeVoiceChangerWorkletProcessorOptions,
70
- type SonareRtRealtimeEngineRuntimeOptions,
71
- type SonareWorkletMessage,
72
- type SonareWorkletProcessorOptions,
73
- type WorkletPort,
74
- type WorkletTransport,
75
- } from './worklet/messages';
76
- // --- internal modules (split out of this file; bundled back into a single
77
- // dist/worklet.js by tsup, so the public surface is unchanged) ---
78
- import {
79
- createSonareEngineCommandRingBuffer,
80
- createSonareEngineTelemetryRingBuffer,
81
- createSonareMeterRingBuffer,
82
- createSonareScopeRingBuffer,
83
- ENGINE_MIXER_PARAM_FADER_DB,
84
- ENGINE_MIXER_PARAM_PAN,
85
- encodeFrameHi,
86
- encodeFrameLo,
87
- engineMixerBusTarget,
88
- engineMixerLaneTarget,
89
- engineMixerMasterTarget,
90
- engineRingFromSharedBuffer,
91
- isRecord,
92
- magnitudeToDb,
93
- meterFromEngine,
94
- meterRingFromSharedBuffer,
95
- popSonareEngineCommandRingBuffer,
96
- pushSonareEngineCommandRingBuffer,
97
- readSonareEngineTelemetryRingBuffer,
98
- readSonareMeterRingBuffer,
99
- readSonareScopeRingBuffer,
100
- type SharedMeterRingWriter,
101
- type SharedScopeRingWriter,
102
- type SharedSpectrumRingWriter,
103
- SONARE_ENGINE_COMMAND_RECORD_BYTES,
104
- SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
105
- SONARE_METER_RING_RECORD_FLOATS,
106
- SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS,
107
- type SonareEngineCommandRecord,
108
- type SonareEngineCommandRingBuffer,
109
- SonareEngineCommandType,
110
- SonareEngineTelemetryError,
111
- type SonareEngineTelemetryRecord,
112
- type SonareEngineTelemetryRingBuffer,
113
- SonareEngineTelemetryType,
114
- type SonareMeterRingBuffer,
115
- type SonareScopeRingBuffer,
116
- type SonareWorkletMeterSnapshot,
117
- type SonareWorkletScopeSnapshot,
118
- type SonareWorkletSpectrumSnapshot,
119
- scopeRingFromSharedBuffer,
120
- spectrumRingFromSharedBuffer,
121
- telemetryFromEngine,
122
- toBigInt64,
123
- toDb,
124
- writeSonareEngineTelemetryRingBuffer,
125
- } from './worklet/protocol';
126
-
14
+ export type { SonareEngineOptions } from './worklet/engine-options';
15
+ export { SonareRealtimeEngineWorkletProcessor } from './worklet/engine-processor';
16
+ export { registerSonareRealtimeEngineWorkletProcessor } from './worklet/engine-register';
127
17
  export type {
128
18
  SonareEngineCaptureRequestMessage,
129
19
  SonareEngineCaptureResponseMessage,
@@ -159,7 +49,6 @@ export type {
159
49
  SonareRealtimeVoiceChangerResetMessage,
160
50
  SonareRealtimeVoiceChangerSetConfigMessage,
161
51
  SonareRealtimeVoiceChangerWorkletProcessorOptions,
162
- SonareRtRealtimeEngineRuntimeOptions,
163
52
  SonareWorkletDestroyMessage,
164
53
  SonareWorkletMessage,
165
54
  SonareWorkletProcessorOptions,
@@ -167,6 +56,10 @@ export type {
167
56
  SonareWorkletSetMeterIntervalMessage,
168
57
  SonareWorkletTransportMessage,
169
58
  } from './worklet/messages';
59
+ export {
60
+ registerSonareWorkletProcessor,
61
+ SonareWorkletProcessor,
62
+ } from './worklet/mixer-processor';
170
63
  export {
171
64
  createSonareEngineCommandRingBuffer,
172
65
  createSonareEngineTelemetryRingBuffer,
@@ -213,3429 +106,7 @@ export {
213
106
  sonareSpectrumRingBufferByteLength,
214
107
  writeSonareEngineTelemetryRingBuffer,
215
108
  } from './worklet/protocol';
216
-
217
- export interface SonareEngineOptions extends SonareRealtimeEngineNodeOptions {
218
- offlineEngine?: RealtimeEngine;
219
- offlineBlockSize?: number;
220
- offlineChannelCount?: number;
221
- }
222
-
223
- type SuspendableAudioContext = BaseAudioContext & {
224
- suspend?: () => Promise<void>;
225
- resume?: () => Promise<void>;
226
- };
227
-
228
- /**
229
- * AudioWorklet-style mixer bridge backed by the package's single `sonare.wasm`.
230
- *
231
- * The WASM module must already be initialized via `init()` before constructing
232
- * this bridge. Each AudioWorklet input is treated as one stereo strip:
233
- * `inputs[strip][0]` is left and `inputs[strip][1]` is right. Missing channels
234
- * are replaced with preallocated silence.
235
- */
236
- export class SonareWorkletProcessor {
237
- readonly sampleRate: number;
238
- readonly blockSize: number;
239
- private mixer: Mixer;
240
- private realtime: MixerRealtimeBuffer;
241
- private closed = false;
242
- private processedFrames = 0;
243
- private lastMeterFrame = 0;
244
- private meterIntervalFrames: number;
245
- private spectrumIntervalFrames: number;
246
- private lastSpectrumFrame = 0;
247
- private transport?: WorkletTransport;
248
- private meterRing?: SharedMeterRingWriter;
249
- private spectrumRing?: SharedSpectrumRingWriter;
250
- private spectrumBands: Float32Array;
251
-
252
- constructor(options: SonareWorkletProcessorOptions, transport?: WorkletTransport) {
253
- if (!options.sceneJson) {
254
- throw new Error('sceneJson is required.');
255
- }
256
- this.sampleRate = options.sampleRate ?? 48000;
257
- this.blockSize = options.blockSize ?? 128;
258
- this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
259
- this.spectrumIntervalFrames = Math.max(0, Math.floor(options.spectrumIntervalFrames ?? 0));
260
- this.transport = transport;
261
- this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
262
- this.meterRing = options.meterSharedBuffer
263
- ? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
264
- : undefined;
265
- this.spectrumRing = options.spectrumSharedBuffer
266
- ? spectrumRingFromSharedBuffer(
267
- options.spectrumSharedBuffer,
268
- options.spectrumRingCapacity,
269
- options.spectrumBands,
270
- )
271
- : undefined;
272
- const spectrumBandCount = this.spectrumRing?.bands ?? Math.max(1, options.spectrumBands ?? 16);
273
- this.spectrumBands = new Float32Array(spectrumBandCount);
274
- this.mixer = Mixer.fromSceneJson(options.sceneJson, this.sampleRate, this.blockSize);
275
- this.mixer.compile();
276
- const sceneStripCount = this.mixer.stripCount();
277
- const stripCount = options.stripCount ?? sceneStripCount;
278
- if (stripCount !== sceneStripCount) {
279
- throw new Error('stripCount must match the scene strip count.');
280
- }
281
- this.realtime = this.mixer.createRealtimeBuffer();
282
- }
283
-
284
- process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
285
- if (this.closed) {
286
- return false;
287
- }
288
- const output = outputs[0];
289
- const leftOut = output?.[0];
290
- const rightOut = output?.[1];
291
- if (!leftOut) {
292
- return true;
293
- }
294
- const frames = leftOut.length;
295
- // The mixer's realtime heap buffers are sized to blockSize. A render quantum
296
- // that differs from blockSize (e.g. a future browser using a quantum other
297
- // than 128, or a misconfigured blockSize) must NOT return false here:
298
- // returning false permanently terminates the AudioWorkletProcessor and
299
- // silently kills the node mid-stream. Instead degrade gracefully by
300
- // processing min(frames, blockSize) and zero-filling any remainder, mirroring
301
- // the sonare-rt processor's behaviour.
302
- const usable = Math.min(frames, this.blockSize);
303
-
304
- for (let strip = 0; strip < this.realtime.leftInputs.length; strip++) {
305
- const input = inputs[strip];
306
- const left = input?.[0];
307
- const right = input?.[1];
308
- const leftTarget = this.realtime.leftInputs[strip];
309
- const rightTarget = this.realtime.rightInputs[strip];
310
- if (left && left.length >= usable) {
311
- leftTarget.set(left.subarray(0, usable));
312
- if (right && right.length >= usable) {
313
- rightTarget.set(right.subarray(0, usable));
314
- } else {
315
- rightTarget.set(left.subarray(0, usable));
316
- }
317
- } else {
318
- leftTarget.fill(0);
319
- rightTarget.fill(0);
320
- }
321
- }
322
-
323
- this.realtime.process(usable);
324
- if (usable === frames) {
325
- leftOut.set(this.realtime.outLeft.subarray(0, usable));
326
- if (rightOut) {
327
- rightOut.set(this.realtime.outRight.subarray(0, usable));
328
- }
329
- } else {
330
- // frames > blockSize: fill the produced part and zero the remaining tail.
331
- leftOut.fill(0);
332
- leftOut.set(this.realtime.outLeft.subarray(0, usable));
333
- if (rightOut) {
334
- rightOut.fill(0);
335
- rightOut.set(this.realtime.outRight.subarray(0, usable));
336
- }
337
- }
338
- this.processedFrames += usable;
339
- this.publishMeter(
340
- this.realtime.outLeft.subarray(0, usable),
341
- this.realtime.outRight.subarray(0, usable),
342
- );
343
- this.publishSpectrum(
344
- this.realtime.outLeft.subarray(0, usable),
345
- this.realtime.outRight.subarray(0, usable),
346
- );
347
- return true;
348
- }
349
-
350
- receiveMessage(message: SonareWorkletMessage): void {
351
- if (this.closed) {
352
- return;
353
- }
354
- if (message.type === 'destroy') {
355
- this.destroy();
356
- return;
357
- }
358
- if (message.type === 'setMeterInterval') {
359
- this.meterIntervalFrames = Math.max(0, Math.floor(message.frames));
360
- return;
361
- }
362
- if (message.type === 'scheduleInsertAutomation') {
363
- this.mixer.scheduleInsertAutomation(
364
- message.stripIndex,
365
- message.insertIndex,
366
- message.paramId,
367
- message.samplePos ?? this.processedFrames,
368
- message.value,
369
- message.curve ?? 'linear',
370
- );
371
- }
372
- }
373
-
374
- destroy(): void {
375
- if (!this.closed) {
376
- this.mixer.delete();
377
- this.closed = true;
378
- }
379
- }
380
-
381
- private publishMeter(left: Float32Array, right: Float32Array): void {
382
- if (!this.transport || this.meterIntervalFrames <= 0) {
383
- return;
384
- }
385
- if (this.processedFrames - this.lastMeterFrame < this.meterIntervalFrames) {
386
- return;
387
- }
388
- this.lastMeterFrame = this.processedFrames;
389
-
390
- let peakL = 0;
391
- let peakR = 0;
392
- let sumL = 0;
393
- let sumR = 0;
394
- let sumLR = 0;
395
- for (let i = 0; i < left.length; i++) {
396
- const l = left[i] ?? 0;
397
- const r = right[i] ?? 0;
398
- const absL = Math.abs(l);
399
- const absR = Math.abs(r);
400
- if (absL > peakL) {
401
- peakL = absL;
402
- }
403
- if (absR > peakR) {
404
- peakR = absR;
405
- }
406
- sumL += l * l;
407
- sumR += r * r;
408
- sumLR += l * r;
409
- }
410
- const rmsL = Math.sqrt(sumL / Math.max(1, left.length));
411
- const rmsR = Math.sqrt(sumR / Math.max(1, right.length));
412
- const denominator = Math.sqrt(sumL * sumR);
413
- const meter: SonareWorkletMeterSnapshot = {
414
- type: 'meter',
415
- targetId: 0,
416
- frame: this.processedFrames,
417
- peakDbL: toDb(peakL),
418
- peakDbR: toDb(peakR),
419
- rmsDbL: toDb(rmsL),
420
- rmsDbR: toDb(rmsR),
421
- correlation: denominator > 0 ? sumLR / denominator : 0,
422
- truePeakDbL: toDb(peakL),
423
- truePeakDbR: toDb(peakR),
424
- momentaryLufs: Number.NaN,
425
- shortTermLufs: Number.NaN,
426
- integratedLufs: Number.NaN,
427
- gainReductionDb: Number.NaN,
428
- };
429
- this.transport.onMeter?.(meter);
430
- if (this.meterRing) {
431
- this.writeMeterRing(meter);
432
- } else {
433
- this.transport.postMessage?.(meter);
434
- }
435
- }
436
-
437
- private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
438
- const ring = this.meterRing;
439
- if (!ring) {
440
- return;
441
- }
442
- const writeIndex = Atomics.load(ring.header, 0);
443
- const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
444
- ring.records[offset] = encodeFrameLo(meter.frame);
445
- ring.records[offset + 1] = encodeFrameHi(meter.frame);
446
- ring.records[offset + 2] = meter.targetId;
447
- ring.records[offset + 3] = meter.peakDbL;
448
- ring.records[offset + 4] = meter.peakDbR;
449
- ring.records[offset + 5] = meter.rmsDbL;
450
- ring.records[offset + 6] = meter.rmsDbR;
451
- ring.records[offset + 7] = meter.correlation;
452
- ring.records[offset + 8] = meter.truePeakDbL;
453
- ring.records[offset + 9] = meter.truePeakDbR;
454
- ring.records[offset + 10] = meter.momentaryLufs;
455
- ring.records[offset + 11] = meter.shortTermLufs;
456
- ring.records[offset + 12] = meter.integratedLufs;
457
- ring.records[offset + 13] = meter.gainReductionDb;
458
- Atomics.store(ring.header, 0, writeIndex + 1);
459
- // writeIndex is a free-running monotonic counter, so an overflow guard here
460
- // would fire on essentially every write past the first `capacity` records
461
- // and store an ever-growing value, not a dropped-record count. Readers
462
- // already detect silent overrun via firstReadable = max(readIndex,
463
- // writeIndex - capacity), so header slot 3 is left at its initial 0.
464
- }
465
-
466
- private publishSpectrum(left: Float32Array, right: Float32Array): void {
467
- if (this.spectrumIntervalFrames <= 0) {
468
- return;
469
- }
470
- if (this.processedFrames - this.lastSpectrumFrame < this.spectrumIntervalFrames) {
471
- return;
472
- }
473
- this.lastSpectrumFrame = this.processedFrames;
474
- this.computeSpectrum(left, right);
475
- if (this.spectrumRing) {
476
- this.writeSpectrumRing(this.processedFrames, this.spectrumBands);
477
- return;
478
- }
479
- const spectrum: SonareWorkletSpectrumSnapshot = {
480
- type: 'spectrum',
481
- frame: this.processedFrames,
482
- bands: new Float32Array(this.spectrumBands),
483
- };
484
- this.transport?.onSpectrum?.(spectrum);
485
- this.transport?.postMessage?.(spectrum);
486
- }
487
-
488
- private computeSpectrum(left: Float32Array, right: Float32Array): void {
489
- // Coarse per-render-quantum band energy, NOT a full FFT analyzer: each band
490
- // is a single-bin DFT (bin = band + 1) evaluated over the current block of n
491
- // samples. Bins at or above the block Nyquist (band + 1 > floor(n / 2))
492
- // alias, so the evaluated band count is clamped to floor(n / 2) and any
493
- // higher bands are pinned to the silence floor. Bin resolution is therefore
494
- // tied to the render quantum (typically 128 samples); treat the output as a
495
- // rough spectral tilt, not a precise spectrum.
496
- const n = Math.max(1, Math.min(left.length, right.length));
497
- const maxBand = Math.floor(n / 2);
498
- for (let band = 0; band < this.spectrumBands.length; band++) {
499
- if (band >= maxBand) {
500
- this.spectrumBands[band] = magnitudeToDb(0);
501
- continue;
502
- }
503
- const bin = band + 1;
504
- let real = 0;
505
- let imag = 0;
506
- for (let i = 0; i < n; i++) {
507
- const sample = 0.5 * ((left[i] ?? 0) + (right[i] ?? 0));
508
- const phase = (-2 * Math.PI * bin * i) / n;
509
- real += sample * Math.cos(phase);
510
- imag += sample * Math.sin(phase);
511
- }
512
- this.spectrumBands[band] = magnitudeToDb((2 * Math.hypot(real, imag)) / n);
513
- }
514
- }
515
-
516
- private writeSpectrumRing(frame: number, bands: Float32Array): void {
517
- const ring = this.spectrumRing;
518
- if (!ring) {
519
- return;
520
- }
521
- const writeIndex = Atomics.load(ring.header, 0);
522
- const offset = (writeIndex % ring.capacity) * ring.recordFloats;
523
- ring.records[offset] = encodeFrameLo(frame);
524
- ring.records[offset + 1] = encodeFrameHi(frame);
525
- ring.records[offset + 2] = bands.length;
526
- ring.records.set(bands.subarray(0, ring.bands), offset + 3);
527
- Atomics.store(ring.header, 0, writeIndex + 1);
528
- // See writeMeterRing: header slot 4 (the spectrum-ring overflow slot) is
529
- // left at its initial 0; readers detect silent overrun via the
530
- // firstReadable = max(readIndex, writeIndex - capacity) clamp. (Slot 3 here
531
- // holds the band count and is still written at ring creation.)
532
- }
533
- }
534
-
535
- /**
536
- * AudioWorklet-style bridge for the DAW realtime engine facade.
537
- *
538
- * The default mode uses the existing `sonare.wasm` embind facade. The
539
- * `sonare-rt` target is exposed as a selectable runtime target for hosts that
540
- * load the dedicated Emscripten AudioWorklet module.
541
- */
542
- export class SonareRealtimeEngineWorkletProcessor {
543
- private static warnedChannelScratchOverflow = false;
544
- readonly sampleRate: number;
545
- readonly blockSize: number;
546
- readonly channelCount: number;
547
- readonly runtimeTarget: 'embind' | 'sonare-rt';
548
- private engine: RealtimeEngine;
549
- private closed = false;
550
- private commandRing?: SonareEngineCommandRingBuffer;
551
- private telemetryRing?: SonareEngineTelemetryRingBuffer;
552
- private meterRing?: SharedMeterRingWriter;
553
- private scopeRing?: SharedScopeRingWriter;
554
- private transport?: WorkletTransport;
555
- private meterIntervalFrames: number;
556
- private lastMeterFrame = Number.NEGATIVE_INFINITY;
557
- // Latest metronome gains/click length pushed via 'syncMetronome'. The
558
- // SetMetronome command only toggles enabled state; the config arrives here.
559
- private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
560
- // Zero-copy prepared realtime path: persistent per-channel views onto the
561
- // engine's WASM-heap scratch (acquired once on the main thread via
562
- // getChannelBuffer). process() writes the AudioWorklet input straight into
563
- // these views, calls engine.processPrepared(frames) which runs the engine IN
564
- // PLACE, then reads the same views back — no std::vector or JS Float32Array is
565
- // allocated per render quantum (the old engine.process() round-tripped fresh
566
- // arrays on both heaps every block, an RT-safety hazard).
567
- private channelBuffers: Float32Array[];
568
- private readonly liveClips = new Map<number, EngineClip>();
569
-
570
- constructor(
571
- options: SonareRealtimeEngineWorkletProcessorOptions = {},
572
- transport?: WorkletTransport,
573
- ) {
574
- this.sampleRate = options.sampleRate ?? 48000;
575
- this.blockSize = options.blockSize ?? 128;
576
- this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
577
- this.runtimeTarget = options.runtimeTarget ?? 'embind';
578
- if (this.runtimeTarget === 'sonare-rt') {
579
- throw new Error(
580
- 'sonare-rt runtime is provided by the dedicated Emscripten AudioWorklet module; use SonareRealtimeEngineNode.create({ runtimeTarget: "sonare-rt", moduleUrl: ... }) to load it.',
581
- );
582
- }
583
- this.transport = transport;
584
- this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
585
- this.commandRing = options.commandSharedBuffer
586
- ? this.commandRingFromSharedBuffer(options.commandSharedBuffer, options.commandRingCapacity)
587
- : undefined;
588
- this.telemetryRing = options.telemetrySharedBuffer
589
- ? this.telemetryRingFromSharedBuffer(
590
- options.telemetrySharedBuffer,
591
- options.telemetryRingCapacity,
592
- )
593
- : undefined;
594
- this.meterRing = options.meterSharedBuffer
595
- ? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
596
- : undefined;
597
- this.scopeRing = options.scopeSharedBuffer
598
- ? scopeRingFromSharedBuffer(
599
- options.scopeSharedBuffer,
600
- options.scopeRingCapacity,
601
- options.scopeBands,
602
- )
603
- : undefined;
604
- this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
605
- // Allocate persistent WASM-heap scratch (worst case: channelCount channels x
606
- // blockSize frames) and acquire the per-channel heap views once.
607
- this.engine.prepareChannels(this.channelCount, this.blockSize);
608
- this.channelBuffers = new Array(this.channelCount);
609
- for (let ch = 0; ch < this.channelCount; ch++) {
610
- this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
611
- }
612
- // Arm the engine's scope producer only when a scope ring was provided. The
613
- // band count follows the ring's record layout so writeScopeRing never
614
- // overruns its slot.
615
- if (this.scopeRing) {
616
- const interval = Math.max(1, Math.floor(options.scopeIntervalFrames ?? this.blockSize));
617
- this.engine.configureScopeTelemetry(interval, this.scopeRing.bands);
618
- }
619
- }
620
-
621
- process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
622
- if (this.closed) {
623
- return false;
624
- }
625
- const output = outputs[0];
626
- const firstOutput = output?.[0];
627
- if (!firstOutput) {
628
- return true;
629
- }
630
- const frames = firstOutput.length;
631
- if (frames > this.blockSize) {
632
- for (const channel of output ?? []) {
633
- channel.fill(0);
634
- }
635
- this.publishTelemetry();
636
- return true;
637
- }
638
-
639
- this.drainCommands();
640
-
641
- // Clamp `frames` to the pre-allocated scratch capacity. The earlier
642
- // `frames > this.blockSize` branch already returns early, so this is
643
- // defensive — but we warn once if it ever fires so the contract violation
644
- // is visible.
645
- let usableFrames = frames;
646
- if (usableFrames > this.blockSize) {
647
- if (!SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow) {
648
- SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = true;
649
- // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
650
- console.warn(
651
- `SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames ` +
652
- `exceeds pre-allocated capacity ${this.blockSize}; clamping.`,
653
- );
654
- }
655
- usableFrames = this.blockSize;
656
- }
657
-
658
- // Defend against WASM linear-memory growth detaching the cached heap views:
659
- // if any view's backing ArrayBuffer has been detached (byteLength === 0),
660
- // re-acquire all of them. This is a control-flow check (no allocation in the
661
- // common case where memory did not grow).
662
- if ((this.channelBuffers[0]?.byteLength ?? 0) === 0) {
663
- this.reacquireChannelBuffers();
664
- }
665
-
666
- const input = inputs[0];
667
- // Write the AudioWorklet input straight into the engine's WASM-heap views;
668
- // no per-block heap allocation.
669
- for (let ch = 0; ch < this.channelCount; ch++) {
670
- const dst = this.channelBuffers[ch];
671
- const source = input?.[ch];
672
- if (source && source.length === usableFrames) {
673
- dst.set(source.subarray(0, usableFrames));
674
- } else {
675
- dst.fill(0, 0, usableFrames);
676
- }
677
- }
678
-
679
- // Run the engine in place over the prepared scratch (allocation-free).
680
- this.engine.processPrepared(usableFrames);
681
-
682
- for (let ch = 0; ch < output.length; ch++) {
683
- const target = output[ch];
684
- const source = this.channelBuffers[ch] ?? this.channelBuffers[0];
685
- if (source) {
686
- target.set(source.subarray(0, Math.min(target.length, usableFrames)));
687
- if (target.length > usableFrames) {
688
- target.fill(0, usableFrames);
689
- }
690
- } else {
691
- target.fill(0);
692
- }
693
- }
694
- this.publishTelemetry();
695
- this.publishMeters();
696
- this.publishScope();
697
- return true;
698
- }
699
-
700
- private reacquireChannelBuffers(): void {
701
- for (let ch = 0; ch < this.channelCount; ch++) {
702
- this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
703
- }
704
- }
705
-
706
- receiveCommand(command: SonareEngineCommandRecord): void {
707
- if (!this.closed) {
708
- this.applyCommand(command);
709
- }
710
- }
711
-
712
- // Applies an out-of-band control-plane sync message. Runs on the AudioWorklet
713
- // global scope but OUTSIDE process() (the message-port callback), so the
714
- // bulk/allocating engine setters (setClips/setMarkers) are safe here — they
715
- // never run on the realtime render path. This is the audio-thread equivalent
716
- // of the engine's control-thread RtPublisher setters.
717
- receiveSync(message: SonareEngineSyncMessage): void {
718
- if (this.closed) {
719
- return;
720
- }
721
- switch (message.type) {
722
- case 'syncClips':
723
- this.liveClips.clear();
724
- for (const clip of message.clips) {
725
- if (clip.id !== undefined) {
726
- this.liveClips.set(clip.id, clip);
727
- }
728
- }
729
- this.engine.setClips(message.clips);
730
- break;
731
- case 'syncClipsDelta':
732
- for (const clipId of message.removeIds) {
733
- this.liveClips.delete(clipId);
734
- }
735
- for (const clip of message.upserts) {
736
- if (clip.id !== undefined) {
737
- this.liveClips.set(clip.id, clip);
738
- }
739
- }
740
- this.engine.setClips(Array.from(this.liveClips.values()));
741
- break;
742
- case 'syncMidiClips':
743
- this.engine.setMidiClips(message.clips);
744
- break;
745
- case 'syncMarkers':
746
- this.engine.setMarkers(message.markers);
747
- break;
748
- case 'syncMetronome':
749
- this.metronomeConfig = resolveMetronomeConfig(message.config);
750
- this.engine.setMetronome(message.config);
751
- break;
752
- case 'syncAutomation':
753
- this.engine.setAutomationLane(message.paramId, message.points);
754
- break;
755
- case 'syncTempo':
756
- if (message.tempoSegments) {
757
- this.engine.setTempoSegments(message.tempoSegments);
758
- } else {
759
- this.engine.setTempo(message.bpm);
760
- }
761
- if (message.timeSignatureSegments) {
762
- this.engine.setTimeSignatureSegments(message.timeSignatureSegments);
763
- } else {
764
- this.engine.setTimeSignature(
765
- message.timeSignature.numerator,
766
- message.timeSignature.denominator,
767
- );
768
- }
769
- break;
770
- case 'syncMixer':
771
- if (message.buses) {
772
- this.engine.setTrackBuses(message.buses);
773
- }
774
- this.engine.setTrackLanes(message.lanes);
775
- for (const strip of message.trackStrips ?? []) {
776
- this.engine.setTrackStripJson(strip.trackId, strip.sceneJson);
777
- }
778
- for (const strip of message.busStrips ?? []) {
779
- this.engine.setBusStripJson(strip.busId, strip.sceneJson);
780
- }
781
- if (message.masterStripJson) {
782
- this.engine.setMasterStripJson(message.masterStripJson);
783
- }
784
- for (const binding of message.laneSidechains ?? []) {
785
- this.engine.setLaneSidechain(binding.trackId, binding.insertIndex, binding.sourceTrackId);
786
- }
787
- break;
788
- case 'syncCapture':
789
- this.engine.setCaptureBuffer(message.channels, message.bufferFrames);
790
- this.engine.setCaptureSource(message.source);
791
- this.engine.setRecordOffsetSamples(message.recordOffsetSamples);
792
- this.engine.setInputMonitor(message.inputMonitor.enabled, message.inputMonitor.gain);
793
- break;
794
- case 'syncTrackStripEqBand':
795
- this.engine.setTrackStripEqBandJson(message.trackId, message.bandIndex, message.bandJson);
796
- break;
797
- case 'syncMasterStripEqBand':
798
- this.engine.setMasterStripEqBandJson(message.bandIndex, message.bandJson);
799
- break;
800
- case 'syncTrackStripInsertBypassed':
801
- this.engine.setTrackStripInsertBypassed(
802
- message.trackId,
803
- message.insertIndex,
804
- message.bypassed,
805
- message.resetOnBypass,
806
- );
807
- break;
808
- case 'syncMasterStripInsertBypassed':
809
- this.engine.setMasterStripInsertBypassed(
810
- message.insertIndex,
811
- message.bypassed,
812
- message.resetOnBypass,
813
- );
814
- break;
815
- case 'syncTrackStripInsertParamByName':
816
- this.engine.setTrackStripInsertParamByName(
817
- message.trackId,
818
- message.insertIndex,
819
- message.paramName,
820
- message.value,
821
- );
822
- break;
823
- case 'syncMasterStripInsertParamByName':
824
- this.engine.setMasterStripInsertParamByName(
825
- message.insertIndex,
826
- message.paramName,
827
- message.value,
828
- );
829
- break;
830
- case 'syncTrackStripPan':
831
- this.engine.setTrackStripPan(message.trackId, message.pan);
832
- break;
833
- case 'syncTrackStripPanLaw':
834
- this.engine.setTrackStripPanLaw(message.trackId, message.panLaw);
835
- break;
836
- case 'syncTrackStripPanMode':
837
- this.engine.setTrackStripPanMode(message.trackId, message.panMode);
838
- break;
839
- case 'syncTrackStripDualPan':
840
- this.engine.setTrackStripDualPan(message.trackId, message.leftPan, message.rightPan);
841
- break;
842
- case 'syncTrackStripChannelDelaySamples':
843
- this.engine.setTrackStripChannelDelaySamples(message.trackId, message.delaySamples);
844
- break;
845
- case 'syncBuiltinInstrument':
846
- this.engine.setBuiltinInstrument(message.config, message.destinationId);
847
- break;
848
- case 'syncSynthInstrument':
849
- this.engine.setSynthInstrument(message.patch, message.destinationId);
850
- break;
851
- case 'syncLoadSoundFont':
852
- this.engine.loadSoundFont(message.data);
853
- break;
854
- case 'syncSf2Instrument':
855
- this.engine.setSf2Instrument(message.config, message.destinationId);
856
- break;
857
- case 'syncMidiNoteOn':
858
- this.engine.pushMidiNoteOn(
859
- message.destinationId,
860
- message.group,
861
- message.channel,
862
- message.note,
863
- message.velocity,
864
- message.renderFrame,
865
- );
866
- break;
867
- case 'syncMidiNoteOff':
868
- this.engine.pushMidiNoteOff(
869
- message.destinationId,
870
- message.group,
871
- message.channel,
872
- message.note,
873
- message.velocity,
874
- message.renderFrame,
875
- );
876
- break;
877
- case 'syncMidiCc':
878
- this.engine.pushMidiCc(
879
- message.destinationId,
880
- message.group,
881
- message.channel,
882
- message.controller,
883
- message.value,
884
- message.renderFrame,
885
- );
886
- break;
887
- case 'syncMidiPanic':
888
- this.engine.pushMidiPanic(message.renderFrame);
889
- break;
890
- }
891
- }
892
-
893
- receiveCaptureRequest(message: SonareEngineCaptureRequestMessage): void {
894
- if (this.closed) {
895
- return;
896
- }
897
- try {
898
- if (message.op === 'status') {
899
- const status = this.engine.captureStatus();
900
- this.transport?.postMessage?.({
901
- type: 'captureResponse',
902
- requestId: message.requestId,
903
- ok: true,
904
- status: {
905
- capturedFrames: status.capturedFrames,
906
- overflowCount: status.overflowCount,
907
- armed: status.armed,
908
- punchEnabled: status.punchEnabled,
909
- source: status.source,
910
- recordOffsetSamples: status.recordOffsetSamples,
911
- },
912
- } satisfies SonareEngineCaptureResponseMessage);
913
- return;
914
- }
915
- if (message.op === 'read') {
916
- const captured = this.engine.capturedAudio();
917
- const channels: number[][] = [];
918
- for (let ch = 0; ch < captured.length; ch++) {
919
- const source = captured[ch];
920
- const copy: number[] = [];
921
- for (let i = 0; i < source.length; i++) {
922
- copy.push(Number(source[i]));
923
- }
924
- channels.push(copy);
925
- }
926
- this.transport?.postMessage?.({
927
- type: 'captureResponse',
928
- requestId: message.requestId,
929
- ok: true,
930
- channels,
931
- } satisfies SonareEngineCaptureResponseMessage);
932
- return;
933
- }
934
- this.engine.resetCapture();
935
- this.transport?.postMessage?.({
936
- type: 'captureResponse',
937
- requestId: message.requestId,
938
- ok: true,
939
- } satisfies SonareEngineCaptureResponseMessage);
940
- } catch (error) {
941
- this.transport?.postMessage?.({
942
- type: 'captureResponse',
943
- requestId: message.requestId,
944
- ok: false,
945
- error: error instanceof Error ? error.message : String(error),
946
- } satisfies SonareEngineCaptureResponseMessage);
947
- }
948
- }
949
-
950
- receiveTransportRequest(message: SonareEngineTransportRequestMessage): void {
951
- if (this.closed) {
952
- return;
953
- }
954
- try {
955
- this.transport?.postMessage?.({
956
- type: 'transportResponse',
957
- requestId: message.requestId,
958
- ok: true,
959
- state: this.engine.getTransportState(),
960
- } satisfies SonareEngineTransportResponseMessage);
961
- } catch (error) {
962
- this.transport?.postMessage?.({
963
- type: 'transportResponse',
964
- requestId: message.requestId,
965
- ok: false,
966
- error: error instanceof Error ? error.message : String(error),
967
- } satisfies SonareEngineTransportResponseMessage);
968
- }
969
- }
970
-
971
- destroy(): void {
972
- if (!this.closed) {
973
- this.engine.destroy();
974
- this.closed = true;
975
- }
976
- }
977
-
978
- private drainCommands(): void {
979
- if (!this.commandRing) {
980
- return;
981
- }
982
- for (let i = 0; i < 64; i++) {
983
- const command = popSonareEngineCommandRingBuffer(this.commandRing);
984
- if (!command) {
985
- return;
986
- }
987
- this.applyCommand(command);
988
- }
989
- }
990
-
991
- private applyCommand(command: SonareEngineCommandRecord): void {
992
- const sampleTime = Number(command.sampleTime ?? -1);
993
- switch (command.type) {
994
- case SonareEngineCommandType.SetParam:
995
- // paramId is carried in targetId, the new value in argFloat (matches the
996
- // SonareEngine.setParam producer). sampleTime is the render frame.
997
- this.engine.setParameter(
998
- Math.trunc(Number(command.targetId ?? 0)),
999
- Number(command.argFloat ?? 0),
1000
- sampleTime,
1001
- );
1002
- break;
1003
- case SonareEngineCommandType.SetParamSmoothed:
1004
- this.engine.setParameterSmoothed(
1005
- Math.trunc(Number(command.targetId ?? 0)),
1006
- Number(command.argFloat ?? 0),
1007
- sampleTime,
1008
- );
1009
- break;
1010
- case SonareEngineCommandType.TransportPlay:
1011
- this.engine.play(sampleTime);
1012
- break;
1013
- case SonareEngineCommandType.TransportStop:
1014
- this.engine.stop(sampleTime);
1015
- break;
1016
- case SonareEngineCommandType.TransportSeekSample:
1017
- this.engine.seekSample(Number(command.argInt ?? 0), sampleTime);
1018
- break;
1019
- case SonareEngineCommandType.TransportSeekPpq:
1020
- this.engine.seekPpq(Number(command.argFloat ?? 0), sampleTime);
1021
- break;
1022
- case SonareEngineCommandType.SetTempoMap:
1023
- this.engine.setTempo(Number(command.argFloat ?? 120));
1024
- break;
1025
- case SonareEngineCommandType.SetLoop:
1026
- this.engine.setLoop(
1027
- Number(command.argFloat ?? 0),
1028
- Number(command.argInt ?? 0) / 1_000_000,
1029
- command.targetId !== 0,
1030
- );
1031
- break;
1032
- case SonareEngineCommandType.ArmRecord:
1033
- this.engine.armCapture(Boolean(command.argInt));
1034
- break;
1035
- case SonareEngineCommandType.Punch:
1036
- // Both endpoints already arrive as samples (see SonareEngine.punch);
1037
- // do NOT re-scale by sampleRate.
1038
- this.engine.setCapturePunch(
1039
- Number(command.argInt ?? 0),
1040
- Math.max(0, Math.round(Number(command.argFloat ?? 0))),
1041
- true,
1042
- );
1043
- break;
1044
- case SonareEngineCommandType.SetMetronome:
1045
- // Metronome config (beatGain/accentGain/clickSamples/clickSeconds) is
1046
- // delivered out-of-band via the 'syncMetronome' message so it carries
1047
- // the caller's full config; the command only toggles enabled state as a
1048
- // sample-aligned fallback.
1049
- this.engine.setMetronome({
1050
- enabled: Boolean(command.argInt),
1051
- beatGain: this.metronomeConfig.beatGain,
1052
- accentGain: this.metronomeConfig.accentGain,
1053
- clickSamples: this.metronomeConfig.clickSamples,
1054
- });
1055
- break;
1056
- case SonareEngineCommandType.SeekMarker:
1057
- // The realtime engine's markers are kept in sync via 'syncMarkers'
1058
- // (RtPublisher-style swap), so a queued kSeekMarker resolves correctly.
1059
- this.engine.seekMarker(Math.trunc(Number(command.targetId ?? 0)), sampleTime);
1060
- break;
1061
- case SonareEngineCommandType.SetSoloMute:
1062
- this.engine.setSoloMute(
1063
- Math.trunc(Number(command.targetId ?? 0)),
1064
- Boolean((Number(command.argInt ?? 0) & 0x2) !== 0),
1065
- Boolean((Number(command.argInt ?? 0) & 0x1) !== 0),
1066
- sampleTime,
1067
- );
1068
- break;
1069
- default:
1070
- this.publishTelemetryRecord({
1071
- type: SonareEngineTelemetryType.Error,
1072
- error: SonareEngineTelemetryError.UnknownTarget,
1073
- renderFrame: 0,
1074
- timelineSample: 0,
1075
- audibleTimelineSample: 0,
1076
- graphLatencySamplesQ8: 0,
1077
- value: Number(command.type),
1078
- });
1079
- break;
1080
- }
1081
- }
1082
-
1083
- private publishTelemetry(): void {
1084
- for (const item of this.engine.drainTelemetry(64)) {
1085
- this.publishTelemetryRecord(telemetryFromEngine(item));
1086
- }
1087
- }
1088
-
1089
- private publishTelemetryRecord(record: SonareEngineTelemetryRecord): void {
1090
- if (this.telemetryRing) {
1091
- writeSonareEngineTelemetryRingBuffer(this.telemetryRing, record);
1092
- return;
1093
- }
1094
- this.transport?.postMessage?.(record);
1095
- }
1096
-
1097
- // Drains the engine meter telemetry queue into the stereo meter ring / transport.
1098
- //
1099
- // Shared-queue contract: `drainMeterTelemetry` and `drainMeterTelemetryWide`
1100
- // pop the SAME single-consumer telemetry queue, so exactly ONE of them may run
1101
- // per engine. The live worklet path owns the queue via the stereo drain below;
1102
- // the worklet meter ring (SONARE_METER_RING_RECORD_FLOATS) is a fixed stereo
1103
- // layout carrying planes 0/1 plus the correlation/LUFS summary. Per-plane
1104
- // surround meters are NOT delivered over the live worklet ring — a host that
1105
- // needs them must use the offline `drainMeterTelemetryWide()` API on a
1106
- // non-worklet engine instance (do not also call it on a worklet-driven engine,
1107
- // or the two drains will starve each other).
1108
- private publishMeters(): void {
1109
- if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
1110
- return;
1111
- }
1112
- for (const item of this.engine.drainMeterTelemetry(64)) {
1113
- const meter = meterFromEngine(item);
1114
- if (
1115
- meter.frame !== this.lastMeterFrame &&
1116
- meter.frame - this.lastMeterFrame < this.meterIntervalFrames
1117
- ) {
1118
- continue;
1119
- }
1120
- if (meter.frame !== this.lastMeterFrame) {
1121
- this.lastMeterFrame = meter.frame;
1122
- }
1123
- // Prefer the lock-free SAB meter ring (matching the telemetry path and
1124
- // SonareWorkletProcessor); only fall back to structured-clone postMessage
1125
- // when no ring was provided, so we do not allocate/post from the audio
1126
- // render callback in SAB mode.
1127
- if (this.meterRing) {
1128
- this.writeMeterRing(meter);
1129
- } else {
1130
- this.transport?.onMeter?.(meter);
1131
- this.transport?.postMessage?.(meter);
1132
- }
1133
- }
1134
- }
1135
-
1136
- private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
1137
- const ring = this.meterRing;
1138
- if (!ring) {
1139
- return;
1140
- }
1141
- const writeIndex = Atomics.load(ring.header, 0);
1142
- const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
1143
- ring.records[offset] = encodeFrameLo(meter.frame);
1144
- ring.records[offset + 1] = encodeFrameHi(meter.frame);
1145
- ring.records[offset + 2] = meter.targetId;
1146
- ring.records[offset + 3] = meter.peakDbL;
1147
- ring.records[offset + 4] = meter.peakDbR;
1148
- ring.records[offset + 5] = meter.rmsDbL;
1149
- ring.records[offset + 6] = meter.rmsDbR;
1150
- ring.records[offset + 7] = meter.correlation;
1151
- ring.records[offset + 8] = meter.truePeakDbL;
1152
- ring.records[offset + 9] = meter.truePeakDbR;
1153
- ring.records[offset + 10] = meter.momentaryLufs;
1154
- ring.records[offset + 11] = meter.shortTermLufs;
1155
- ring.records[offset + 12] = meter.integratedLufs;
1156
- ring.records[offset + 13] = meter.gainReductionDb;
1157
- Atomics.store(ring.header, 0, writeIndex + 1);
1158
- // writeIndex is a free-running monotonic counter, so an overflow guard here
1159
- // would fire on essentially every write past the first `capacity` records
1160
- // and store an ever-growing value, not a dropped-record count. Readers
1161
- // already detect silent overrun via firstReadable = max(readIndex,
1162
- // writeIndex - capacity), so header slot 3 is left at its initial 0.
1163
- }
1164
-
1165
- // Drains the engine's scope producer (FFT spectrum + goniometer points) into
1166
- // the lock-free SAB scope ring. Only the embind runtime publishes scope
1167
- // telemetry; the sonare-rt runtime owns its own transport. No allocation on
1168
- // the render path: records are written field-by-field into the ring.
1169
- private publishScope(): void {
1170
- const ring = this.scopeRing;
1171
- if (!ring) {
1172
- return;
1173
- }
1174
- for (const item of this.engine.drainScopeTelemetry(64)) {
1175
- this.writeScopeRing(ring, item);
1176
- }
1177
- }
1178
-
1179
- private writeScopeRing(ring: SharedScopeRingWriter, record: EngineScopeTelemetry): void {
1180
- const writeIndex = Atomics.load(ring.header, 0);
1181
- const base = (writeIndex % ring.capacity) * ring.recordFloats;
1182
- ring.records[base] = encodeFrameLo(record.renderFrame);
1183
- ring.records[base + 1] = encodeFrameHi(record.renderFrame);
1184
- ring.records[base + 2] = record.targetId;
1185
- const bandCount = Math.min(ring.bands, record.bands.length);
1186
- ring.records[base + 3] = bandCount;
1187
- const pointCount = Math.min(ring.maxPoints, record.points.length);
1188
- ring.records[base + 4] = pointCount;
1189
- const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
1190
- for (let i = 0; i < bandCount; i++) {
1191
- ring.records[bandsBase + i] = record.bands[i];
1192
- }
1193
- const pointsBase = bandsBase + ring.bands;
1194
- for (let i = 0; i < pointCount; i++) {
1195
- const point = record.points[i];
1196
- ring.records[pointsBase + 2 * i] = point.left;
1197
- ring.records[pointsBase + 2 * i + 1] = point.right;
1198
- }
1199
- Atomics.store(ring.header, 0, writeIndex + 1);
1200
- // Like writeMeterRing, writeIndex is a free-running monotonic counter; the
1201
- // reader detects silent overrun via firstReadable, so the overflow slot
1202
- // (header[5]) stays at its initial 0.
1203
- }
1204
-
1205
- private commandRingFromSharedBuffer(
1206
- sharedBuffer: SharedArrayBuffer,
1207
- fallbackCapacity?: number,
1208
- ): SonareEngineCommandRingBuffer {
1209
- const ring = engineRingFromSharedBuffer(
1210
- sharedBuffer,
1211
- SONARE_ENGINE_COMMAND_RECORD_BYTES,
1212
- fallbackCapacity,
1213
- );
1214
- return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1215
- }
1216
-
1217
- private telemetryRingFromSharedBuffer(
1218
- sharedBuffer: SharedArrayBuffer,
1219
- fallbackCapacity?: number,
1220
- ): SonareEngineTelemetryRingBuffer {
1221
- const ring = engineRingFromSharedBuffer(
1222
- sharedBuffer,
1223
- SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
1224
- fallbackCapacity,
1225
- );
1226
- return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1227
- }
1228
- }
1229
-
1230
- export class SonareRtRealtimeEngineRuntime {
1231
- readonly sampleRate: number;
1232
- readonly blockSize: number;
1233
- readonly channelCount: number;
1234
- private readonly module: SonareRtModule;
1235
- private readonly memory: WebAssembly.Memory;
1236
- private readonly engine: number;
1237
- private readonly channelPointerTable: number;
1238
- private readonly channelBuffers: number[];
1239
- private readonly telemetryIntsPtr: number;
1240
- private readonly telemetryFramesPtr: number;
1241
- private readonly commandRing?: SonareEngineCommandRingBuffer;
1242
- private readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
1243
- private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
1244
- private closed = false;
1245
-
1246
- constructor(options: SonareRtRealtimeEngineRuntimeOptions) {
1247
- this.module = options.module;
1248
- this.memory = options.memory;
1249
- this.sampleRate = options.sampleRate ?? 48000;
1250
- this.blockSize = options.blockSize ?? 128;
1251
- this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
1252
- this.commandRing = options.commandSharedBuffer
1253
- ? this.commandRingFromSharedBuffer(options.commandSharedBuffer, options.commandRingCapacity)
1254
- : undefined;
1255
- this.telemetryRing = options.telemetrySharedBuffer
1256
- ? this.telemetryRingFromSharedBuffer(
1257
- options.telemetrySharedBuffer,
1258
- options.telemetryRingCapacity,
1259
- )
1260
- : undefined;
1261
-
1262
- this.engine = this.module._sonare_rt_engine_create();
1263
- if (this.engine <= 0) {
1264
- throw new Error('failed to create sonare-rt engine');
1265
- }
1266
- if (
1267
- this.module._sonare_rt_engine_prepare(
1268
- this.engine,
1269
- this.sampleRate,
1270
- this.blockSize,
1271
- 1024,
1272
- 1024,
1273
- ) !== 1
1274
- ) {
1275
- this.module._sonare_rt_engine_destroy(this.engine);
1276
- throw new Error('failed to prepare sonare-rt engine');
1277
- }
1278
- this.channelPointerTable = this.module._malloc(
1279
- this.channelCount * Uint32Array.BYTES_PER_ELEMENT,
1280
- );
1281
- this.channelBuffers = [];
1282
- for (let ch = 0; ch < this.channelCount; ch++) {
1283
- this.channelBuffers.push(
1284
- this.module._malloc(this.blockSize * Float32Array.BYTES_PER_ELEMENT),
1285
- );
1286
- }
1287
- this.telemetryIntsPtr = this.module._malloc(64 * 4 * Int32Array.BYTES_PER_ELEMENT);
1288
- this.telemetryFramesPtr = this.module._malloc(64 * 3 * Float64Array.BYTES_PER_ELEMENT);
1289
- this.writeChannelPointers();
1290
- }
1291
-
1292
- process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
1293
- if (this.closed) {
1294
- return false;
1295
- }
1296
- const output = outputs[0];
1297
- const firstOutput = output?.[0];
1298
- if (!firstOutput) {
1299
- return true;
1300
- }
1301
- const frames = firstOutput.length;
1302
- if (frames > this.blockSize) {
1303
- for (const channel of output) {
1304
- channel.fill(0);
1305
- }
1306
- return true;
1307
- }
1308
-
1309
- this.drainCommands();
1310
- const heap = new Float32Array(this.memory.buffer);
1311
- const input = inputs[0];
1312
- for (let ch = 0; ch < this.channelCount; ch++) {
1313
- const ptr = this.channelBuffers[ch] ?? this.channelBuffers[0];
1314
- const offset = ptr >> 2;
1315
- const source = input?.[ch];
1316
- if (source && source.length === frames) {
1317
- heap.set(source, offset);
1318
- } else {
1319
- heap.fill(0, offset, offset + frames);
1320
- }
1321
- }
1322
-
1323
- this.module._sonare_rt_engine_process(
1324
- this.engine,
1325
- this.channelPointerTable,
1326
- this.channelCount,
1327
- frames,
1328
- );
1329
-
1330
- for (let ch = 0; ch < output.length; ch++) {
1331
- const target = output[ch];
1332
- const ptr = this.channelBuffers[ch] ?? this.channelBuffers[0];
1333
- target.set(heap.subarray(ptr >> 2, (ptr >> 2) + target.length));
1334
- }
1335
- this.publishTelemetry();
1336
- return true;
1337
- }
1338
-
1339
- receiveCommand(command: SonareEngineCommandRecord): void {
1340
- if (!this.closed) {
1341
- this.applyCommand(command);
1342
- }
1343
- }
1344
-
1345
- // Out-of-band control sync for the sonare-rt runtime. The sonare-rt C ABI
1346
- // (src/wasm/rt_bindings.cpp) exposes set_metronome_enabled and seek_marker but
1347
- // NOT set_clips / set_markers, so clip/marker mutations cannot be applied to a
1348
- // live sonare-rt engine. We honor the metronome config and surface a clear
1349
- // telemetry error for the unsupported clip/marker paths instead of silently
1350
- // dropping them. The default 'embind' runtime wires all three fully.
1351
- receiveSync(message: SonareEngineSyncMessage): void {
1352
- if (this.closed) {
1353
- return;
1354
- }
1355
- switch (message.type) {
1356
- case 'syncMetronome':
1357
- this.metronomeConfig = resolveMetronomeConfig(message.config);
1358
- this.module._sonare_rt_engine_set_metronome_enabled(
1359
- this.engine,
1360
- message.config.enabled ? 1 : 0,
1361
- this.metronomeConfig.beatGain,
1362
- this.metronomeConfig.accentGain,
1363
- this.metronomeConfig.clickSamples,
1364
- );
1365
- break;
1366
- case 'syncTempo':
1367
- this.module._sonare_rt_engine_set_tempo(this.engine, message.bpm);
1368
- break;
1369
- case 'syncClips':
1370
- case 'syncClipsDelta':
1371
- case 'syncMidiClips':
1372
- case 'syncMarkers':
1373
- case 'syncAutomation':
1374
- case 'syncMixer':
1375
- case 'syncCapture':
1376
- case 'syncTrackStripEqBand':
1377
- case 'syncMasterStripEqBand':
1378
- case 'syncTrackStripInsertBypassed':
1379
- case 'syncMasterStripInsertBypassed':
1380
- case 'syncBuiltinInstrument':
1381
- case 'syncSynthInstrument':
1382
- case 'syncSf2Instrument':
1383
- case 'syncLoadSoundFont':
1384
- case 'syncMidiNoteOn':
1385
- case 'syncMidiNoteOff':
1386
- case 'syncMidiCc':
1387
- case 'syncMidiPanic':
1388
- // The sonare-rt C ABI exposes no set_clips / set_markers /
1389
- // set_automation_lane / set_track_lanes, so these mutations cannot
1390
- // reach a live sonare-rt engine. Surface a clear telemetry error rather
1391
- // than silently dropping.
1392
- if (this.telemetryRing) {
1393
- writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1394
- type: SonareEngineTelemetryType.Error,
1395
- error: SonareEngineTelemetryError.UnknownTarget,
1396
- renderFrame: 0,
1397
- timelineSample: 0,
1398
- audibleTimelineSample: 0,
1399
- graphLatencySamplesQ8: 0,
1400
- value: 0,
1401
- });
1402
- }
1403
- break;
1404
- }
1405
- }
1406
-
1407
- receiveCaptureRequest(message: SonareEngineCaptureRequestMessage, port?: WorkletPort): void {
1408
- if (this.closed) {
1409
- return;
1410
- }
1411
- port?.postMessage?.({
1412
- type: 'captureResponse',
1413
- requestId: message.requestId,
1414
- ok: false,
1415
- error: 'Capture read-back is not supported by the sonare-rt runtime.',
1416
- } satisfies SonareEngineCaptureResponseMessage);
1417
- }
1418
-
1419
- receiveTransportRequest(message: SonareEngineTransportRequestMessage, port?: WorkletPort): void {
1420
- if (this.closed) {
1421
- return;
1422
- }
1423
- port?.postMessage?.({
1424
- type: 'transportResponse',
1425
- requestId: message.requestId,
1426
- ok: false,
1427
- error: 'Transport state read-back is not supported by the sonare-rt runtime.',
1428
- } satisfies SonareEngineTransportResponseMessage);
1429
- }
1430
-
1431
- destroy(): void {
1432
- if (this.closed) {
1433
- return;
1434
- }
1435
- this.module._free(this.telemetryFramesPtr);
1436
- this.module._free(this.telemetryIntsPtr);
1437
- for (const ptr of this.channelBuffers) {
1438
- this.module._free(ptr);
1439
- }
1440
- this.module._free(this.channelPointerTable);
1441
- this.module._sonare_rt_engine_destroy(this.engine);
1442
- this.closed = true;
1443
- }
1444
-
1445
- private writeChannelPointers(): void {
1446
- const pointers = new Uint32Array(this.memory.buffer);
1447
- const offset = this.channelPointerTable >> 2;
1448
- for (let ch = 0; ch < this.channelBuffers.length; ch++) {
1449
- pointers[offset + ch] = this.channelBuffers[ch];
1450
- }
1451
- }
1452
-
1453
- private drainCommands(): void {
1454
- if (!this.commandRing) {
1455
- return;
1456
- }
1457
- for (let i = 0; i < 64; i++) {
1458
- const command = popSonareEngineCommandRingBuffer(this.commandRing);
1459
- if (!command) {
1460
- return;
1461
- }
1462
- this.applyCommand(command);
1463
- }
1464
- }
1465
-
1466
- private applyCommand(command: SonareEngineCommandRecord): void {
1467
- const sampleTime = toBigInt64(command.sampleTime, -1n);
1468
- switch (command.type) {
1469
- case SonareEngineCommandType.SetParam:
1470
- case SonareEngineCommandType.SetParamSmoothed:
1471
- // The sonare-rt C ABI (src/wasm/rt_bindings.cpp) does not export a
1472
- // sonare_rt_engine_set_param entry point, so parameter automation has no
1473
- // realtime transport on this runtime target. Surface a clear error
1474
- // telemetry record (rather than silently dropping the command) so hosts
1475
- // can detect the unsupported path; the embind runtime fully wires this.
1476
- if (this.telemetryRing) {
1477
- writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1478
- type: SonareEngineTelemetryType.Error,
1479
- error: SonareEngineTelemetryError.UnknownTarget,
1480
- renderFrame: 0,
1481
- timelineSample: 0,
1482
- audibleTimelineSample: 0,
1483
- graphLatencySamplesQ8: 0,
1484
- value: Number(command.type),
1485
- });
1486
- }
1487
- break;
1488
- case SonareEngineCommandType.TransportPlay:
1489
- this.module._sonare_rt_engine_play(this.engine, sampleTime);
1490
- break;
1491
- case SonareEngineCommandType.TransportStop:
1492
- this.module._sonare_rt_engine_stop(this.engine, sampleTime);
1493
- break;
1494
- case SonareEngineCommandType.TransportSeekSample:
1495
- this.module._sonare_rt_engine_seek_sample(
1496
- this.engine,
1497
- toBigInt64(command.argInt, 0n),
1498
- sampleTime,
1499
- );
1500
- break;
1501
- case SonareEngineCommandType.TransportSeekPpq:
1502
- this.module._sonare_rt_engine_seek_ppq(
1503
- this.engine,
1504
- Number(command.argFloat ?? 0),
1505
- sampleTime,
1506
- );
1507
- break;
1508
- case SonareEngineCommandType.SetTempoMap:
1509
- this.module._sonare_rt_engine_set_tempo(this.engine, Number(command.argFloat ?? 120));
1510
- break;
1511
- case SonareEngineCommandType.SetLoop:
1512
- this.module._sonare_rt_engine_set_loop(
1513
- this.engine,
1514
- Number(command.argFloat ?? 0),
1515
- Number(command.argInt ?? 0) / 1_000_000,
1516
- command.targetId ? 1 : 0,
1517
- );
1518
- break;
1519
- case SonareEngineCommandType.ArmRecord:
1520
- this.module._sonare_rt_engine_set_capture_armed(this.engine, command.argInt ? 1 : 0);
1521
- break;
1522
- case SonareEngineCommandType.Punch:
1523
- // Both endpoints already arrive as samples (see SonareEngine.punch);
1524
- // do NOT re-scale by sampleRate.
1525
- this.module._sonare_rt_engine_set_capture_punch(
1526
- this.engine,
1527
- toBigInt64(command.argInt, 0n),
1528
- BigInt(Math.max(0, Math.round(Number(command.argFloat ?? 0)))),
1529
- 1,
1530
- );
1531
- break;
1532
- case SonareEngineCommandType.SetMetronome:
1533
- this.module._sonare_rt_engine_set_metronome_enabled(
1534
- this.engine,
1535
- command.argInt ? 1 : 0,
1536
- this.metronomeConfig.beatGain,
1537
- this.metronomeConfig.accentGain,
1538
- this.metronomeConfig.clickSamples,
1539
- );
1540
- break;
1541
- case SonareEngineCommandType.SeekMarker:
1542
- this.module._sonare_rt_engine_seek_marker(
1543
- this.engine,
1544
- Math.trunc(command.targetId ?? 0),
1545
- sampleTime,
1546
- );
1547
- break;
1548
- default:
1549
- if (this.telemetryRing) {
1550
- writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1551
- type: SonareEngineTelemetryType.Error,
1552
- error: SonareEngineTelemetryError.UnknownTarget,
1553
- renderFrame: 0,
1554
- timelineSample: 0,
1555
- audibleTimelineSample: 0,
1556
- graphLatencySamplesQ8: 0,
1557
- value: Number(command.type),
1558
- });
1559
- }
1560
- break;
1561
- }
1562
- }
1563
-
1564
- private publishTelemetry(): void {
1565
- if (!this.telemetryRing) {
1566
- this.module._sonare_rt_engine_drain_telemetry(
1567
- this.engine,
1568
- this.telemetryIntsPtr,
1569
- this.telemetryFramesPtr,
1570
- 64,
1571
- );
1572
- return;
1573
- }
1574
- const count = this.module._sonare_rt_engine_drain_telemetry(
1575
- this.engine,
1576
- this.telemetryIntsPtr,
1577
- this.telemetryFramesPtr,
1578
- 64,
1579
- );
1580
- const ints = new Int32Array(this.memory.buffer);
1581
- const frames = new Float64Array(this.memory.buffer);
1582
- const intBase = this.telemetryIntsPtr >> 2;
1583
- const frameBase = this.telemetryFramesPtr >> 3;
1584
- for (let i = 0; i < count; i++) {
1585
- writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1586
- type: ints[intBase + i * 4],
1587
- error: ints[intBase + i * 4 + 1],
1588
- renderFrame: frames[frameBase + i * 3],
1589
- timelineSample: frames[frameBase + i * 3 + 1],
1590
- audibleTimelineSample: frames[frameBase + i * 3 + 2],
1591
- graphLatencySamplesQ8: ints[intBase + i * 4 + 2],
1592
- value: ints[intBase + i * 4 + 3],
1593
- });
1594
- }
1595
- }
1596
-
1597
- private commandRingFromSharedBuffer(
1598
- sharedBuffer: SharedArrayBuffer,
1599
- fallbackCapacity?: number,
1600
- ): SonareEngineCommandRingBuffer {
1601
- const ring = engineRingFromSharedBuffer(
1602
- sharedBuffer,
1603
- SONARE_ENGINE_COMMAND_RECORD_BYTES,
1604
- fallbackCapacity,
1605
- );
1606
- return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1607
- }
1608
-
1609
- private telemetryRingFromSharedBuffer(
1610
- sharedBuffer: SharedArrayBuffer,
1611
- fallbackCapacity?: number,
1612
- ): SonareEngineTelemetryRingBuffer {
1613
- const ring = engineRingFromSharedBuffer(
1614
- sharedBuffer,
1615
- SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
1616
- fallbackCapacity,
1617
- );
1618
- return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1619
- }
1620
- }
1621
-
1622
- export class SonareRealtimeEngineNode {
1623
- readonly node: AudioWorkletNode;
1624
- readonly capabilities: SonareRealtimeEngineNodeCapabilities;
1625
- readonly commandRing?: SonareEngineCommandRingBuffer;
1626
- readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
1627
- readonly meterRing?: SonareMeterRingBuffer;
1628
- readonly scopeRing?: SonareScopeRingBuffer;
1629
- readonly ready: Promise<void>;
1630
- private telemetryReadIndex = 0;
1631
- private meterReadIndex = 0;
1632
- private scopeReadIndex = 0;
1633
- private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
1634
- private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
1635
- private scopeListeners = new Set<(scope: SonareWorkletScopeSnapshot) => void>();
1636
- private captureRequestId = 1;
1637
- private readonly captureRequests = new Map<
1638
- number,
1639
- {
1640
- resolve: (response: SonareEngineCaptureResponseMessage) => void;
1641
- reject: (reason?: unknown) => void;
1642
- }
1643
- >();
1644
- private transportRequestId = 1;
1645
- private readonly transportRequests = new Map<
1646
- number,
1647
- {
1648
- resolve: (response: SonareEngineTransportResponseMessage) => void;
1649
- reject: (reason?: unknown) => void;
1650
- }
1651
- >();
1652
- private resolveReady!: () => void;
1653
- private rejectReady!: (reason?: unknown) => void;
1654
- private destroyed = false;
1655
-
1656
- private constructor(
1657
- node: AudioWorkletNode,
1658
- capabilities: SonareRealtimeEngineNodeCapabilities,
1659
- commandRing?: SonareEngineCommandRingBuffer,
1660
- telemetryRing?: SonareEngineTelemetryRingBuffer,
1661
- meterRing?: SonareMeterRingBuffer,
1662
- scopeRing?: SonareScopeRingBuffer,
1663
- ) {
1664
- this.node = node;
1665
- this.capabilities = capabilities;
1666
- this.commandRing = commandRing;
1667
- this.telemetryRing = telemetryRing;
1668
- this.meterRing = meterRing;
1669
- this.scopeRing = scopeRing;
1670
- this.ready = new Promise((resolve, reject) => {
1671
- this.resolveReady = resolve;
1672
- this.rejectReady = reject;
1673
- });
1674
- if (!capabilities.readyMessage) {
1675
- this.resolveReady();
1676
- }
1677
- this.node.port.onmessage = (event: MessageEvent<unknown>) => {
1678
- if (isEngineCaptureResponseMessage(event.data)) {
1679
- const pending = this.captureRequests.get(event.data.requestId);
1680
- if (pending) {
1681
- this.captureRequests.delete(event.data.requestId);
1682
- if (event.data.ok) {
1683
- pending.resolve(event.data);
1684
- } else {
1685
- pending.reject(new Error(event.data.error ?? 'Capture request failed'));
1686
- }
1687
- }
1688
- } else if (isEngineTransportResponseMessage(event.data)) {
1689
- const pending = this.transportRequests.get(event.data.requestId);
1690
- if (pending) {
1691
- this.transportRequests.delete(event.data.requestId);
1692
- if (event.data.ok) {
1693
- pending.resolve(event.data);
1694
- } else {
1695
- pending.reject(new Error(event.data.error ?? 'Transport request failed'));
1696
- }
1697
- }
1698
- } else if (isEngineTelemetryRecord(event.data)) {
1699
- this.emitTelemetry(event.data);
1700
- } else if (isMeterSnapshot(event.data)) {
1701
- this.emitMeter(event.data);
1702
- } else if (isRecord(event.data) && event.data.type === 'ready') {
1703
- this.resolveReady();
1704
- } else if (isRecord(event.data) && event.data.type === 'error') {
1705
- this.rejectReady(new Error(String(event.data.message ?? 'AudioWorklet error')));
1706
- }
1707
- };
1708
- }
1709
-
1710
- static async create(
1711
- context: BaseAudioContext,
1712
- options: SonareRealtimeEngineNodeOptions = {},
1713
- ): Promise<SonareRealtimeEngineNode> {
1714
- const runtimeTarget = options.runtimeTarget ?? 'embind';
1715
- const processorName = options.processorName ?? 'sonare-realtime-engine-processor';
1716
- const moduleUrl = options.moduleUrl;
1717
- if (moduleUrl && context.audioWorklet?.addModule) {
1718
- await context.audioWorklet.addModule(moduleUrl);
1719
- }
1720
- const detectedCapabilities =
1721
- options.engineAbiVersion !== undefined
1722
- ? {
1723
- engineAbiVersion: options.engineAbiVersion,
1724
- expectedEngineAbiVersion: options.expectedEngineAbiVersion ?? options.engineAbiVersion,
1725
- abiCompatible:
1726
- options.engineAbiVersion ===
1727
- (options.expectedEngineAbiVersion ?? options.engineAbiVersion),
1728
- }
1729
- : runtimeTarget === 'embind'
1730
- ? engineCapabilities()
1731
- : undefined;
1732
- if (options.requireAbiCompatible !== false && detectedCapabilities?.abiCompatible === false) {
1733
- throw new Error(
1734
- `Engine ABI mismatch: wasm=${detectedCapabilities.engineAbiVersion}, expected=${detectedCapabilities.expectedEngineAbiVersion}`,
1735
- );
1736
- }
1737
- const sharedArrayBuffer = typeof globalThis.SharedArrayBuffer === 'function';
1738
- const atomics = typeof globalThis.Atomics === 'object';
1739
- const audioWorklet = typeof AudioWorkletNode !== 'undefined' || !!options.nodeFactory;
1740
- const degradedReason =
1741
- options.mode !== 'postMessage' && (!sharedArrayBuffer || !atomics)
1742
- ? 'SharedArrayBuffer or Atomics unavailable; using postMessage transport.'
1743
- : undefined;
1744
- const mode =
1745
- options.mode === 'postMessage' || !sharedArrayBuffer || !atomics ? 'postMessage' : 'sab';
1746
- if (options.mode === 'sab' && mode !== 'sab') {
1747
- throw new Error(
1748
- 'SharedArrayBuffer mode requested but SharedArrayBuffer/Atomics are unavailable.',
1749
- );
1750
- }
1751
-
1752
- const commandRing =
1753
- mode === 'sab'
1754
- ? createSonareEngineCommandRingBuffer(options.commandRingCapacity ?? 128)
1755
- : undefined;
1756
- const telemetryRing =
1757
- mode === 'sab'
1758
- ? createSonareEngineTelemetryRingBuffer(options.telemetryRingCapacity ?? 128)
1759
- : undefined;
1760
- // Meter ring: only the embind runtime publishes engine meters into a SAB
1761
- // ring (the sonare-rt runtime owns its own meter transport). Lock-free
1762
- // meter delivery matches the telemetry path and keeps the audio render
1763
- // callback allocation-free in SAB mode.
1764
- const meterRing =
1765
- mode === 'sab' && runtimeTarget === 'embind'
1766
- ? createSonareMeterRingBuffer(options.meterRingCapacity ?? 128)
1767
- : undefined;
1768
- // Scope ring (FFT spectrum + goniometer): opt-in, embind-only. The
1769
- // per-block FFT is heavier than the meter path, so it is created only when
1770
- // the caller requests scope telemetry via scopeIntervalFrames > 0.
1771
- const scopeIntervalFrames = Math.max(0, Math.floor(options.scopeIntervalFrames ?? 0));
1772
- const scopeRing =
1773
- mode === 'sab' && runtimeTarget === 'embind' && scopeIntervalFrames > 0
1774
- ? createSonareScopeRingBuffer(options.scopeRingCapacity ?? 64, options.scopeBands ?? 48)
1775
- : undefined;
1776
- const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
1777
- const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
1778
- runtimeTarget,
1779
- rtModuleUrl: options.rtModuleUrl,
1780
- rtWasmBinary: options.rtWasmBinary,
1781
- sampleRate: options.sampleRate ?? context.sampleRate,
1782
- blockSize: options.blockSize,
1783
- channelCount,
1784
- commandSharedBuffer: commandRing?.sharedBuffer,
1785
- commandRingCapacity: commandRing?.capacity,
1786
- telemetrySharedBuffer: telemetryRing?.sharedBuffer,
1787
- telemetryRingCapacity: telemetryRing?.capacity,
1788
- meterSharedBuffer: meterRing?.sharedBuffer,
1789
- meterRingCapacity: meterRing?.capacity,
1790
- scopeSharedBuffer: scopeRing?.sharedBuffer,
1791
- scopeRingCapacity: scopeRing?.capacity,
1792
- scopeBands: scopeRing?.bands,
1793
- scopeIntervalFrames: scopeRing ? scopeIntervalFrames : undefined,
1794
- wasmBinary: options.wasmBinary,
1795
- initialSyncMessages: options.initialSyncMessages,
1796
- initialCommands: options.initialCommands,
1797
- };
1798
- const factory =
1799
- options.nodeFactory ??
1800
- ((ctx: BaseAudioContext, name: string, nodeOptions: AudioWorkletNodeOptions) =>
1801
- new AudioWorkletNode(ctx, name, nodeOptions));
1802
- const node = factory(context, processorName, {
1803
- numberOfInputs: 1,
1804
- numberOfOutputs: 1,
1805
- outputChannelCount: [channelCount],
1806
- processorOptions,
1807
- });
1808
- return new SonareRealtimeEngineNode(
1809
- node,
1810
- {
1811
- mode,
1812
- runtimeTarget,
1813
- sharedArrayBuffer,
1814
- atomics,
1815
- audioWorklet,
1816
- engineAbiVersion: detectedCapabilities?.engineAbiVersion,
1817
- expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
1818
- abiCompatible: detectedCapabilities?.abiCompatible,
1819
- degradedReason,
1820
- readyMessage:
1821
- runtimeTarget === 'sonare-rt' ||
1822
- (runtimeTarget === 'embind' && moduleUrl !== undefined && !options.nodeFactory),
1823
- },
1824
- commandRing,
1825
- telemetryRing,
1826
- meterRing,
1827
- scopeRing,
1828
- );
1829
- }
1830
-
1831
- play(sampleTime = -1): boolean {
1832
- return this.sendCommand({ type: SonareEngineCommandType.TransportPlay, sampleTime });
1833
- }
1834
-
1835
- stop(sampleTime = -1): boolean {
1836
- return this.sendCommand({ type: SonareEngineCommandType.TransportStop, sampleTime });
1837
- }
1838
-
1839
- seekSample(timelineSample: number, sampleTime = -1): boolean {
1840
- return this.sendCommand({
1841
- type: SonareEngineCommandType.TransportSeekSample,
1842
- sampleTime,
1843
- argInt: timelineSample,
1844
- });
1845
- }
1846
-
1847
- seekPpq(ppq: number, sampleTime = -1): boolean {
1848
- return this.sendCommand({
1849
- type: SonareEngineCommandType.TransportSeekPpq,
1850
- sampleTime,
1851
- argFloat: ppq,
1852
- });
1853
- }
1854
-
1855
- sendCommand(command: SonareEngineCommandRecord): boolean {
1856
- if (this.destroyed) {
1857
- return false;
1858
- }
1859
- if (this.commandRing) {
1860
- return pushSonareEngineCommandRingBuffer(this.commandRing, command);
1861
- }
1862
- this.node.port.postMessage(command);
1863
- return true;
1864
- }
1865
-
1866
- requestCaptureStatus(): Promise<EngineCaptureStatus> {
1867
- return this.sendCaptureRequest('status').then((response) => {
1868
- if (!response.status) {
1869
- throw new Error('Capture status response is missing status.');
1870
- }
1871
- return response.status;
1872
- });
1873
- }
1874
-
1875
- requestCapturedAudio(): Promise<Float32Array[]> {
1876
- return this.sendCaptureRequest('read').then((response) =>
1877
- (response.channels ?? []).map((channel) =>
1878
- channel instanceof Float32Array ? channel : new Float32Array(channel),
1879
- ),
1880
- );
1881
- }
1882
-
1883
- requestCaptureReset(): Promise<void> {
1884
- return this.sendCaptureRequest('reset').then(() => undefined);
1885
- }
1886
-
1887
- requestTransportState(): Promise<EngineTransportState> {
1888
- return this.sendTransportRequest().then((response) => {
1889
- if (!response.state) {
1890
- throw new Error('Transport state response is missing state.');
1891
- }
1892
- return response.state;
1893
- });
1894
- }
1895
-
1896
- pollTelemetry(): SonareEngineTelemetryRecord[] {
1897
- if (!this.telemetryRing) {
1898
- return [];
1899
- }
1900
- const read = readSonareEngineTelemetryRingBuffer(this.telemetryRing, this.telemetryReadIndex);
1901
- this.telemetryReadIndex = read.nextReadIndex;
1902
- for (const telemetry of read.telemetry) {
1903
- this.emitTelemetry(telemetry);
1904
- }
1905
- return read.telemetry;
1906
- }
1907
-
1908
- // Drains any meters published into the SAB meter ring (embind SAB mode) and
1909
- // forwards them to onMeter listeners. In postMessage mode meters arrive via
1910
- // node.port.onmessage instead, so this is a no-op then.
1911
- pollMeters(): SonareWorkletMeterSnapshot[] {
1912
- if (!this.meterRing) {
1913
- return [];
1914
- }
1915
- const read = readSonareMeterRingBuffer(this.meterRing, this.meterReadIndex);
1916
- this.meterReadIndex = read.nextReadIndex;
1917
- for (const meter of read.meters) {
1918
- this.emitMeter(meter);
1919
- }
1920
- return read.meters;
1921
- }
1922
-
1923
- // Drains scope telemetry (FFT spectrum + goniometer points) published into the
1924
- // SAB scope ring and forwards each record to onScope listeners. A no-op unless
1925
- // the node was created with scopeIntervalFrames > 0 (embind SAB mode).
1926
- pollScope(): SonareWorkletScopeSnapshot[] {
1927
- if (!this.scopeRing) {
1928
- return [];
1929
- }
1930
- const read = readSonareScopeRingBuffer(this.scopeRing, this.scopeReadIndex);
1931
- this.scopeReadIndex = read.nextReadIndex;
1932
- for (const scope of read.scopes) {
1933
- this.emitScope(scope);
1934
- }
1935
- return read.scopes;
1936
- }
1937
-
1938
- onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
1939
- this.telemetryListeners.add(callback);
1940
- return () => {
1941
- this.telemetryListeners.delete(callback);
1942
- };
1943
- }
1944
-
1945
- onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
1946
- this.meterListeners.add(callback);
1947
- return () => {
1948
- this.meterListeners.delete(callback);
1949
- };
1950
- }
1951
-
1952
- onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
1953
- this.scopeListeners.add(callback);
1954
- return () => {
1955
- this.scopeListeners.delete(callback);
1956
- };
1957
- }
1958
-
1959
- destroy(): void {
1960
- if (this.destroyed) {
1961
- return;
1962
- }
1963
- this.destroyed = true;
1964
- this.node.port.postMessage({ type: SonareEngineCommandType.TransportStop, sampleTime: -1 });
1965
- this.node.disconnect();
1966
- for (const pending of this.captureRequests.values()) {
1967
- pending.reject(new Error('Realtime engine node is destroyed.'));
1968
- }
1969
- this.captureRequests.clear();
1970
- for (const pending of this.transportRequests.values()) {
1971
- pending.reject(new Error('Realtime engine node is destroyed.'));
1972
- }
1973
- this.transportRequests.clear();
1974
- this.telemetryListeners.clear();
1975
- this.meterListeners.clear();
1976
- this.scopeListeners.clear();
1977
- }
1978
-
1979
- private emitTelemetry(telemetry: SonareEngineTelemetryRecord): void {
1980
- for (const listener of this.telemetryListeners) {
1981
- listener(telemetry);
1982
- }
1983
- }
1984
-
1985
- private emitMeter(meter: SonareWorkletMeterSnapshot): void {
1986
- for (const listener of this.meterListeners) {
1987
- listener(meter);
1988
- }
1989
- }
1990
-
1991
- private emitScope(scope: SonareWorkletScopeSnapshot): void {
1992
- for (const listener of this.scopeListeners) {
1993
- listener(scope);
1994
- }
1995
- }
1996
-
1997
- private sendCaptureRequest(
1998
- op: SonareEngineCaptureRequestMessage['op'],
1999
- ): Promise<SonareEngineCaptureResponseMessage> {
2000
- if (this.destroyed) {
2001
- return Promise.reject(new Error('Realtime engine node is destroyed.'));
2002
- }
2003
- const requestId = this.captureRequestId++;
2004
- const promise = new Promise<SonareEngineCaptureResponseMessage>((resolve, reject) => {
2005
- this.captureRequests.set(requestId, { resolve, reject });
2006
- });
2007
- this.node.port.postMessage({ type: 'captureRequest', requestId, op });
2008
- return promise;
2009
- }
2010
-
2011
- private sendTransportRequest(): Promise<SonareEngineTransportResponseMessage> {
2012
- if (this.destroyed) {
2013
- return Promise.reject(new Error('Realtime engine node is destroyed.'));
2014
- }
2015
- const requestId = this.transportRequestId++;
2016
- const promise = new Promise<SonareEngineTransportResponseMessage>((resolve, reject) => {
2017
- this.transportRequests.set(requestId, { resolve, reject });
2018
- });
2019
- this.node.port.postMessage({ type: 'transportRequest', requestId, op: 'state' });
2020
- return promise;
2021
- }
2022
- }
2023
-
2024
- export class SonareEngine {
2025
- readonly node: AudioWorkletNode;
2026
- readonly capabilities: SonareRealtimeEngineNodeCapabilities;
2027
- readonly transport: SonareEngineTransportFacade;
2028
- private readonly realtimeNode: SonareRealtimeEngineNode;
2029
- private readonly offlineEngine: RealtimeEngine;
2030
- private readonly context: SuspendableAudioContext;
2031
- private readonly sampleRate: number;
2032
- private readonly offlineBlockSize: number;
2033
- private readonly offlineChannelCount: number;
2034
- private readonly automationLanes = new Map<number, EngineAutomationPoint[]>();
2035
- private readonly clips = new Map<number, EngineClip>();
2036
- private readonly midiClips = new Map<number, EngineMidiClipSchedule>();
2037
- private readonly markers = new Map<number, EngineMarker>();
2038
- private readonly trackLaneIds: number[] = [];
2039
- private readonly trackSends = new Map<number, EngineTrackSend[]>();
2040
- private readonly trackOutputBus = new Map<number, number>();
2041
- private readonly laneSidechains = new Map<
2042
- string,
2043
- { trackId: number; insertIndex: number; sourceTrackId: number }
2044
- >();
2045
- private readonly buses: EngineBus[] = [];
2046
- private readonly trackStripJson = new Map<number, string>();
2047
- private readonly busStripJson = new Map<number, string>();
2048
- private masterStripJson: string | undefined;
2049
- private captureConfig: Omit<SonareEngineSyncCaptureMessage, 'type'> | undefined;
2050
- private tempoBpm = 120;
2051
- private timeSignature = { numerator: 4, denominator: 4 };
2052
- private tempoSegments: EngineTempoSegment[] = [{ startPpq: 0, bpm: 120 }];
2053
- private timeSignatureSegments: EngineTimeSignatureSegment[] = [
2054
- { startPpq: 0, numerator: 4, denominator: 4 },
2055
- ];
2056
- private latestTransportState: EngineTransportState | undefined;
2057
- private nextClipId = 1;
2058
- private nextMarkerId = 1;
2059
- private transportPlaying = false;
2060
- private readonly pendingInstrumentSync: SonareEngineInstrumentSyncMessage[] = [];
2061
- private destroyed = false;
2062
-
2063
- private constructor(
2064
- context: BaseAudioContext,
2065
- realtimeNode: SonareRealtimeEngineNode,
2066
- offlineEngine: RealtimeEngine,
2067
- sampleRate: number,
2068
- offlineBlockSize: number,
2069
- offlineChannelCount: number,
2070
- ) {
2071
- this.context = context;
2072
- this.realtimeNode = realtimeNode;
2073
- this.offlineEngine = offlineEngine;
2074
- this.node = realtimeNode.node;
2075
- this.capabilities = realtimeNode.capabilities;
2076
- this.sampleRate = sampleRate;
2077
- this.offlineBlockSize = offlineBlockSize;
2078
- this.offlineChannelCount = offlineChannelCount;
2079
- this.transport = {
2080
- play: (sampleTime = -1) => {
2081
- const ok = this.realtimeNode.play(sampleTime);
2082
- if (ok) {
2083
- this.transportPlaying = true;
2084
- }
2085
- return ok;
2086
- },
2087
- stop: (sampleTime = -1) => {
2088
- const ok = this.realtimeNode.stop(sampleTime);
2089
- if (ok) {
2090
- this.transportPlaying = false;
2091
- this.flushPendingInstrumentSync();
2092
- }
2093
- return ok;
2094
- },
2095
- seekPpq: (ppq, sampleTime = -1) => {
2096
- this.offlineEngine.seekPpq(ppq, sampleTime);
2097
- return this.realtimeNode.seekPpq(ppq, sampleTime);
2098
- },
2099
- seekSeconds: (seconds, sampleTime = -1) => {
2100
- const timelineSample = Math.max(0, Math.round(seconds * this.sampleRate));
2101
- this.offlineEngine.seekSample(timelineSample, sampleTime);
2102
- return this.realtimeNode.seekSample(timelineSample, sampleTime);
2103
- },
2104
- setTempo: (bpm) => this.setTempo(bpm),
2105
- setTempoSegments: (segments) => this.setTempoSegments(segments),
2106
- setLoop: (startPpq, endPpq, enabled = true) => this.setLoop(startPpq, endPpq, enabled),
2107
- };
2108
- }
2109
-
2110
- static async create(
2111
- context: BaseAudioContext,
2112
- options: SonareEngineOptions = {},
2113
- ): Promise<SonareEngine> {
2114
- const sampleRate = options.sampleRate ?? context.sampleRate;
2115
- const blockSize = options.offlineBlockSize ?? options.blockSize ?? 128;
2116
- const channelCount = Math.max(
2117
- 1,
2118
- Math.floor(options.offlineChannelCount ?? options.channelCount ?? 2),
2119
- );
2120
- const realtimeNode = await SonareRealtimeEngineNode.create(context, options);
2121
- const offlineEngine = options.offlineEngine ?? new RealtimeEngine(sampleRate, blockSize);
2122
- return new SonareEngine(
2123
- context,
2124
- realtimeNode,
2125
- offlineEngine,
2126
- sampleRate,
2127
- blockSize,
2128
- channelCount,
2129
- );
2130
- }
2131
-
2132
- async suspend(): Promise<void> {
2133
- if (this.destroyed) {
2134
- return;
2135
- }
2136
- await this.context.suspend?.();
2137
- }
2138
-
2139
- async resume(): Promise<void> {
2140
- if (this.destroyed) {
2141
- return;
2142
- }
2143
- await this.context.resume?.();
2144
- }
2145
-
2146
- setTempo(bpm: number): void {
2147
- this.tempoBpm = bpm;
2148
- this.tempoSegments = [{ startPpq: 0, bpm }];
2149
- this.offlineEngine.setTempo(bpm);
2150
- this.postTempoSync();
2151
- this.realtimeNode.sendCommand({
2152
- type: SonareEngineCommandType.SetTempoMap,
2153
- sampleTime: -1,
2154
- argFloat: bpm,
2155
- });
2156
- }
2157
-
2158
- setTempoSegments(segments: readonly EngineTempoSegment[]): void {
2159
- this.tempoSegments = segments.map((segment) => ({ ...segment }));
2160
- this.tempoBpm = this.tempoSegments[0]?.bpm ?? this.tempoBpm;
2161
- this.offlineEngine.setTempoSegments(this.tempoSegments);
2162
- this.postTempoSync();
2163
- }
2164
-
2165
- setTimeSignature(numerator: number, denominator: number): void {
2166
- this.timeSignature = { numerator, denominator };
2167
- this.timeSignatureSegments = [{ startPpq: 0, numerator, denominator }];
2168
- this.offlineEngine.setTimeSignature(numerator, denominator);
2169
- this.postTempoSync();
2170
- }
2171
-
2172
- setTimeSignatureSegments(segments: readonly EngineTimeSignatureSegment[]): void {
2173
- this.timeSignatureSegments = segments.map((segment) => ({ ...segment }));
2174
- const first = this.timeSignatureSegments[0];
2175
- if (first) {
2176
- this.timeSignature = { numerator: first.numerator, denominator: first.denominator };
2177
- }
2178
- this.offlineEngine.setTimeSignatureSegments(this.timeSignatureSegments);
2179
- this.postTempoSync();
2180
- }
2181
-
2182
- setLoop(startPpq: number, endPpq: number, enabled = true): boolean {
2183
- this.offlineEngine.setLoop(startPpq, endPpq, enabled);
2184
- // Transport precision contract: the SAB command record carries exactly one
2185
- // Float64 lane (argFloat) and one Int64 lane (argInt). startPpq travels in
2186
- // argFloat with full double precision, matching the offline engine; endPpq
2187
- // is carried as micro-PPQ (round(endPpq * 1e6)) in the integer lane and
2188
- // divided back by 1e6 on the consumer. Loop ENDS are therefore snapped to
2189
- // the nearest 1e-6 PPQ over the realtime transport (max 5e-7 PPQ drift),
2190
- // while loop STARTS and the offline path stay exact. This is intentional:
2191
- // the record has no second free Float64 lane, and a micro-PPQ grid on the
2192
- // loop end is well below audible/sample-accurate resolution at any tempo.
2193
- return this.realtimeNode.sendCommand({
2194
- type: SonareEngineCommandType.SetLoop,
2195
- targetId: enabled ? 1 : 0,
2196
- sampleTime: -1,
2197
- argFloat: startPpq,
2198
- argInt: Math.round(endPpq * 1_000_000),
2199
- });
2200
- }
2201
-
2202
- countInEndSample(startSample: number, bars: number): number {
2203
- return this.offlineEngine.countInEndSample(startSample, bars);
2204
- }
2205
-
2206
- async getTransportState(): Promise<EngineTransportState> {
2207
- const state = await this.realtimeNode.requestTransportState();
2208
- this.latestTransportState = state;
2209
- return state;
2210
- }
2211
-
2212
- cachedTransportState(): EngineTransportState | undefined {
2213
- return this.latestTransportState;
2214
- }
2215
-
2216
- setParam(nodeId: string, param: string | number, value: number): boolean {
2217
- const paramId = this.resolveParamId(nodeId, param);
2218
- // Mirror the change into the offline engine so a subsequent offline render
2219
- // reflects the live value, then push a sample-accurate command to the
2220
- // realtime runtime (mirrors setTempo/setLoop above).
2221
- this.offlineEngine.setParameter(paramId, value);
2222
- return this.realtimeNode.sendCommand({
2223
- type: SonareEngineCommandType.SetParam,
2224
- targetId: paramId,
2225
- sampleTime: -1,
2226
- argFloat: value,
2227
- });
2228
- }
2229
-
2230
- scheduleParam(
2231
- nodeId: string,
2232
- param: string | number,
2233
- ppq: number,
2234
- value: number,
2235
- curve: number | 'linear' | 'exponential' = 'linear',
2236
- ): void {
2237
- const paramId = this.resolveParamId(nodeId, param);
2238
- const lane = this.automationLanes.get(paramId) ?? [];
2239
- lane.push({ ppq, value, curveToNext: this.curveCode(curve) });
2240
- lane.sort((a, b) => a.ppq - b.ppq);
2241
- this.automationLanes.set(paramId, lane);
2242
- this.offlineEngine.setAutomationLane(paramId, lane);
2243
- // Mirror the lane to the live worklet engine so scheduled automation plays
2244
- // back in real time, not just in renderOffline(). Lanes can exceed the
2245
- // fixed-size SAB command record, so they ride an out-of-band 'syncAutomation'
2246
- // message applied outside process() (like syncClips/syncMarkers).
2247
- this.postSync({ type: 'syncAutomation', paramId, points: lane });
2248
- }
2249
-
2250
- addAutomationPoint(
2251
- laneId: string | number,
2252
- ppq: number,
2253
- value: number,
2254
- curve: number | 'linear' | 'exponential' = 'linear',
2255
- ): void {
2256
- this.scheduleParam('', laneId, ppq, value, curve);
2257
- }
2258
-
2259
- /**
2260
- * Replaces the automation lane for `paramId` with the given breakpoints.
2261
- *
2262
- * Unlike scheduleParam (which appends a single point), this sets the whole
2263
- * lane at once; an empty array clears the lane. The points are defensively
2264
- * copied and sorted by ppq before being mirrored to the offline engine and
2265
- * the live worklet engine.
2266
- *
2267
- * @param paramId Automation target id (registered parameter or a reserved
2268
- * engine mixer target from automationParamId/busAutomationParamId).
2269
- * @param points Lane breakpoints; order does not matter.
2270
- */
2271
- setAutomationLane(paramId: number, points: ReadonlyArray<EngineAutomationPoint>): void {
2272
- const sorted = points.map((point) => ({ ...point })).sort((a, b) => a.ppq - b.ppq);
2273
- if (sorted.length === 0) {
2274
- this.automationLanes.delete(paramId);
2275
- } else {
2276
- this.automationLanes.set(paramId, sorted);
2277
- }
2278
- this.offlineEngine.setAutomationLane(paramId, sorted);
2279
- this.postSync({ type: 'syncAutomation', paramId, points: sorted });
2280
- }
2281
-
2282
- /**
2283
- * Returns the automation target id for a mixer strip parameter.
2284
- *
2285
- * The id addresses the engine's reserved mixer namespace, so it can be fed
2286
- * straight to setAutomationLane to automate a fader or pan without
2287
- * registering a parameter.
2288
- *
2289
- * @param target Track id (declares a mixer lane on first use) or 'master'.
2290
- * @param kind Strip parameter to address.
2291
- * @returns Reserved engine parameter id for the strip parameter.
2292
- */
2293
- automationParamId(target: string | number, kind: 'faderDb' | 'pan'): number {
2294
- const paramKind = kind === 'pan' ? ENGINE_MIXER_PARAM_PAN : ENGINE_MIXER_PARAM_FADER_DB;
2295
- if (target === 'master') {
2296
- return engineMixerMasterTarget(paramKind);
2297
- }
2298
- return engineMixerLaneTarget(this.ensureTrackLane(target), paramKind);
2299
- }
2300
-
2301
- /**
2302
- * Returns the automation target id for a bus fader.
2303
- *
2304
- * @param busId Bus id (declares the mixer bus on first use).
2305
- * @returns Reserved engine parameter id for the bus fader gain (dB).
2306
- */
2307
- busAutomationParamId(busId: number): number {
2308
- return engineMixerBusTarget(this.ensureBus(busId), ENGINE_MIXER_PARAM_FADER_DB);
2309
- }
2310
-
2311
- /**
2312
- * Returns the number of automation lanes installed on the engine, including
2313
- * lanes whose breakpoint list is currently empty.
2314
- *
2315
- * @returns Engine-side automation lane count.
2316
- */
2317
- automationLaneCount(): number {
2318
- return this.offlineEngine.automationLaneCount();
2319
- }
2320
-
2321
- listParameters(): EngineParameterInfo[] {
2322
- const parameters: EngineParameterInfo[] = [];
2323
- for (let index = 0; index < this.offlineEngine.parameterCount(); index++) {
2324
- parameters.push(this.offlineEngine.parameterInfoByIndex(index));
2325
- }
2326
- return parameters;
2327
- }
2328
-
2329
- setSoloMute(target: string | number, solo: boolean, mute: boolean): boolean {
2330
- const laneIndex = this.ensureTrackLane(target);
2331
- this.offlineEngine.setSoloMute(laneIndex, solo, mute);
2332
- return this.realtimeNode.sendCommand({
2333
- type: SonareEngineCommandType.SetSoloMute,
2334
- targetId: laneIndex,
2335
- sampleTime: -1,
2336
- argInt: (mute ? 0x1 : 0) | (solo ? 0x2 : 0),
2337
- });
2338
- }
2339
-
2340
- setStripGain(target: string | number, db: number): boolean {
2341
- if (target === 'master') {
2342
- const paramId = engineMixerMasterTarget(ENGINE_MIXER_PARAM_FADER_DB);
2343
- this.offlineEngine.setParameter(paramId, db);
2344
- return this.realtimeNode.sendCommand({
2345
- type: SonareEngineCommandType.SetParamSmoothed,
2346
- targetId: paramId,
2347
- sampleTime: -1,
2348
- argFloat: db,
2349
- });
2350
- }
2351
- const laneIndex = this.ensureTrackLane(target);
2352
- const paramId = engineMixerLaneTarget(laneIndex, ENGINE_MIXER_PARAM_FADER_DB);
2353
- this.offlineEngine.setParameter(paramId, db);
2354
- return this.realtimeNode.sendCommand({
2355
- type: SonareEngineCommandType.SetParamSmoothed,
2356
- targetId: paramId,
2357
- sampleTime: -1,
2358
- argFloat: db,
2359
- });
2360
- }
2361
-
2362
- setStripPan(target: string | number, pan: number): boolean {
2363
- if (target === 'master') {
2364
- const paramId = engineMixerMasterTarget(ENGINE_MIXER_PARAM_PAN);
2365
- this.offlineEngine.setParameter(paramId, pan);
2366
- return this.realtimeNode.sendCommand({
2367
- type: SonareEngineCommandType.SetParamSmoothed,
2368
- targetId: paramId,
2369
- sampleTime: -1,
2370
- argFloat: pan,
2371
- });
2372
- }
2373
- const laneIndex = this.ensureTrackLane(target);
2374
- const paramId = engineMixerLaneTarget(laneIndex, ENGINE_MIXER_PARAM_PAN);
2375
- this.offlineEngine.setParameter(paramId, pan);
2376
- return this.realtimeNode.sendCommand({
2377
- type: SonareEngineCommandType.SetParamSmoothed,
2378
- targetId: paramId,
2379
- sampleTime: -1,
2380
- argFloat: pan,
2381
- });
2382
- }
2383
-
2384
- /**
2385
- * Declares the mixer track lanes in an explicit order.
2386
- *
2387
- * Lane indices are append-only: once a track id occupies a lane, its index
2388
- * stays fixed for the engine's lifetime. The given list must therefore start
2389
- * with the already-declared lane ids in their current order and may only
2390
- * append new track ids after them. Entries carrying `sends` replace that
2391
- * track's send list; entries without `sends` leave existing sends untouched.
2392
- *
2393
- * @param lanes Track ids or lane descriptors in the desired lane order.
2394
- */
2395
- setTrackLanes(lanes: ReadonlyArray<number | EngineTrackLane>): void {
2396
- const entries = lanes.map((lane) => (typeof lane === 'number' ? { trackId: lane } : lane));
2397
- const ids: number[] = [];
2398
- for (const entry of entries) {
2399
- if (!Number.isInteger(entry.trackId) || entry.trackId <= 0) {
2400
- throw new Error(`Invalid track id for mixer lane: ${String(entry.trackId)}`);
2401
- }
2402
- ids.push(entry.trackId);
2403
- }
2404
- if (new Set(ids).size !== ids.length) {
2405
- throw new Error('Duplicate track id in mixer lane list');
2406
- }
2407
- for (let index = 0; index < this.trackLaneIds.length; index++) {
2408
- if (ids[index] !== this.trackLaneIds[index]) {
2409
- throw new Error(
2410
- 'Mixer lanes are append-only: keep existing lanes in order and only append new track ids',
2411
- );
2412
- }
2413
- }
2414
- for (const entry of entries) {
2415
- if (entry.sends) {
2416
- this.trackSends.set(
2417
- entry.trackId,
2418
- entry.sends.map((send) => ({ ...send })),
2419
- );
2420
- }
2421
- if (entry.outputBusId !== undefined) {
2422
- if (entry.outputBusId === 0) {
2423
- this.trackOutputBus.delete(entry.trackId);
2424
- } else {
2425
- this.trackOutputBus.set(entry.trackId, entry.outputBusId);
2426
- }
2427
- }
2428
- }
2429
- this.trackLaneIds.splice(0, this.trackLaneIds.length, ...ids);
2430
- this.syncMixer();
2431
- }
2432
-
2433
- /**
2434
- * Routes a track lane's post-fader output into a declared bus instead of
2435
- * the master mix (group/folder routing); busId 0 restores the master mix.
2436
- */
2437
- setTrackOutputBus(target: string | number, busId: number): void {
2438
- const laneIndex = this.ensureTrackLane(target);
2439
- const trackId = this.trackLaneIds[laneIndex];
2440
- if (busId === 0) {
2441
- this.trackOutputBus.delete(trackId);
2442
- } else {
2443
- this.trackOutputBus.set(trackId, busId);
2444
- }
2445
- this.syncMixer();
2446
- }
2447
-
2448
- /**
2449
- * Keys one insert of a lane strip from another lane's post-strip pre-fader
2450
- * audio (ducking/sidechainRouter inserts). sourceTarget null removes the
2451
- * binding.
2452
- */
2453
- setLaneSidechain(
2454
- target: string | number,
2455
- insertIndex: number,
2456
- sourceTarget: string | number | null,
2457
- ): void {
2458
- const laneIndex = this.ensureTrackLane(target);
2459
- const trackId = this.trackLaneIds[laneIndex];
2460
- const key = `${trackId}:${insertIndex}`;
2461
- let sourceTrackId = 0;
2462
- if (sourceTarget !== null) {
2463
- const sourceIndex = this.ensureTrackLane(sourceTarget);
2464
- sourceTrackId = this.trackLaneIds[sourceIndex];
2465
- }
2466
- if (sourceTrackId === 0) {
2467
- this.laneSidechains.delete(key);
2468
- } else {
2469
- this.laneSidechains.set(key, { trackId, insertIndex, sourceTrackId });
2470
- }
2471
- this.offlineEngine.setLaneSidechain(trackId, insertIndex, sourceTrackId);
2472
- this.postSync({
2473
- type: 'syncMixer',
2474
- lanes: this.mixerLanes(),
2475
- laneSidechains: [{ trackId, insertIndex, sourceTrackId }],
2476
- });
2477
- }
2478
-
2479
- setSends(target: string | number, sends: EngineTrackSend[]): void {
2480
- const laneIndex = this.ensureTrackLane(target);
2481
- const trackId = this.trackLaneIds[laneIndex];
2482
- this.trackSends.set(
2483
- trackId,
2484
- sends.map((send) => ({ ...send })),
2485
- );
2486
- this.syncMixer();
2487
- }
2488
-
2489
- setTrackBuses(buses: EngineBus[]): void {
2490
- this.buses.splice(0, this.buses.length, ...buses.map((bus) => ({ ...bus })));
2491
- this.syncMixer();
2492
- }
2493
-
2494
- setBusGain(busId: number, db: number): boolean {
2495
- const busIndex = this.ensureBus(busId);
2496
- this.buses[busIndex] = { ...this.buses[busIndex], busId, gainDb: db };
2497
- this.offlineEngine.setTrackBuses(this.buses);
2498
- const paramId = engineMixerBusTarget(busIndex, ENGINE_MIXER_PARAM_FADER_DB);
2499
- this.offlineEngine.setParameter(paramId, db);
2500
- return this.realtimeNode.sendCommand({
2501
- type: SonareEngineCommandType.SetParamSmoothed,
2502
- targetId: paramId,
2503
- sampleTime: -1,
2504
- argFloat: db,
2505
- });
2506
- }
2507
-
2508
- setTrackStripJson(target: string | number, sceneJson: string): void {
2509
- const laneIndex = this.ensureTrackLane(target);
2510
- const trackId = this.trackLaneIds[laneIndex];
2511
- this.offlineEngine.setTrackStripJson(trackId, sceneJson);
2512
- this.trackStripJson.set(trackId, sceneJson);
2513
- this.syncMixer();
2514
- }
2515
-
2516
- setTrackStripEqBand(target: string | number, bandIndex: number, band: EqBand | string): void {
2517
- const laneIndex = this.ensureTrackLane(target);
2518
- const trackId = this.trackLaneIds[laneIndex];
2519
- const bandJson = typeof band === 'string' ? band : JSON.stringify(band);
2520
- this.offlineEngine.setTrackStripEqBandJson(trackId, bandIndex, bandJson);
2521
- this.postSync({ type: 'syncTrackStripEqBand', trackId, bandIndex, bandJson });
2522
- }
2523
-
2524
- setTrackStripInsertBypassed(
2525
- target: string | number,
2526
- insertIndex: number,
2527
- bypassed: boolean,
2528
- resetOnBypass = false,
2529
- ): void {
2530
- const laneIndex = this.ensureTrackLane(target);
2531
- const trackId = this.trackLaneIds[laneIndex];
2532
- this.offlineEngine.setTrackStripInsertBypassed(trackId, insertIndex, bypassed, resetOnBypass);
2533
- this.postSync({
2534
- type: 'syncTrackStripInsertBypassed',
2535
- trackId,
2536
- insertIndex,
2537
- bypassed,
2538
- resetOnBypass,
2539
- });
2540
- }
2541
-
2542
- setTrackStripInsertParamByName(
2543
- target: string | number,
2544
- insertIndex: number,
2545
- paramName: string,
2546
- value: number,
2547
- ): void {
2548
- const laneIndex = this.ensureTrackLane(target);
2549
- const trackId = this.trackLaneIds[laneIndex];
2550
- this.offlineEngine.setTrackStripInsertParamByName(trackId, insertIndex, paramName, value);
2551
- this.postSync({
2552
- type: 'syncTrackStripInsertParamByName',
2553
- trackId,
2554
- insertIndex,
2555
- paramName,
2556
- value,
2557
- });
2558
- }
2559
-
2560
- setTrackStripPan(target: string | number, pan: number): void {
2561
- const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
2562
- this.offlineEngine.setTrackStripPan(trackId, pan);
2563
- this.postSync({ type: 'syncTrackStripPan', trackId, pan });
2564
- }
2565
-
2566
- setTrackStripPanLaw(target: string | number, panLaw: PanLaw | number): void {
2567
- const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
2568
- const code = panLawCode(panLaw);
2569
- this.offlineEngine.setTrackStripPanLaw(trackId, code);
2570
- this.postSync({ type: 'syncTrackStripPanLaw', trackId, panLaw: code });
2571
- }
2572
-
2573
- setTrackStripPanMode(target: string | number, panMode: PanMode | number): void {
2574
- const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
2575
- const code = panModeCode(panMode);
2576
- this.offlineEngine.setTrackStripPanMode(trackId, code);
2577
- this.postSync({ type: 'syncTrackStripPanMode', trackId, panMode: code });
2578
- }
2579
-
2580
- setTrackStripDualPan(target: string | number, leftPan: number, rightPan: number): void {
2581
- const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
2582
- this.offlineEngine.setTrackStripDualPan(trackId, leftPan, rightPan);
2583
- this.postSync({ type: 'syncTrackStripDualPan', trackId, leftPan, rightPan });
2584
- }
2585
-
2586
- setTrackStripChannelDelaySamples(target: string | number, delaySamples: number): void {
2587
- const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
2588
- this.offlineEngine.setTrackStripChannelDelaySamples(trackId, delaySamples);
2589
- this.postSync({ type: 'syncTrackStripChannelDelaySamples', trackId, delaySamples });
2590
- }
2591
-
2592
- setStripEq(target: string | number, bandIndex: number, band: EqBand | string): void {
2593
- if (target === 'master') {
2594
- this.setMasterStripEqBand(bandIndex, band);
2595
- return;
2596
- }
2597
- this.setTrackStripEqBand(target, bandIndex, band);
2598
- }
2599
-
2600
- setStripInsertBypassed(
2601
- target: string | number,
2602
- insertIndex: number,
2603
- bypassed: boolean,
2604
- resetOnBypass = false,
2605
- ): void {
2606
- if (target === 'master') {
2607
- this.setMasterStripInsertBypassed(insertIndex, bypassed, resetOnBypass);
2608
- return;
2609
- }
2610
- this.setTrackStripInsertBypassed(target, insertIndex, bypassed, resetOnBypass);
2611
- }
2612
-
2613
- setStripInserts(target: string | number, sceneJson: string): void {
2614
- if (target === 'master') {
2615
- this.setMasterStripJson(sceneJson);
2616
- return;
2617
- }
2618
- this.setTrackStripJson(target, sceneJson);
2619
- }
2620
-
2621
- setBusStripJson(busId: number, sceneJson: string): void {
2622
- this.ensureBus(busId);
2623
- this.offlineEngine.setBusStripJson(busId, sceneJson);
2624
- this.busStripJson.set(busId, sceneJson);
2625
- this.syncMixer();
2626
- }
2627
-
2628
- setMasterStripJson(sceneJson: string): void {
2629
- this.offlineEngine.setMasterStripJson(sceneJson);
2630
- this.masterStripJson = sceneJson;
2631
- this.syncMixer();
2632
- }
2633
-
2634
- setMasterStripEqBand(bandIndex: number, band: EqBand | string): void {
2635
- const bandJson = typeof band === 'string' ? band : JSON.stringify(band);
2636
- this.offlineEngine.setMasterStripEqBandJson(bandIndex, bandJson);
2637
- this.postSync({ type: 'syncMasterStripEqBand', bandIndex, bandJson });
2638
- }
2639
-
2640
- setMasterStripInsertBypassed(
2641
- insertIndex: number,
2642
- bypassed: boolean,
2643
- resetOnBypass = false,
2644
- ): void {
2645
- this.offlineEngine.setMasterStripInsertBypassed(insertIndex, bypassed, resetOnBypass);
2646
- this.postSync({
2647
- type: 'syncMasterStripInsertBypassed',
2648
- insertIndex,
2649
- bypassed,
2650
- resetOnBypass,
2651
- });
2652
- }
2653
-
2654
- setMasterStripInsertParamByName(insertIndex: number, paramName: string, value: number): void {
2655
- this.offlineEngine.setMasterStripInsertParamByName(insertIndex, paramName, value);
2656
- this.postSync({
2657
- type: 'syncMasterStripInsertParamByName',
2658
- insertIndex,
2659
- paramName,
2660
- value,
2661
- });
2662
- }
2663
-
2664
- setStripInsertParamByName(
2665
- target: string | number,
2666
- insertIndex: number,
2667
- paramName: string,
2668
- value: number,
2669
- ): void {
2670
- if (target === 'master') {
2671
- this.setMasterStripInsertParamByName(insertIndex, paramName, value);
2672
- return;
2673
- }
2674
- this.setTrackStripInsertParamByName(target, insertIndex, paramName, value);
2675
- }
2676
-
2677
- setMasterChain(sceneJson: string): void {
2678
- this.setMasterStripJson(sceneJson);
2679
- }
2680
-
2681
- addClip(
2682
- trackId: string | number,
2683
- buffer: Float32Array[],
2684
- startPpq: number,
2685
- opts: Partial<Omit<EngineClip, 'channels' | 'startPpq'>> = {},
2686
- ): number {
2687
- const id = opts.id ?? this.nextClipId++;
2688
- const clip: EngineClip = {
2689
- ...opts,
2690
- id,
2691
- channels: buffer,
2692
- startPpq,
2693
- trackId: this.resolveTargetId(trackId),
2694
- };
2695
- this.ensureTrackLane(trackId);
2696
- this.clips.set(id, clip);
2697
- this.syncClipsDelta([clip], []);
2698
- return id;
2699
- }
2700
-
2701
- removeClip(clipId: number): void {
2702
- this.clips.delete(clipId);
2703
- this.syncClipsDelta([], [clipId]);
2704
- }
2705
-
2706
- setMidiClips(clips: readonly EngineMidiClipSchedule[]): void {
2707
- this.midiClips.clear();
2708
- for (const clip of clips) {
2709
- const id = clip.id ?? this.nextClipId++;
2710
- this.midiClips.set(id, { ...clip, id, events: clip.events.map((event) => ({ ...event })) });
2711
- }
2712
- this.syncMidiClips();
2713
- }
2714
-
2715
- setBuiltinInstrument(
2716
- trackId: string | number,
2717
- config: { destinationId?: number } & Record<string, unknown> = {},
2718
- ): void {
2719
- const destinationId = this.resolveTargetId(trackId);
2720
- this.offlineEngine.setBuiltinInstrument(config, destinationId);
2721
- this.postInstrumentSync({ type: 'syncBuiltinInstrument', destinationId, config });
2722
- }
2723
-
2724
- setSynthInstrument(trackId: string | number, patch: Record<string, unknown> | string = {}): void {
2725
- const destinationId = this.resolveTargetId(trackId);
2726
- this.offlineEngine.setSynthInstrument(patch, destinationId);
2727
- this.postInstrumentSync({ type: 'syncSynthInstrument', destinationId, patch });
2728
- }
2729
-
2730
- loadSoundFont(data: Uint8Array): void {
2731
- this.offlineEngine.loadSoundFont(data);
2732
- this.postInstrumentSync({ type: 'syncLoadSoundFont', data });
2733
- }
2734
-
2735
- setSf2Instrument(
2736
- trackId: string | number,
2737
- config: { destinationId?: number; gain?: number; polyphony?: number } = {},
2738
- ): void {
2739
- const destinationId = this.resolveTargetId(trackId);
2740
- this.offlineEngine.setSf2Instrument(config, destinationId);
2741
- this.postInstrumentSync({ type: 'syncSf2Instrument', destinationId, config });
2742
- }
2743
-
2744
- pushMidiNoteOn(
2745
- trackId: string | number,
2746
- group: number,
2747
- channel: number,
2748
- note: number,
2749
- velocity: number,
2750
- renderFrame = -1,
2751
- ): void {
2752
- const destinationId = this.resolveTargetId(trackId);
2753
- this.offlineEngine.pushMidiNoteOn(destinationId, group, channel, note, velocity, renderFrame);
2754
- this.postSync({
2755
- type: 'syncMidiNoteOn',
2756
- destinationId,
2757
- group,
2758
- channel,
2759
- note,
2760
- velocity,
2761
- renderFrame,
2762
- });
2763
- }
2764
-
2765
- pushMidiNoteOff(
2766
- trackId: string | number,
2767
- group: number,
2768
- channel: number,
2769
- note: number,
2770
- velocity = 0,
2771
- renderFrame = -1,
2772
- ): void {
2773
- const destinationId = this.resolveTargetId(trackId);
2774
- this.offlineEngine.pushMidiNoteOff(destinationId, group, channel, note, velocity, renderFrame);
2775
- this.postSync({
2776
- type: 'syncMidiNoteOff',
2777
- destinationId,
2778
- group,
2779
- channel,
2780
- note,
2781
- velocity,
2782
- renderFrame,
2783
- });
2784
- }
2785
-
2786
- pushMidiCc(
2787
- trackId: string | number,
2788
- group: number,
2789
- channel: number,
2790
- controller: number,
2791
- value: number,
2792
- renderFrame = -1,
2793
- ): void {
2794
- const destinationId = this.resolveTargetId(trackId);
2795
- this.offlineEngine.pushMidiCc(destinationId, group, channel, controller, value, renderFrame);
2796
- this.postSync({
2797
- type: 'syncMidiCc',
2798
- destinationId,
2799
- group,
2800
- channel,
2801
- controller,
2802
- value,
2803
- renderFrame,
2804
- });
2805
- }
2806
-
2807
- pushMidiPanic(renderFrame = -1): void {
2808
- this.offlineEngine.pushMidiPanic(renderFrame);
2809
- this.postSync({ type: 'syncMidiPanic', renderFrame });
2810
- }
2811
-
2812
- configureCapture(options: {
2813
- bufferFrames: number;
2814
- channels?: number;
2815
- source?: EngineCaptureStatus['source'];
2816
- recordOffsetSamples?: number;
2817
- inputMonitor?: { enabled: boolean; gain?: number };
2818
- }): void {
2819
- const bufferFrames = Math.trunc(options.bufferFrames);
2820
- const channels = Math.trunc(options.channels ?? this.offlineChannelCount);
2821
- const source = options.source ?? 'output';
2822
- const recordOffsetSamples = Math.trunc(options.recordOffsetSamples ?? 0);
2823
- const inputMonitor = {
2824
- enabled: Boolean(options.inputMonitor?.enabled),
2825
- gain: options.inputMonitor?.gain ?? 1,
2826
- };
2827
- this.offlineEngine.setCaptureBuffer(channels, bufferFrames);
2828
- this.offlineEngine.setCaptureSource(source);
2829
- this.offlineEngine.setRecordOffsetSamples(recordOffsetSamples);
2830
- this.offlineEngine.setInputMonitor(inputMonitor.enabled, inputMonitor.gain);
2831
- this.captureConfig = { bufferFrames, channels, source, recordOffsetSamples, inputMonitor };
2832
- this.postSync({ type: 'syncCapture', ...this.captureConfig });
2833
- }
2834
-
2835
- armRecord(trackId: string | number, enabled: boolean): boolean {
2836
- if (enabled && !this.captureConfig) {
2837
- throw new Error('Capture buffer is not configured');
2838
- }
2839
- this.offlineEngine.armCapture(enabled);
2840
- return this.realtimeNode.sendCommand({
2841
- type: SonareEngineCommandType.ArmRecord,
2842
- targetId: this.resolveTargetId(trackId),
2843
- sampleTime: -1,
2844
- argInt: enabled ? 1 : 0,
2845
- });
2846
- }
2847
-
2848
- punch(inPpq: number, outPpq: number): boolean {
2849
- const inSample = this.offlineEngine.sampleAtPpq(inPpq);
2850
- const outSample = this.offlineEngine.sampleAtPpq(outPpq);
2851
- this.offlineEngine.setCapturePunch(inSample, outSample, true);
2852
- // Carry BOTH endpoints as already-converted SAMPLES so the realtime engine
2853
- // agrees with the offline engine. The previous code sent the raw PPQ out
2854
- // point and let the consumer multiply by sampleRate (treating PPQ as
2855
- // seconds), which ignored tempo and produced a punch-out ~2x too large at
2856
- // 120 BPM. argInt = in sample, argFloat = out sample (full-precision double).
2857
- return this.realtimeNode.sendCommand({
2858
- type: SonareEngineCommandType.Punch,
2859
- sampleTime: -1,
2860
- argInt: inSample,
2861
- argFloat: outSample,
2862
- });
2863
- }
2864
-
2865
- captureStatus(): Promise<EngineCaptureStatus> {
2866
- return this.realtimeNode.requestCaptureStatus();
2867
- }
2868
-
2869
- capturedAudio(): Promise<Float32Array[]> {
2870
- return this.realtimeNode.requestCapturedAudio();
2871
- }
2872
-
2873
- async resetCapture(): Promise<void> {
2874
- this.offlineEngine.resetCapture();
2875
- await this.realtimeNode.requestCaptureReset();
2876
- }
2877
-
2878
- setMetronome(opts: EngineMetronomeConfig): void {
2879
- this.offlineEngine.setMetronome(opts);
2880
- // The full config (beatGain/accentGain/clickSamples/clickSeconds) cannot fit
2881
- // the fixed-size SAB command record, so it is delivered out-of-band; the
2882
- // SetMetronome command then toggles enabled state on the audio thread.
2883
- this.postSync({ type: 'syncMetronome', config: opts });
2884
- this.realtimeNode.sendCommand({
2885
- type: SonareEngineCommandType.SetMetronome,
2886
- sampleTime: -1,
2887
- argInt: opts.enabled ? 1 : 0,
2888
- });
2889
- }
2890
-
2891
- addMarker(ppq: number, name = ''): number {
2892
- const id = this.nextMarkerId++;
2893
- this.markers.set(id, { id, ppq, name });
2894
- this.syncMarkers();
2895
- return id;
2896
- }
2897
-
2898
- /**
2899
- * Replaces the whole marker set in one call.
2900
- *
2901
- * Entries without an `id` are assigned fresh ids; entries carrying an `id`
2902
- * keep it (ids must be positive and unique within the list). Returns the
2903
- * resolved markers in the order given, so a caller can map its own marker
2904
- * identities to the engine ids used by `seekMarker`/`setLoopFromMarkers`.
2905
- *
2906
- * @param markers The full marker list (an empty list clears all markers).
2907
- * @returns The markers with their resolved engine ids.
2908
- */
2909
- setMarkers(markers: ReadonlyArray<{ ppq: number; name?: string; id?: number }>): EngineMarker[] {
2910
- const resolved: EngineMarker[] = [];
2911
- const seen = new Set<number>();
2912
- for (const marker of markers) {
2913
- if (!Number.isFinite(marker.ppq)) {
2914
- throw new Error(`Invalid marker ppq: ${String(marker.ppq)}`);
2915
- }
2916
- if (marker.id !== undefined) {
2917
- if (!Number.isInteger(marker.id) || marker.id <= 0) {
2918
- throw new Error(`Invalid marker id: ${String(marker.id)}`);
2919
- }
2920
- if (seen.has(marker.id)) {
2921
- throw new Error(`Duplicate marker id: ${marker.id}`);
2922
- }
2923
- }
2924
- const id = marker.id ?? this.nextMarkerId++;
2925
- seen.add(id);
2926
- if (id >= this.nextMarkerId) {
2927
- this.nextMarkerId = id + 1;
2928
- }
2929
- resolved.push({ id, ppq: marker.ppq, name: marker.name ?? '' });
2930
- }
2931
- this.markers.clear();
2932
- for (const marker of resolved) {
2933
- this.markers.set(marker.id, marker);
2934
- }
2935
- this.syncMarkers();
2936
- return resolved.map((marker) => ({ ...marker }));
2937
- }
2938
-
2939
- markerCount(): number {
2940
- return this.offlineEngine.markerCount();
2941
- }
2942
-
2943
- markerByIndex(index: number): EngineMarker {
2944
- return this.offlineEngine.markerByIndex(index);
2945
- }
2946
-
2947
- marker(markerId: number): EngineMarker {
2948
- return this.offlineEngine.marker(markerId);
2949
- }
2950
-
2951
- seekMarker(markerId: number): boolean {
2952
- this.offlineEngine.seekMarker(markerId);
2953
- // Forward to the live worklet engine. Its marker set is kept in sync via the
2954
- // 'syncMarkers' message (see syncMarkers), so a queued kSeekMarker resolves
2955
- // the marker id to its frame on the audio thread. Returns the sendCommand
2956
- // result (previously this method always returned a misleading `false`).
2957
- return this.realtimeNode.sendCommand({
2958
- type: SonareEngineCommandType.SeekMarker,
2959
- targetId: markerId,
2960
- sampleTime: -1,
2961
- });
2962
- }
2963
-
2964
- setLoopFromMarkers(startMarkerId: number, endMarkerId: number): boolean {
2965
- this.offlineEngine.setLoopFromMarkers(startMarkerId, endMarkerId);
2966
- const start = this.offlineEngine.marker(startMarkerId);
2967
- const end = this.offlineEngine.marker(endMarkerId);
2968
- return this.setLoop(start.ppq, end.ppq, true);
2969
- }
2970
-
2971
- async renderOffline(totalFrames: number): Promise<Float32Array[]> {
2972
- const frames = Math.max(0, Math.floor(totalFrames));
2973
- const inputs: Float32Array[] = [];
2974
- for (let ch = 0; ch < this.offlineChannelCount; ch++) {
2975
- inputs.push(new Float32Array(frames));
2976
- }
2977
- return this.offlineEngine.renderOffline(inputs, this.offlineBlockSize);
2978
- }
2979
-
2980
- onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
2981
- return this.realtimeNode.onMeter(callback);
2982
- }
2983
-
2984
- onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
2985
- return this.realtimeNode.onScope(callback);
2986
- }
2987
-
2988
- onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
2989
- return this.realtimeNode.onTelemetry(callback);
2990
- }
2991
-
2992
- pollTelemetry(): SonareEngineTelemetryRecord[] {
2993
- return this.realtimeNode.pollTelemetry();
2994
- }
2995
-
2996
- pollMeters(): SonareWorkletMeterSnapshot[] {
2997
- return this.realtimeNode.pollMeters();
2998
- }
2999
-
3000
- pollScope(): SonareWorkletScopeSnapshot[] {
3001
- return this.realtimeNode.pollScope();
3002
- }
3003
-
3004
- destroy(): void {
3005
- if (this.destroyed) {
3006
- return;
3007
- }
3008
- this.destroyed = true;
3009
- this.transport.stop();
3010
- this.realtimeNode.pollTelemetry();
3011
- this.realtimeNode.destroy();
3012
- this.offlineEngine.destroy();
3013
- }
3014
-
3015
- private syncClipsDelta(upserts: EngineClip[], removeIds: number[]): void {
3016
- const clips = Array.from(this.clips.values());
3017
- this.offlineEngine.setClips(clips);
3018
- this.postSync({
3019
- type: 'syncClipsDelta',
3020
- upserts,
3021
- removeIds,
3022
- });
3023
- }
3024
-
3025
- private syncMidiClips(): void {
3026
- const clips = Array.from(this.midiClips.values());
3027
- this.offlineEngine.setMidiClips(clips);
3028
- this.postSync({ type: 'syncMidiClips', clips });
3029
- }
3030
-
3031
- private mixerLanes(): EngineTrackLane[] {
3032
- return this.trackLaneIds.map((trackId) => {
3033
- const sends = this.trackSends.get(trackId);
3034
- const outputBusId = this.trackOutputBus.get(trackId);
3035
- return {
3036
- trackId,
3037
- ...(sends && sends.length > 0 ? { sends: sends.map((send) => ({ ...send })) } : {}),
3038
- ...(outputBusId !== undefined ? { outputBusId } : {}),
3039
- };
3040
- });
3041
- }
3042
-
3043
- private syncMixer(): void {
3044
- const lanes = this.mixerLanes();
3045
- const buses = this.buses.map((bus) => ({ ...bus }));
3046
- this.offlineEngine.setTrackBuses(buses);
3047
- if (lanes.length > 0) {
3048
- this.offlineEngine.setTrackLanes(lanes);
3049
- }
3050
- const trackStrips = Array.from(this.trackStripJson, ([trackId, sceneJson]) => ({
3051
- trackId,
3052
- sceneJson,
3053
- }));
3054
- const busStrips = Array.from(this.busStripJson, ([busId, sceneJson]) => ({
3055
- busId,
3056
- sceneJson,
3057
- }));
3058
- this.postSync({
3059
- type: 'syncMixer',
3060
- lanes,
3061
- buses,
3062
- trackStrips,
3063
- laneSidechains: Array.from(this.laneSidechains.values()),
3064
- busStrips,
3065
- masterStripJson: this.masterStripJson,
3066
- });
3067
- }
3068
-
3069
- private syncMarkers(): void {
3070
- const markers = Array.from(this.markers.values()).sort((a, b) => a.ppq - b.ppq);
3071
- this.offlineEngine.setMarkers(markers);
3072
- this.postSync({ type: 'syncMarkers', markers });
3073
- }
3074
-
3075
- private postInstrumentSync(message: SonareEngineInstrumentSyncMessage): void {
3076
- if (this.destroyed) {
3077
- return;
3078
- }
3079
- if (this.transportPlaying) {
3080
- this.pendingInstrumentSync.push(message);
3081
- return;
3082
- }
3083
- this.postSync(message);
3084
- }
3085
-
3086
- private flushPendingInstrumentSync(): void {
3087
- if (this.destroyed || this.pendingInstrumentSync.length === 0) {
3088
- return;
3089
- }
3090
- const pending = this.pendingInstrumentSync.splice(0);
3091
- for (const message of pending) {
3092
- this.postSync(message);
3093
- }
3094
- }
3095
-
3096
- private postTempoSync(): void {
3097
- this.postSync({
3098
- type: 'syncTempo',
3099
- bpm: this.tempoBpm,
3100
- timeSignature: { ...this.timeSignature },
3101
- tempoSegments: this.tempoSegments.map((segment) => ({ ...segment })),
3102
- timeSignatureSegments: this.timeSignatureSegments.map((segment) => ({ ...segment })),
3103
- });
3104
- }
3105
-
3106
- // Posts an out-of-band control-sync message to the worklet engine processor.
3107
- // Sync messages use a string `type` so the worklet's message handler routes
3108
- // them to receiveSync() (numeric `type` is reserved for SonareEngineCommandRecord).
3109
- private postSync(message: SonareEngineSyncMessage): void {
3110
- if (this.destroyed) {
3111
- return;
3112
- }
3113
- this.realtimeNode.node.port.postMessage(message);
3114
- }
3115
-
3116
- private resolveParamId(nodeId: string, param: string | number): number {
3117
- if (typeof param === 'number') {
3118
- return param;
3119
- }
3120
- const byName = this.listParameters().find((info) => info.name === param);
3121
- if (byName) {
3122
- return byName.id;
3123
- }
3124
- return this.resolveTargetId(param || nodeId);
3125
- }
3126
-
3127
- private resolveTargetId(target: string | number): number {
3128
- if (typeof target === 'number') {
3129
- return target;
3130
- }
3131
- const parsed = Number.parseInt(target, 10);
3132
- return Number.isFinite(parsed) ? parsed : 0;
3133
- }
3134
-
3135
- private ensureTrackLane(target: string | number): number {
3136
- const trackId = this.resolveTargetId(target);
3137
- if (!Number.isInteger(trackId) || trackId <= 0) {
3138
- throw new Error(`Invalid track id for mixer lane: ${String(target)}`);
3139
- }
3140
- const existing = this.trackLaneIds.indexOf(trackId);
3141
- if (existing >= 0) {
3142
- return existing;
3143
- }
3144
- this.trackLaneIds.push(trackId);
3145
- this.syncMixer();
3146
- return this.trackLaneIds.length - 1;
3147
- }
3148
-
3149
- private ensureBus(busId: number): number {
3150
- const resolved = Math.trunc(busId);
3151
- if (!Number.isInteger(resolved) || resolved <= 0) {
3152
- throw new Error(`Invalid bus id for mixer bus: ${String(busId)}`);
3153
- }
3154
- const existing = this.buses.findIndex((bus) => bus.busId === resolved);
3155
- if (existing >= 0) {
3156
- return existing;
3157
- }
3158
- this.buses.push({ busId: resolved });
3159
- this.syncMixer();
3160
- return this.buses.length - 1;
3161
- }
3162
-
3163
- private curveCode(curve: number | 'linear' | 'exponential'): number {
3164
- if (typeof curve === 'number') {
3165
- return curve;
3166
- }
3167
- return curve === 'exponential' ? 1 : 0;
3168
- }
3169
- }
3170
-
3171
- export class SonareRealtimeVoiceChangerWorkletProcessor {
3172
- private static warnedMonoOverflow = false;
3173
- private static warnedInterleavedOverflow = false;
3174
- private changer: RealtimeVoiceChanger;
3175
- private readonly sampleRate: number;
3176
- private readonly blockSize: number;
3177
- private readonly channelCount: number;
3178
- // WASM-heap typed-memory views, sized to the worst case (blockSize *
3179
- // channelCount). Acquired on the main thread (constructor) so the
3180
- // audio-thread process() never crosses an allocation boundary.
3181
- private monoInput: Float32Array;
3182
- private monoOutput: Float32Array;
3183
- // Planar heap-backed views (one Float32Array per channel) used by the
3184
- // multi-channel path. AudioWorklet inputs/outputs are already planar
3185
- // Float32Arrays, so this avoids the per-sample interleave/deinterleave
3186
- // passes that the older interleaved path needed.
3187
- private planarChannels: Float32Array[];
3188
- private destroyed = false;
3189
-
3190
- constructor(options: SonareRealtimeVoiceChangerWorkletProcessorOptions = {}) {
3191
- this.sampleRate = options.sampleRate ?? 48000;
3192
- this.blockSize = options.blockSize ?? 128;
3193
- this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 1));
3194
- this.changer = new RealtimeVoiceChanger(options.preset ?? 'neutral-monitor');
3195
- this.changer.prepare(this.sampleRate, this.blockSize, this.channelCount);
3196
- // Acquire WASM-heap views once, sized to the worst case. These are alive
3197
- // for the lifetime of the changer; if the host requests more frames per
3198
- // process() than blockSize, we clamp (see ensure*Capacity).
3199
- this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
3200
- this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
3201
- this.planarChannels = [];
3202
- if (this.channelCount > 1) {
3203
- for (let ch = 0; ch < this.channelCount; ch++) {
3204
- this.planarChannels.push(this.changer.getPlanarChannelBuffer(ch, this.blockSize));
3205
- }
3206
- }
3207
- }
3208
-
3209
- /**
3210
- * Handles a control-plane message from the main thread. Runs on the
3211
- * AudioWorklet global scope but OUTSIDE of `process()` (i.e. outside the
3212
- * realtime audio callback), so it is safe to perform JSON parsing and
3213
- * DSP coefficient recomputation here. `setConfig` MUST NOT be deferred
3214
- * into `process()` because that would block the audio thread for longer
3215
- * than one render quantum (e.g. 128 samples / 44.1 kHz = ~2.9 ms).
3216
- */
3217
- receiveMessage(message: SonareRealtimeVoiceChangerMessage): void {
3218
- if (this.destroyed) {
3219
- return;
3220
- }
3221
- if (message.type === 'setConfig') {
3222
- // Apply synchronously on the message-handler thread. `setConfig` may
3223
- // allocate and parse JSON internally; doing it here keeps `process()`
3224
- // realtime-safe.
3225
- this.changer.setConfig(message.preset);
3226
- } else if (message.type === 'reset') {
3227
- this.changer.reset();
3228
- } else if (message.type === 'destroy') {
3229
- this.destroy();
3230
- }
3231
- }
3232
-
3233
- process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
3234
- const output = outputs[0];
3235
- if (this.destroyed || !output || output.length === 0) {
3236
- return !this.destroyed;
3237
- }
3238
-
3239
- // The cached heap views can detach if WASM linear memory grows (the embind
3240
- // module is built ALLOW_MEMORY_GROWTH). Re-acquire them if detached
3241
- // (byteLength === 0) before touching them; in the common no-growth case this
3242
- // is a cheap branch with no allocation.
3243
- if (this.monoInput.byteLength === 0) {
3244
- this.reacquireBuffers();
3245
- }
3246
-
3247
- const input = inputs[0];
3248
- const requestedFrames = output[0]?.length ?? 0;
3249
- const requestedChannels = Math.min(this.channelCount, output.length);
3250
- if (requestedFrames === 0 || requestedChannels === 0) {
3251
- return true;
3252
- }
3253
-
3254
- if (requestedChannels === 1) {
3255
- // Clamp to the pre-allocated capacity; warn (once) if the host violated
3256
- // the contract. We never reallocate on the audio thread.
3257
- const frames = this.ensureMonoCapacity(requestedFrames);
3258
- const source = input?.[0];
3259
- if (source) {
3260
- this.monoInput.set(source.subarray(0, frames));
3261
- } else {
3262
- this.monoInput.fill(0, 0, frames);
3263
- }
3264
- this.changer.processMonoInto(
3265
- this.monoInput.subarray(0, frames),
3266
- this.monoOutput.subarray(0, frames),
3267
- );
3268
- output[0].set(this.monoOutput.subarray(0, frames));
3269
- return true;
3270
- }
3271
-
3272
- const frames = this.ensureInterleavedCapacity(requestedFrames, requestedChannels);
3273
- const channels = requestedChannels;
3274
- // Planar zero-copy path: AudioWorklet's input[ch] is already a
3275
- // Float32Array per channel, so we set() straight into the heap-backed
3276
- // planar view and processPreparedPlanar runs in place.
3277
- for (let ch = 0; ch < channels; ch++) {
3278
- const src = input?.[ch];
3279
- const dst = this.planarChannels[ch];
3280
- if (!dst) {
3281
- continue;
3282
- }
3283
- if (src) {
3284
- dst.set(src.subarray(0, frames));
3285
- } else {
3286
- dst.fill(0, 0, frames);
3287
- }
3288
- }
3289
- this.changer.processPreparedPlanar(frames);
3290
- for (let ch = 0; ch < channels; ch++) {
3291
- const src = this.planarChannels[ch];
3292
- if (src) {
3293
- output[ch].set(src.subarray(0, frames));
3294
- }
3295
- // No `for frame` inner loop needed; output[ch] is a Float32Array.
3296
- }
3297
- return true;
3298
- }
3299
-
3300
- destroy(): void {
3301
- if (this.destroyed) {
3302
- return;
3303
- }
3304
- this.destroyed = true;
3305
- this.changer.delete();
3306
- }
3307
-
3308
- // Re-acquires the cached WASM-heap views after a memory-growth detachment.
3309
- // The underlying C++ vectors are pre-warmed (ensure_*_capacity ran at prepare
3310
- // time), so getMono*/getPlanar* return fresh views onto the SAME storage
3311
- // without reallocating it.
3312
- private reacquireBuffers(): void {
3313
- this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
3314
- this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
3315
- if (this.channelCount > 1) {
3316
- for (let ch = 0; ch < this.channelCount; ch++) {
3317
- this.planarChannels[ch] = this.changer.getPlanarChannelBuffer(ch, this.blockSize);
3318
- }
3319
- }
3320
- }
3321
-
3322
- /**
3323
- * Returns the number of frames we can actually process given the
3324
- * pre-allocated capacity. If the host requests more frames than the
3325
- * worst-case block size declared at construction time, we clamp to the
3326
- * available capacity and warn once — we MUST NOT reallocate on the
3327
- * realtime audio thread.
3328
- */
3329
- private ensureMonoCapacity(frames: number): number {
3330
- const capacity = this.monoInput.length;
3331
- if (frames <= capacity) {
3332
- return frames;
3333
- }
3334
- if (!SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow) {
3335
- SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow = true;
3336
- // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
3337
- console.warn(
3338
- `SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames} mono frames ` +
3339
- `exceeds pre-allocated capacity ${capacity}; clamping. ` +
3340
- 'Increase blockSize at construction time to avoid this.',
3341
- );
3342
- }
3343
- return capacity;
3344
- }
3345
-
3346
- /**
3347
- * Same contract as ensureMonoCapacity but for the planar per-channel
3348
- * scratch. Returns the number of frames that fit in the available capacity.
3349
- */
3350
- private ensureInterleavedCapacity(frames: number, channels: number): number {
3351
- const capacity = this.planarChannels[0]?.length ?? 0;
3352
- if (frames <= capacity) {
3353
- return frames;
3354
- }
3355
- if (!SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow) {
3356
- SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow = true;
3357
- // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
3358
- console.warn(
3359
- `SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames}x${channels} ` +
3360
- `planar frames exceeds pre-allocated capacity ${capacity}; clamping. ` +
3361
- 'Increase blockSize or channelCount at construction time to avoid this.',
3362
- );
3363
- }
3364
- return capacity;
3365
- }
3366
- }
3367
-
3368
- export function registerSonareWorkletProcessor(name = 'sonare-worklet-processor'): void {
3369
- const scope = globalThis as unknown as {
3370
- AudioWorkletProcessor?: new () => object;
3371
- registerProcessor?: (processorName: string, processorCtor: unknown) => void;
3372
- };
3373
- if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
3374
- throw new Error('AudioWorkletProcessor is not available in this context.');
3375
- }
3376
- const Base = scope.AudioWorkletProcessor;
3377
- class RegisteredSonareWorkletProcessor extends Base {
3378
- private bridge: SonareWorkletProcessor;
3379
- readonly port?: WorkletPort;
3380
-
3381
- constructor(options?: { processorOptions?: SonareWorkletProcessorOptions }) {
3382
- super();
3383
- const port = this.port;
3384
- this.bridge = new SonareWorkletProcessor(options?.processorOptions ?? { sceneJson: '' }, {
3385
- postMessage: (message) => port?.postMessage?.(message),
3386
- });
3387
- const onMessage = (event: { data: unknown }) => {
3388
- if (isWorkletMessage(event.data)) {
3389
- this.bridge.receiveMessage(event.data);
3390
- }
3391
- };
3392
- if (port?.addEventListener) {
3393
- port.addEventListener('message', onMessage);
3394
- port.start?.();
3395
- } else if (port) {
3396
- port.onmessage = onMessage;
3397
- }
3398
- }
3399
-
3400
- process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
3401
- return this.bridge.process(inputs, outputs);
3402
- }
3403
- }
3404
- scope.registerProcessor(name, RegisteredSonareWorkletProcessor);
3405
- }
3406
-
3407
- export function registerSonareRealtimeVoiceChangerWorkletProcessor(
3408
- name = 'sonare-realtime-voice-changer-processor',
3409
- ): void {
3410
- const scope = globalThis as unknown as {
3411
- AudioWorkletProcessor?: new () => object;
3412
- registerProcessor?: (processorName: string, processorCtor: unknown) => void;
3413
- };
3414
- if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
3415
- throw new Error('AudioWorkletProcessor is not available in this context.');
3416
- }
3417
- const Base = scope.AudioWorkletProcessor;
3418
- class RegisteredSonareRealtimeVoiceChangerWorkletProcessor extends Base {
3419
- private bridge: SonareRealtimeVoiceChangerWorkletProcessor;
3420
- readonly port?: WorkletPort;
3421
-
3422
- constructor(options?: {
3423
- processorOptions?: SonareRealtimeVoiceChangerWorkletProcessorOptions;
3424
- }) {
3425
- super();
3426
- const port = this.port;
3427
- this.bridge = new SonareRealtimeVoiceChangerWorkletProcessor(options?.processorOptions ?? {});
3428
- const onMessage = (event: { data: unknown }) => {
3429
- if (isRealtimeVoiceChangerMessage(event.data)) {
3430
- this.bridge.receiveMessage(event.data);
3431
- }
3432
- };
3433
- if (port?.addEventListener) {
3434
- port.addEventListener('message', onMessage);
3435
- port.start?.();
3436
- } else if (port) {
3437
- port.onmessage = onMessage;
3438
- }
3439
- }
3440
-
3441
- process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
3442
- return this.bridge.process(inputs, outputs);
3443
- }
3444
- }
3445
- scope.registerProcessor(name, RegisteredSonareRealtimeVoiceChangerWorkletProcessor);
3446
- }
3447
-
3448
- export function registerSonareRealtimeEngineWorkletProcessor(
3449
- name = 'sonare-realtime-engine-processor',
3450
- ): void {
3451
- const scope = globalThis as unknown as {
3452
- AudioWorkletProcessor?: new () => object;
3453
- registerProcessor?: (processorName: string, processorCtor: unknown) => void;
3454
- };
3455
- if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
3456
- throw new Error('AudioWorkletProcessor is not available in this context.');
3457
- }
3458
- const Base = scope.AudioWorkletProcessor;
3459
- class RegisteredSonareRealtimeEngineWorkletProcessor extends Base {
3460
- private bridge?: SonareRealtimeEngineWorkletProcessor;
3461
- private rtBridge?: SonareRtRealtimeEngineRuntime;
3462
- private readonly pendingMessages: unknown[] = [];
3463
- readonly port?: WorkletPort;
3464
-
3465
- constructor(options?: { processorOptions?: SonareRealtimeEngineWorkletProcessorOptions }) {
3466
- super();
3467
- const port = this.port;
3468
- const processorOptions = options?.processorOptions ?? {};
3469
- if (processorOptions.runtimeTarget === 'sonare-rt') {
3470
- void this.initializeSonareRt(processorOptions, port);
3471
- } else {
3472
- void this.initializeEmbind(processorOptions, port);
3473
- }
3474
- const onMessage = (event: { data: unknown }) => {
3475
- if (!this.bridge && !this.rtBridge) {
3476
- if (this.pendingMessages.length < 1024) {
3477
- this.pendingMessages.push(event.data);
3478
- }
3479
- return;
3480
- }
3481
- if (isEngineCommandRecord(event.data)) {
3482
- this.bridge?.receiveCommand(event.data);
3483
- this.rtBridge?.receiveCommand(event.data);
3484
- } else if (isEngineSyncMessage(event.data)) {
3485
- this.bridge?.receiveSync(event.data);
3486
- this.rtBridge?.receiveSync(event.data);
3487
- } else if (isEngineCaptureRequestMessage(event.data)) {
3488
- this.bridge?.receiveCaptureRequest(event.data);
3489
- this.rtBridge?.receiveCaptureRequest(event.data, port);
3490
- } else if (isEngineTransportRequestMessage(event.data)) {
3491
- this.bridge?.receiveTransportRequest(event.data);
3492
- this.rtBridge?.receiveTransportRequest(event.data, port);
3493
- }
3494
- };
3495
- if (port?.addEventListener) {
3496
- port.addEventListener('message', onMessage);
3497
- port.start?.();
3498
- } else if (port) {
3499
- port.onmessage = onMessage;
3500
- }
3501
- }
3502
-
3503
- process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
3504
- if (this.rtBridge) {
3505
- return this.rtBridge.process(inputs, outputs);
3506
- }
3507
- if (this.bridge) {
3508
- return this.bridge.process(inputs, outputs);
3509
- }
3510
- const output = outputs[0];
3511
- for (const channel of output ?? []) {
3512
- channel.fill(0);
3513
- }
3514
- return true;
3515
- }
3516
-
3517
- private replayPendingMessages(port?: WorkletPort): void {
3518
- const messages = this.pendingMessages.splice(0);
3519
- for (const data of messages) {
3520
- if (isEngineCommandRecord(data)) {
3521
- this.bridge?.receiveCommand(data);
3522
- this.rtBridge?.receiveCommand(data);
3523
- } else if (isEngineSyncMessage(data)) {
3524
- this.bridge?.receiveSync(data);
3525
- this.rtBridge?.receiveSync(data);
3526
- } else if (isEngineCaptureRequestMessage(data)) {
3527
- this.bridge?.receiveCaptureRequest(data);
3528
- this.rtBridge?.receiveCaptureRequest(data, port);
3529
- } else if (isEngineTransportRequestMessage(data)) {
3530
- this.bridge?.receiveTransportRequest(data);
3531
- this.rtBridge?.receiveTransportRequest(data, port);
3532
- }
3533
- }
3534
- }
3535
-
3536
- private async initializeEmbind(
3537
- options: SonareRealtimeEngineWorkletProcessorOptions,
3538
- port?: WorkletPort,
3539
- ): Promise<void> {
3540
- try {
3541
- const initPromise = (
3542
- globalThis as typeof globalThis & { SonareEmbindInitPromise?: Promise<void> }
3543
- ).SonareEmbindInitPromise;
3544
- if (initPromise) {
3545
- await initPromise;
3546
- }
3547
- if (!isInitialized()) {
3548
- type EmbindModuleFactory = (options?: {
3549
- locateFile?: (path: string, prefix: string) => string;
3550
- wasmBinary?: ArrayBuffer | Uint8Array;
3551
- }) => Promise<SonareModule>;
3552
- const moduleFactory = (
3553
- globalThis as typeof globalThis & {
3554
- SonareEmbindModuleFactory?: EmbindModuleFactory;
3555
- }
3556
- ).SonareEmbindModuleFactory;
3557
- if (!moduleFactory) {
3558
- throw new Error('embind realtime engine module is not initialized.');
3559
- }
3560
- await initSonareModule({
3561
- locateFile: (path) => path,
3562
- wasmBinary: options.wasmBinary,
3563
- moduleFactory,
3564
- });
3565
- }
3566
- this.bridge = new SonareRealtimeEngineWorkletProcessor(options, {
3567
- postMessage: (message) => port?.postMessage?.(message),
3568
- onMeter: (meter) => port?.postMessage?.(meter),
3569
- });
3570
- for (const message of options.initialSyncMessages ?? []) {
3571
- this.bridge.receiveSync(message);
3572
- }
3573
- for (const command of options.initialCommands ?? []) {
3574
- this.bridge.receiveCommand(command);
3575
- }
3576
- this.replayPendingMessages(port);
3577
- port?.postMessage?.({ type: 'ready', runtimeTarget: 'embind' });
3578
- } catch (error) {
3579
- port?.postMessage?.({
3580
- type: 'error',
3581
- message: error instanceof Error ? error.message : String(error),
3582
- });
3583
- }
3584
- }
3585
-
3586
- private async initializeSonareRt(
3587
- options: SonareRealtimeEngineWorkletProcessorOptions,
3588
- port?: WorkletPort,
3589
- ): Promise<void> {
3590
- try {
3591
- if (!options.rtModuleUrl) {
3592
- throw new Error('rtModuleUrl is required for sonare-rt AudioWorklet runtime.');
3593
- }
3594
- const rtModuleUrl = options.rtModuleUrl;
3595
- const memory = new WebAssembly.Memory({ initial: 1024, maximum: 1024, shared: true });
3596
- const globalFactory = (
3597
- globalThis as typeof globalThis & {
3598
- SonareRtModuleFactory?: (options?: {
3599
- wasmMemory?: WebAssembly.Memory;
3600
- wasmBinary?: ArrayBuffer | Uint8Array;
3601
- locateFile?: (path: string) => string;
3602
- }) => Promise<SonareRtModule>;
3603
- }
3604
- ).SonareRtModuleFactory;
3605
- const moduleFactory = globalFactory
3606
- ? { default: globalFactory }
3607
- : ((await import(rtModuleUrl)) as {
3608
- default: (options?: {
3609
- wasmMemory?: WebAssembly.Memory;
3610
- wasmBinary?: ArrayBuffer | Uint8Array;
3611
- locateFile?: (path: string) => string;
3612
- }) => Promise<SonareRtModule>;
3613
- });
3614
- const module = await moduleFactory.default({
3615
- wasmMemory: memory,
3616
- wasmBinary: options.rtWasmBinary,
3617
- locateFile: (path) => rtModuleUrl.replace(/[^/]*$/, path),
3618
- });
3619
- this.rtBridge = new SonareRtRealtimeEngineRuntime({
3620
- module,
3621
- memory,
3622
- sampleRate: options.sampleRate,
3623
- blockSize: options.blockSize,
3624
- channelCount: options.channelCount,
3625
- commandSharedBuffer: options.commandSharedBuffer,
3626
- commandRingCapacity: options.commandRingCapacity,
3627
- telemetrySharedBuffer: options.telemetrySharedBuffer,
3628
- telemetryRingCapacity: options.telemetryRingCapacity,
3629
- });
3630
- this.replayPendingMessages(port);
3631
- port?.postMessage?.({ type: 'ready', runtimeTarget: 'sonare-rt' });
3632
- } catch (error) {
3633
- port?.postMessage?.({
3634
- type: 'error',
3635
- message: error instanceof Error ? error.message : String(error),
3636
- });
3637
- }
3638
- }
3639
- }
3640
- scope.registerProcessor(name, RegisteredSonareRealtimeEngineWorkletProcessor);
3641
- }
109
+ export {
110
+ registerSonareRealtimeVoiceChangerWorkletProcessor,
111
+ SonareRealtimeVoiceChangerWorkletProcessor,
112
+ } from './worklet/voice-changer-processor';