@libraz/libsonare 1.0.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/worklet.ts ADDED
@@ -0,0 +1,2140 @@
1
+ import type {
2
+ EngineAutomationPoint,
3
+ EngineClip,
4
+ EngineMarker,
5
+ EngineMeterTelemetry,
6
+ EngineMetronomeConfig,
7
+ EngineParameterInfo,
8
+ EngineTelemetry,
9
+ MixerRealtimeBuffer,
10
+ } from './index';
11
+ import { engineCapabilities, Mixer, RealtimeEngine } from './index';
12
+ import type { AutomationCurve } from './public_types';
13
+ import type { SonareRtModule } from './sonare-rt';
14
+
15
+ // With code-splitting disabled, the worklet bundle carries its own copy of the
16
+ // module singleton (a real AudioWorkletGlobalScope cannot resolve sibling
17
+ // chunks, so the bundle must be self-contained). Re-export the lifecycle so that
18
+ // realm can initialize its own wasm instance, independent of the main-thread
19
+ // `index` module.
20
+ export { init, isInitialized } from './index';
21
+
22
+ export interface SonareWorkletProcessorOptions {
23
+ sceneJson: string;
24
+ sampleRate?: number;
25
+ blockSize?: number;
26
+ stripCount?: number;
27
+ meterIntervalFrames?: number;
28
+ meterSharedBuffer?: SharedArrayBuffer;
29
+ meterRingCapacity?: number;
30
+ spectrumIntervalFrames?: number;
31
+ spectrumBands?: number;
32
+ spectrumSharedBuffer?: SharedArrayBuffer;
33
+ spectrumRingCapacity?: number;
34
+ }
35
+
36
+ export interface SonareRealtimeEngineWorkletProcessorOptions {
37
+ runtimeTarget?: 'embind' | 'sonare-rt';
38
+ rtModuleUrl?: string;
39
+ rtWasmBinary?: ArrayBuffer | Uint8Array;
40
+ sampleRate?: number;
41
+ blockSize?: number;
42
+ channelCount?: number;
43
+ meterIntervalFrames?: number;
44
+ commandSharedBuffer?: SharedArrayBuffer;
45
+ commandRingCapacity?: number;
46
+ telemetrySharedBuffer?: SharedArrayBuffer;
47
+ telemetryRingCapacity?: number;
48
+ }
49
+
50
+ export interface SonareRealtimeEngineNodeCapabilities {
51
+ mode: 'sab' | 'postMessage';
52
+ runtimeTarget: 'embind' | 'sonare-rt';
53
+ sharedArrayBuffer: boolean;
54
+ atomics: boolean;
55
+ audioWorklet: boolean;
56
+ engineAbiVersion?: number;
57
+ expectedEngineAbiVersion?: number;
58
+ abiCompatible?: boolean;
59
+ degradedReason?: string;
60
+ }
61
+
62
+ export interface SonareRealtimeEngineNodeOptions
63
+ extends SonareRealtimeEngineWorkletProcessorOptions {
64
+ processorName?: string;
65
+ moduleUrl?: string | URL;
66
+ rtModuleUrl?: string;
67
+ mode?: 'auto' | 'sab' | 'postMessage';
68
+ engineAbiVersion?: number;
69
+ expectedEngineAbiVersion?: number;
70
+ requireAbiCompatible?: boolean;
71
+ nodeFactory?: (
72
+ context: BaseAudioContext,
73
+ processorName: string,
74
+ options: AudioWorkletNodeOptions,
75
+ ) => AudioWorkletNode;
76
+ }
77
+
78
+ export interface SonareRtRealtimeEngineRuntimeOptions {
79
+ module: SonareRtModule;
80
+ memory: WebAssembly.Memory;
81
+ sampleRate?: number;
82
+ blockSize?: number;
83
+ channelCount?: number;
84
+ commandSharedBuffer?: SharedArrayBuffer;
85
+ commandRingCapacity?: number;
86
+ telemetrySharedBuffer?: SharedArrayBuffer;
87
+ telemetryRingCapacity?: number;
88
+ }
89
+
90
+ export interface SonareEngineOptions extends SonareRealtimeEngineNodeOptions {
91
+ offlineEngine?: RealtimeEngine;
92
+ offlineBlockSize?: number;
93
+ offlineChannelCount?: number;
94
+ }
95
+
96
+ export interface SonareEngineTransportFacade {
97
+ play(sampleTime?: number): boolean;
98
+ stop(sampleTime?: number): boolean;
99
+ seekPpq(ppq: number, sampleTime?: number): boolean;
100
+ seekSeconds(seconds: number, sampleTime?: number): boolean;
101
+ setTempo(bpm: number): void;
102
+ setLoop(startPpq: number, endPpq: number, enabled?: boolean): boolean;
103
+ }
104
+
105
+ type SuspendableAudioContext = BaseAudioContext & {
106
+ suspend?: () => Promise<void>;
107
+ resume?: () => Promise<void>;
108
+ };
109
+
110
+ type WorkletInput = readonly (readonly Float32Array[])[];
111
+ type WorkletOutput = Float32Array[][];
112
+
113
+ export interface SonareWorkletScheduleInsertAutomationMessage {
114
+ type: 'scheduleInsertAutomation';
115
+ stripIndex: number;
116
+ insertIndex: number;
117
+ paramId: number;
118
+ value: number;
119
+ samplePos?: number;
120
+ curve?: AutomationCurve;
121
+ }
122
+
123
+ export interface SonareWorkletSetMeterIntervalMessage {
124
+ type: 'setMeterInterval';
125
+ frames: number;
126
+ }
127
+
128
+ export interface SonareWorkletDestroyMessage {
129
+ type: 'destroy';
130
+ }
131
+
132
+ export type SonareWorkletMessage =
133
+ | SonareWorkletScheduleInsertAutomationMessage
134
+ | SonareWorkletSetMeterIntervalMessage
135
+ | SonareWorkletDestroyMessage;
136
+
137
+ export interface SonareWorkletMeterSnapshot {
138
+ type: 'meter';
139
+ frame: number;
140
+ peakDbL: number;
141
+ peakDbR: number;
142
+ rmsDbL: number;
143
+ rmsDbR: number;
144
+ correlation: number;
145
+ }
146
+
147
+ export interface SonareWorkletSpectrumSnapshot {
148
+ type: 'spectrum';
149
+ frame: number;
150
+ bands: Float32Array;
151
+ }
152
+
153
+ export type SonareWorkletTransportMessage =
154
+ | SonareWorkletMeterSnapshot
155
+ | SonareWorkletSpectrumSnapshot
156
+ | SonareEngineTelemetryRecord;
157
+
158
+ export const SONARE_METER_RING_HEADER_INTS = 4;
159
+ export const SONARE_METER_RING_RECORD_FLOATS = 6;
160
+ export const SONARE_SPECTRUM_RING_HEADER_INTS = 5;
161
+ export const SONARE_ENGINE_RING_HEADER_INTS = 5;
162
+ export const SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
163
+ export const SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
164
+
165
+ export enum SonareEngineCommandType {
166
+ SetParam = 0,
167
+ SetParamSmoothed = 1,
168
+ TransportPlay = 2,
169
+ TransportStop = 3,
170
+ TransportSeekSample = 4,
171
+ TransportSeekPpq = 5,
172
+ SetTempoMap = 6,
173
+ SetLoop = 7,
174
+ SwapGraph = 8,
175
+ SwapAutomation = 9,
176
+ SetSoloMute = 10,
177
+ AddClip = 11,
178
+ RemoveClip = 12,
179
+ ArmRecord = 13,
180
+ Punch = 14,
181
+ SetMetronome = 15,
182
+ SetMarker = 16,
183
+ SeekMarker = 17,
184
+ }
185
+
186
+ export enum SonareEngineTelemetryType {
187
+ ProcessBlock = 0,
188
+ Error = 1,
189
+ }
190
+
191
+ export enum SonareEngineTelemetryError {
192
+ None = 0,
193
+ CommandQueueOverflow = 1,
194
+ PendingCommandOverflow = 2,
195
+ BoundaryOverflow = 3,
196
+ TelemetryOverflow = 4,
197
+ CaptureOverflow = 5,
198
+ MaxBlockExceeded = 6,
199
+ UnknownTarget = 7,
200
+ NonRealtimeSafeParameter = 8,
201
+ NotPrepared = 9,
202
+ NonQueueableCommand = 10,
203
+ AutomationBindTargetOverflow = 11,
204
+ StaleAutomationLanes = 12,
205
+ SmoothedParameterCapacity = 13,
206
+ }
207
+
208
+ interface WorkletTransport {
209
+ postMessage?: (message: SonareWorkletTransportMessage) => void;
210
+ onMeter?: (meter: SonareWorkletMeterSnapshot) => void;
211
+ onSpectrum?: (spectrum: SonareWorkletSpectrumSnapshot) => void;
212
+ }
213
+
214
+ export interface SonareMeterRingBuffer {
215
+ sharedBuffer: SharedArrayBuffer;
216
+ header: Int32Array;
217
+ records: Float32Array;
218
+ capacity: number;
219
+ }
220
+
221
+ export interface SonareMeterRingReadResult {
222
+ nextReadIndex: number;
223
+ meters: SonareWorkletMeterSnapshot[];
224
+ }
225
+
226
+ export interface SonareSpectrumRingBuffer {
227
+ sharedBuffer: SharedArrayBuffer;
228
+ header: Int32Array;
229
+ records: Float32Array;
230
+ capacity: number;
231
+ bands: number;
232
+ }
233
+
234
+ export interface SonareSpectrumRingReadResult {
235
+ nextReadIndex: number;
236
+ spectra: SonareWorkletSpectrumSnapshot[];
237
+ }
238
+
239
+ export interface SonareEngineCommandRecord {
240
+ type: SonareEngineCommandType | number;
241
+ targetId?: number;
242
+ sampleTime?: number | bigint;
243
+ argFloat?: number;
244
+ argInt?: number | bigint;
245
+ }
246
+
247
+ export interface SonareEngineTelemetryRecord {
248
+ type: SonareEngineTelemetryType | number;
249
+ error: SonareEngineTelemetryError | number;
250
+ renderFrame: number;
251
+ timelineSample: number;
252
+ audibleTimelineSample: number;
253
+ graphLatencySamplesQ8: number;
254
+ value: number;
255
+ }
256
+
257
+ export interface SonareEngineCommandRingBuffer {
258
+ sharedBuffer: SharedArrayBuffer;
259
+ header: Int32Array;
260
+ view: DataView;
261
+ capacity: number;
262
+ }
263
+
264
+ export interface SonareEngineTelemetryRingBuffer {
265
+ sharedBuffer: SharedArrayBuffer;
266
+ header: Int32Array;
267
+ view: DataView;
268
+ capacity: number;
269
+ }
270
+
271
+ export interface SonareEngineTelemetryRingReadResult {
272
+ nextReadIndex: number;
273
+ telemetry: SonareEngineTelemetryRecord[];
274
+ }
275
+
276
+ interface SharedMeterRingWriter {
277
+ header: Int32Array;
278
+ records: Float32Array;
279
+ capacity: number;
280
+ }
281
+
282
+ interface SharedSpectrumRingWriter {
283
+ header: Int32Array;
284
+ records: Float32Array;
285
+ capacity: number;
286
+ bands: number;
287
+ recordFloats: number;
288
+ }
289
+
290
+ interface WorkletPort {
291
+ postMessage?: (message: unknown) => void;
292
+ onmessage?: (event: { data: unknown }) => void;
293
+ addEventListener?: (type: 'message', listener: (event: { data: unknown }) => void) => void;
294
+ start?: () => void;
295
+ }
296
+
297
+ function toDb(value: number): number {
298
+ return value > 0 ? 20 * Math.log10(value) : Number.NEGATIVE_INFINITY;
299
+ }
300
+
301
+ function isRecord(value: unknown): value is Record<string, unknown> {
302
+ return typeof value === 'object' && value !== null;
303
+ }
304
+
305
+ function isWorkletMessage(value: unknown): value is SonareWorkletMessage {
306
+ if (!isRecord(value) || typeof value.type !== 'string') {
307
+ return false;
308
+ }
309
+ return (
310
+ value.type === 'scheduleInsertAutomation' ||
311
+ value.type === 'setMeterInterval' ||
312
+ value.type === 'destroy'
313
+ );
314
+ }
315
+
316
+ function isEngineCommandRecord(value: unknown): value is SonareEngineCommandRecord {
317
+ return isRecord(value) && typeof value.type === 'number';
318
+ }
319
+
320
+ function isEngineTelemetryRecord(value: unknown): value is SonareEngineTelemetryRecord {
321
+ return (
322
+ isRecord(value) &&
323
+ typeof value.type === 'number' &&
324
+ typeof value.error === 'number' &&
325
+ typeof value.renderFrame === 'number' &&
326
+ typeof value.timelineSample === 'number' &&
327
+ typeof value.audibleTimelineSample === 'number' &&
328
+ typeof value.graphLatencySamplesQ8 === 'number' &&
329
+ typeof value.value === 'number'
330
+ );
331
+ }
332
+
333
+ function isMeterSnapshot(value: unknown): value is SonareWorkletMeterSnapshot {
334
+ return (
335
+ isRecord(value) &&
336
+ value.type === 'meter' &&
337
+ typeof value.frame === 'number' &&
338
+ typeof value.peakDbL === 'number' &&
339
+ typeof value.peakDbR === 'number' &&
340
+ typeof value.rmsDbL === 'number' &&
341
+ typeof value.rmsDbR === 'number' &&
342
+ typeof value.correlation === 'number'
343
+ );
344
+ }
345
+
346
+ export function sonareMeterRingBufferByteLength(capacity: number): number {
347
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
348
+ return (
349
+ SONARE_METER_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
350
+ clampedCapacity * SONARE_METER_RING_RECORD_FLOATS * Float32Array.BYTES_PER_ELEMENT
351
+ );
352
+ }
353
+
354
+ export function createSonareMeterRingBuffer(capacity = 128): SonareMeterRingBuffer {
355
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
356
+ const sharedBuffer = new SharedArrayBuffer(sonareMeterRingBufferByteLength(clampedCapacity));
357
+ const ring = meterRingFromSharedBuffer(sharedBuffer, clampedCapacity);
358
+ Atomics.store(ring.header, 0, 0);
359
+ Atomics.store(ring.header, 1, clampedCapacity);
360
+ Atomics.store(ring.header, 2, SONARE_METER_RING_RECORD_FLOATS);
361
+ Atomics.store(ring.header, 3, 0);
362
+ return { sharedBuffer, header: ring.header, records: ring.records, capacity: ring.capacity };
363
+ }
364
+
365
+ export function readSonareMeterRingBuffer(
366
+ ring: SonareMeterRingBuffer,
367
+ readIndex = 0,
368
+ ): SonareMeterRingReadResult {
369
+ const writeIndex = Atomics.load(ring.header, 0);
370
+ const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
371
+ const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
372
+ const meters: SonareWorkletMeterSnapshot[] = [];
373
+ for (let index = firstReadable; index < writeIndex; index++) {
374
+ const offset = (index % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
375
+ meters.push({
376
+ type: 'meter',
377
+ frame: ring.records[offset],
378
+ peakDbL: ring.records[offset + 1],
379
+ peakDbR: ring.records[offset + 2],
380
+ rmsDbL: ring.records[offset + 3],
381
+ rmsDbR: ring.records[offset + 4],
382
+ correlation: ring.records[offset + 5],
383
+ });
384
+ }
385
+ return { nextReadIndex: writeIndex, meters };
386
+ }
387
+
388
+ export function sonareSpectrumRingBufferByteLength(capacity: number, bands = 16): number {
389
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
390
+ const clampedBands = Math.max(1, Math.floor(bands));
391
+ return (
392
+ SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
393
+ clampedCapacity * (2 + clampedBands) * Float32Array.BYTES_PER_ELEMENT
394
+ );
395
+ }
396
+
397
+ export function createSonareSpectrumRingBuffer(
398
+ capacity = 128,
399
+ bands = 16,
400
+ ): SonareSpectrumRingBuffer {
401
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
402
+ const clampedBands = Math.max(1, Math.floor(bands));
403
+ const sharedBuffer = new SharedArrayBuffer(
404
+ sonareSpectrumRingBufferByteLength(clampedCapacity, clampedBands),
405
+ );
406
+ const ring = spectrumRingFromSharedBuffer(sharedBuffer, clampedCapacity, clampedBands);
407
+ Atomics.store(ring.header, 0, 0);
408
+ Atomics.store(ring.header, 1, clampedCapacity);
409
+ Atomics.store(ring.header, 2, ring.recordFloats);
410
+ Atomics.store(ring.header, 3, clampedBands);
411
+ Atomics.store(ring.header, 4, 0);
412
+ return {
413
+ sharedBuffer,
414
+ header: ring.header,
415
+ records: ring.records,
416
+ capacity: ring.capacity,
417
+ bands: ring.bands,
418
+ };
419
+ }
420
+
421
+ export function readSonareSpectrumRingBuffer(
422
+ ring: SonareSpectrumRingBuffer,
423
+ readIndex = 0,
424
+ ): SonareSpectrumRingReadResult {
425
+ const writeIndex = Atomics.load(ring.header, 0);
426
+ const recordFloats = Atomics.load(ring.header, 2) || 2 + ring.bands;
427
+ const bands = Atomics.load(ring.header, 3) || ring.bands;
428
+ const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
429
+ const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
430
+ const spectra: SonareWorkletSpectrumSnapshot[] = [];
431
+ for (let index = firstReadable; index < writeIndex; index++) {
432
+ const offset = (index % ring.capacity) * recordFloats;
433
+ const values = new Float32Array(bands);
434
+ values.set(ring.records.subarray(offset + 2, offset + 2 + bands));
435
+ spectra.push({ type: 'spectrum', frame: ring.records[offset], bands: values });
436
+ }
437
+ return { nextReadIndex: writeIndex, spectra };
438
+ }
439
+
440
+ export function sonareEngineCommandRingBufferByteLength(capacity: number): number {
441
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
442
+ return (
443
+ SONARE_ENGINE_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
444
+ clampedCapacity * SONARE_ENGINE_COMMAND_RECORD_BYTES
445
+ );
446
+ }
447
+
448
+ export function sonareEngineTelemetryRingBufferByteLength(capacity: number): number {
449
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
450
+ return (
451
+ SONARE_ENGINE_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
452
+ clampedCapacity * SONARE_ENGINE_TELEMETRY_RECORD_BYTES
453
+ );
454
+ }
455
+
456
+ export function createSonareEngineCommandRingBuffer(capacity = 128): SonareEngineCommandRingBuffer {
457
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
458
+ const sharedBuffer = new SharedArrayBuffer(
459
+ sonareEngineCommandRingBufferByteLength(clampedCapacity),
460
+ );
461
+ const ring = engineRingFromSharedBuffer(
462
+ sharedBuffer,
463
+ SONARE_ENGINE_COMMAND_RECORD_BYTES,
464
+ clampedCapacity,
465
+ );
466
+ return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
467
+ }
468
+
469
+ export function createSonareEngineTelemetryRingBuffer(
470
+ capacity = 128,
471
+ ): SonareEngineTelemetryRingBuffer {
472
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
473
+ const sharedBuffer = new SharedArrayBuffer(
474
+ sonareEngineTelemetryRingBufferByteLength(clampedCapacity),
475
+ );
476
+ const ring = engineRingFromSharedBuffer(
477
+ sharedBuffer,
478
+ SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
479
+ clampedCapacity,
480
+ );
481
+ return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
482
+ }
483
+
484
+ export function pushSonareEngineCommandRingBuffer(
485
+ ring: SonareEngineCommandRingBuffer,
486
+ command: SonareEngineCommandRecord,
487
+ ): boolean {
488
+ const writeIndex = Atomics.load(ring.header, 0);
489
+ const readIndex = Atomics.load(ring.header, 1);
490
+ if (writeIndex - readIndex >= ring.capacity) {
491
+ Atomics.add(ring.header, 4, 1);
492
+ return false;
493
+ }
494
+ writeEngineCommandRecord(
495
+ ring.view,
496
+ recordOffset(writeIndex, ring.capacity, SONARE_ENGINE_COMMAND_RECORD_BYTES),
497
+ command,
498
+ );
499
+ Atomics.store(ring.header, 0, writeIndex + 1);
500
+ return true;
501
+ }
502
+
503
+ export function popSonareEngineCommandRingBuffer(
504
+ ring: SonareEngineCommandRingBuffer,
505
+ ): SonareEngineCommandRecord | null {
506
+ const readIndex = Atomics.load(ring.header, 1);
507
+ const writeIndex = Atomics.load(ring.header, 0);
508
+ if (readIndex >= writeIndex) {
509
+ return null;
510
+ }
511
+ const command = readEngineCommandRecord(
512
+ ring.view,
513
+ recordOffset(readIndex, ring.capacity, SONARE_ENGINE_COMMAND_RECORD_BYTES),
514
+ );
515
+ Atomics.store(ring.header, 1, readIndex + 1);
516
+ return command;
517
+ }
518
+
519
+ export function writeSonareEngineTelemetryRingBuffer(
520
+ ring: SonareEngineTelemetryRingBuffer,
521
+ telemetry: SonareEngineTelemetryRecord,
522
+ ): void {
523
+ const writeIndex = Atomics.load(ring.header, 0);
524
+ writeEngineTelemetryRecord(
525
+ ring.view,
526
+ recordOffset(writeIndex, ring.capacity, SONARE_ENGINE_TELEMETRY_RECORD_BYTES),
527
+ telemetry,
528
+ );
529
+ Atomics.store(ring.header, 0, writeIndex + 1);
530
+ if (writeIndex + 1 > ring.capacity) {
531
+ Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
532
+ }
533
+ }
534
+
535
+ export function readSonareEngineTelemetryRingBuffer(
536
+ ring: SonareEngineTelemetryRingBuffer,
537
+ readIndex = 0,
538
+ ): SonareEngineTelemetryRingReadResult {
539
+ const writeIndex = Atomics.load(ring.header, 0);
540
+ const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
541
+ const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
542
+ const telemetry: SonareEngineTelemetryRecord[] = [];
543
+ for (let index = firstReadable; index < writeIndex; index++) {
544
+ telemetry.push(
545
+ readEngineTelemetryRecord(
546
+ ring.view,
547
+ recordOffset(index, ring.capacity, SONARE_ENGINE_TELEMETRY_RECORD_BYTES),
548
+ ),
549
+ );
550
+ }
551
+ return { nextReadIndex: writeIndex, telemetry };
552
+ }
553
+
554
+ function meterRingFromSharedBuffer(
555
+ sharedBuffer: SharedArrayBuffer,
556
+ fallbackCapacity?: number,
557
+ ): SharedMeterRingWriter {
558
+ const headerBytes = SONARE_METER_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
559
+ const header = new Int32Array(sharedBuffer, 0, SONARE_METER_RING_HEADER_INTS);
560
+ const existingCapacity = Atomics.load(header, 1);
561
+ const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
562
+ const minBytes = sonareMeterRingBufferByteLength(capacity);
563
+ if (sharedBuffer.byteLength < minBytes) {
564
+ throw new Error('meterSharedBuffer is too small for the requested ring capacity.');
565
+ }
566
+ Atomics.store(header, 1, capacity);
567
+ Atomics.store(header, 2, SONARE_METER_RING_RECORD_FLOATS);
568
+ return {
569
+ header,
570
+ records: new Float32Array(
571
+ sharedBuffer,
572
+ headerBytes,
573
+ capacity * SONARE_METER_RING_RECORD_FLOATS,
574
+ ),
575
+ capacity,
576
+ };
577
+ }
578
+
579
+ function spectrumRingFromSharedBuffer(
580
+ sharedBuffer: SharedArrayBuffer,
581
+ fallbackCapacity?: number,
582
+ fallbackBands?: number,
583
+ ): SharedSpectrumRingWriter {
584
+ const headerBytes = SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
585
+ const header = new Int32Array(sharedBuffer, 0, SONARE_SPECTRUM_RING_HEADER_INTS);
586
+ const existingCapacity = Atomics.load(header, 1);
587
+ const existingBands = Atomics.load(header, 3);
588
+ const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
589
+ const bands = Math.max(1, Math.floor(existingBands || fallbackBands || 16));
590
+ const recordFloats = 2 + bands;
591
+ const minBytes = sonareSpectrumRingBufferByteLength(capacity, bands);
592
+ if (sharedBuffer.byteLength < minBytes) {
593
+ throw new Error('spectrumSharedBuffer is too small for the requested ring capacity.');
594
+ }
595
+ Atomics.store(header, 1, capacity);
596
+ Atomics.store(header, 2, recordFloats);
597
+ Atomics.store(header, 3, bands);
598
+ return {
599
+ header,
600
+ records: new Float32Array(sharedBuffer, headerBytes, capacity * recordFloats),
601
+ capacity,
602
+ bands,
603
+ recordFloats,
604
+ };
605
+ }
606
+
607
+ function engineRingFromSharedBuffer(
608
+ sharedBuffer: SharedArrayBuffer,
609
+ recordBytes: number,
610
+ fallbackCapacity?: number,
611
+ ): { header: Int32Array; view: DataView; capacity: number } {
612
+ const headerBytes = SONARE_ENGINE_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
613
+ const header = new Int32Array(sharedBuffer, 0, SONARE_ENGINE_RING_HEADER_INTS);
614
+ const existingCapacity = Atomics.load(header, 2);
615
+ const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
616
+ const minBytes = headerBytes + capacity * recordBytes;
617
+ if (sharedBuffer.byteLength < minBytes) {
618
+ throw new Error('engine SharedArrayBuffer is too small for the requested ring capacity.');
619
+ }
620
+ Atomics.store(header, 2, capacity);
621
+ Atomics.store(header, 3, recordBytes);
622
+ return {
623
+ header,
624
+ view: new DataView(sharedBuffer, headerBytes, capacity * recordBytes),
625
+ capacity,
626
+ };
627
+ }
628
+
629
+ function recordOffset(index: number, capacity: number, recordBytes: number): number {
630
+ return (index % capacity) * recordBytes;
631
+ }
632
+
633
+ function toBigInt64(value: number | bigint | undefined, fallback: bigint): bigint {
634
+ if (typeof value === 'bigint') {
635
+ return value;
636
+ }
637
+ if (typeof value === 'number') {
638
+ return BigInt(Math.trunc(value));
639
+ }
640
+ return fallback;
641
+ }
642
+
643
+ function writeEngineCommandRecord(
644
+ view: DataView,
645
+ offset: number,
646
+ command: SonareEngineCommandRecord,
647
+ ): void {
648
+ view.setUint32(offset, command.type, true);
649
+ view.setUint32(offset + 4, command.targetId ?? 0, true);
650
+ view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
651
+ view.setFloat32(offset + 16, command.argFloat ?? 0, true);
652
+ view.setUint32(offset + 20, 0, true);
653
+ view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
654
+ }
655
+
656
+ function readEngineCommandRecord(view: DataView, offset: number): SonareEngineCommandRecord {
657
+ return {
658
+ type: view.getUint32(offset, true),
659
+ targetId: view.getUint32(offset + 4, true),
660
+ sampleTime: Number(view.getBigInt64(offset + 8, true)),
661
+ argFloat: view.getFloat32(offset + 16, true),
662
+ argInt: Number(view.getBigInt64(offset + 24, true)),
663
+ };
664
+ }
665
+
666
+ function writeEngineTelemetryRecord(
667
+ view: DataView,
668
+ offset: number,
669
+ telemetry: SonareEngineTelemetryRecord,
670
+ ): void {
671
+ view.setUint32(offset, telemetry.type, true);
672
+ view.setUint32(offset + 4, telemetry.error, true);
673
+ view.setBigInt64(offset + 8, BigInt(Math.trunc(telemetry.renderFrame)), true);
674
+ view.setBigInt64(offset + 16, BigInt(Math.trunc(telemetry.timelineSample)), true);
675
+ view.setBigInt64(offset + 24, BigInt(Math.trunc(telemetry.audibleTimelineSample)), true);
676
+ view.setInt32(offset + 32, telemetry.graphLatencySamplesQ8, true);
677
+ view.setUint32(offset + 36, telemetry.value, true);
678
+ view.setBigInt64(offset + 40, 0n, true);
679
+ }
680
+
681
+ function readEngineTelemetryRecord(view: DataView, offset: number): SonareEngineTelemetryRecord {
682
+ return {
683
+ type: view.getUint32(offset, true),
684
+ error: view.getUint32(offset + 4, true),
685
+ renderFrame: Number(view.getBigInt64(offset + 8, true)),
686
+ timelineSample: Number(view.getBigInt64(offset + 16, true)),
687
+ audibleTimelineSample: Number(view.getBigInt64(offset + 24, true)),
688
+ graphLatencySamplesQ8: view.getInt32(offset + 32, true),
689
+ value: view.getUint32(offset + 36, true),
690
+ };
691
+ }
692
+
693
+ function telemetryFromEngine(telemetry: EngineTelemetry): SonareEngineTelemetryRecord {
694
+ return {
695
+ type: telemetry.type,
696
+ error: telemetry.error,
697
+ renderFrame: telemetry.renderFrame,
698
+ timelineSample: telemetry.timelineSample,
699
+ audibleTimelineSample: telemetry.audibleTimelineSample,
700
+ graphLatencySamplesQ8: telemetry.graphLatencySamplesQ8,
701
+ value: telemetry.value,
702
+ };
703
+ }
704
+
705
+ function meterFromEngine(meter: EngineMeterTelemetry): SonareWorkletMeterSnapshot {
706
+ return {
707
+ type: 'meter',
708
+ frame: meter.renderFrame,
709
+ peakDbL: meter.peakDbL,
710
+ peakDbR: meter.peakDbR,
711
+ rmsDbL: meter.rmsDbL,
712
+ rmsDbR: meter.rmsDbR,
713
+ correlation: meter.correlation,
714
+ };
715
+ }
716
+
717
+ function magnitudeToDb(value: number): number {
718
+ return value > 1.0e-12 ? 20 * Math.log10(value) : -120;
719
+ }
720
+
721
+ /**
722
+ * AudioWorklet-style mixer bridge backed by the package's single `sonare.wasm`.
723
+ *
724
+ * The WASM module must already be initialized via `init()` before constructing
725
+ * this bridge. Each AudioWorklet input is treated as one stereo strip:
726
+ * `inputs[strip][0]` is left and `inputs[strip][1]` is right. Missing channels
727
+ * are replaced with preallocated silence.
728
+ */
729
+ export class SonareWorkletProcessor {
730
+ readonly sampleRate: number;
731
+ readonly blockSize: number;
732
+ private mixer: Mixer;
733
+ private realtime: MixerRealtimeBuffer;
734
+ private closed = false;
735
+ private processedFrames = 0;
736
+ private lastMeterFrame = 0;
737
+ private meterIntervalFrames: number;
738
+ private spectrumIntervalFrames: number;
739
+ private lastSpectrumFrame = 0;
740
+ private transport?: WorkletTransport;
741
+ private meterRing?: SharedMeterRingWriter;
742
+ private spectrumRing?: SharedSpectrumRingWriter;
743
+ private spectrumBands: Float32Array;
744
+
745
+ constructor(options: SonareWorkletProcessorOptions, transport?: WorkletTransport) {
746
+ if (!options.sceneJson) {
747
+ throw new Error('sceneJson is required.');
748
+ }
749
+ this.sampleRate = options.sampleRate ?? 48000;
750
+ this.blockSize = options.blockSize ?? 128;
751
+ this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
752
+ this.spectrumIntervalFrames = Math.max(0, Math.floor(options.spectrumIntervalFrames ?? 0));
753
+ this.transport = transport;
754
+ this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
755
+ this.meterRing = options.meterSharedBuffer
756
+ ? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
757
+ : undefined;
758
+ this.spectrumRing = options.spectrumSharedBuffer
759
+ ? spectrumRingFromSharedBuffer(
760
+ options.spectrumSharedBuffer,
761
+ options.spectrumRingCapacity,
762
+ options.spectrumBands,
763
+ )
764
+ : undefined;
765
+ const spectrumBandCount = this.spectrumRing?.bands ?? Math.max(1, options.spectrumBands ?? 16);
766
+ this.spectrumBands = new Float32Array(spectrumBandCount);
767
+ this.mixer = Mixer.fromSceneJson(options.sceneJson, this.sampleRate, this.blockSize);
768
+ this.mixer.compile();
769
+ const sceneStripCount = this.mixer.stripCount();
770
+ const stripCount = options.stripCount ?? sceneStripCount;
771
+ if (stripCount !== sceneStripCount) {
772
+ throw new Error('stripCount must match the scene strip count.');
773
+ }
774
+ this.realtime = this.mixer.createRealtimeBuffer();
775
+ }
776
+
777
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
778
+ if (this.closed) {
779
+ return false;
780
+ }
781
+ const output = outputs[0];
782
+ const leftOut = output?.[0];
783
+ const rightOut = output?.[1];
784
+ if (!leftOut) {
785
+ return true;
786
+ }
787
+ const frames = leftOut.length;
788
+ if (frames !== this.blockSize) {
789
+ return false;
790
+ }
791
+
792
+ for (let strip = 0; strip < this.realtime.leftInputs.length; strip++) {
793
+ const input = inputs[strip];
794
+ const left = input?.[0];
795
+ const right = input?.[1];
796
+ const leftTarget = this.realtime.leftInputs[strip];
797
+ const rightTarget = this.realtime.rightInputs[strip];
798
+ if (left && left.length === frames) {
799
+ leftTarget.set(left);
800
+ if (right && right.length === frames) {
801
+ rightTarget.set(right);
802
+ } else {
803
+ rightTarget.set(left);
804
+ }
805
+ } else {
806
+ leftTarget.fill(0);
807
+ rightTarget.fill(0);
808
+ }
809
+ }
810
+
811
+ this.realtime.process(frames);
812
+ leftOut.set(this.realtime.outLeft);
813
+ if (rightOut) {
814
+ rightOut.set(this.realtime.outRight);
815
+ }
816
+ this.processedFrames += frames;
817
+ this.publishMeter(this.realtime.outLeft, this.realtime.outRight);
818
+ this.publishSpectrum(this.realtime.outLeft, this.realtime.outRight);
819
+ return true;
820
+ }
821
+
822
+ receiveMessage(message: SonareWorkletMessage): void {
823
+ if (this.closed) {
824
+ return;
825
+ }
826
+ if (message.type === 'destroy') {
827
+ this.destroy();
828
+ return;
829
+ }
830
+ if (message.type === 'setMeterInterval') {
831
+ this.meterIntervalFrames = Math.max(0, Math.floor(message.frames));
832
+ return;
833
+ }
834
+ if (message.type === 'scheduleInsertAutomation') {
835
+ this.mixer.scheduleInsertAutomation(
836
+ message.stripIndex,
837
+ message.insertIndex,
838
+ message.paramId,
839
+ message.samplePos ?? this.processedFrames,
840
+ message.value,
841
+ message.curve ?? 'linear',
842
+ );
843
+ }
844
+ }
845
+
846
+ destroy(): void {
847
+ if (!this.closed) {
848
+ this.mixer.delete();
849
+ this.closed = true;
850
+ }
851
+ }
852
+
853
+ private publishMeter(left: Float32Array, right: Float32Array): void {
854
+ if (!this.transport || this.meterIntervalFrames <= 0) {
855
+ return;
856
+ }
857
+ if (this.processedFrames - this.lastMeterFrame < this.meterIntervalFrames) {
858
+ return;
859
+ }
860
+ this.lastMeterFrame = this.processedFrames;
861
+
862
+ let peakL = 0;
863
+ let peakR = 0;
864
+ let sumL = 0;
865
+ let sumR = 0;
866
+ let sumLR = 0;
867
+ for (let i = 0; i < left.length; i++) {
868
+ const l = left[i] ?? 0;
869
+ const r = right[i] ?? 0;
870
+ const absL = Math.abs(l);
871
+ const absR = Math.abs(r);
872
+ if (absL > peakL) {
873
+ peakL = absL;
874
+ }
875
+ if (absR > peakR) {
876
+ peakR = absR;
877
+ }
878
+ sumL += l * l;
879
+ sumR += r * r;
880
+ sumLR += l * r;
881
+ }
882
+ const rmsL = Math.sqrt(sumL / Math.max(1, left.length));
883
+ const rmsR = Math.sqrt(sumR / Math.max(1, right.length));
884
+ const denominator = Math.sqrt(sumL * sumR);
885
+ const meter: SonareWorkletMeterSnapshot = {
886
+ type: 'meter',
887
+ frame: this.processedFrames,
888
+ peakDbL: toDb(peakL),
889
+ peakDbR: toDb(peakR),
890
+ rmsDbL: toDb(rmsL),
891
+ rmsDbR: toDb(rmsR),
892
+ correlation: denominator > 0 ? sumLR / denominator : 0,
893
+ };
894
+ this.transport.onMeter?.(meter);
895
+ if (this.meterRing) {
896
+ this.writeMeterRing(meter);
897
+ } else {
898
+ this.transport.postMessage?.(meter);
899
+ }
900
+ }
901
+
902
+ private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
903
+ const ring = this.meterRing;
904
+ if (!ring) {
905
+ return;
906
+ }
907
+ const writeIndex = Atomics.load(ring.header, 0);
908
+ const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
909
+ ring.records[offset] = meter.frame;
910
+ ring.records[offset + 1] = meter.peakDbL;
911
+ ring.records[offset + 2] = meter.peakDbR;
912
+ ring.records[offset + 3] = meter.rmsDbL;
913
+ ring.records[offset + 4] = meter.rmsDbR;
914
+ ring.records[offset + 5] = meter.correlation;
915
+ Atomics.store(ring.header, 0, writeIndex + 1);
916
+ if (writeIndex + 1 > ring.capacity) {
917
+ Atomics.store(ring.header, 3, writeIndex + 1 - ring.capacity);
918
+ }
919
+ }
920
+
921
+ private publishSpectrum(left: Float32Array, right: Float32Array): void {
922
+ if (this.spectrumIntervalFrames <= 0) {
923
+ return;
924
+ }
925
+ if (this.processedFrames - this.lastSpectrumFrame < this.spectrumIntervalFrames) {
926
+ return;
927
+ }
928
+ this.lastSpectrumFrame = this.processedFrames;
929
+ this.computeSpectrum(left, right);
930
+ if (this.spectrumRing) {
931
+ this.writeSpectrumRing(this.processedFrames, this.spectrumBands);
932
+ return;
933
+ }
934
+ const spectrum: SonareWorkletSpectrumSnapshot = {
935
+ type: 'spectrum',
936
+ frame: this.processedFrames,
937
+ bands: new Float32Array(this.spectrumBands),
938
+ };
939
+ this.transport?.onSpectrum?.(spectrum);
940
+ this.transport?.postMessage?.(spectrum);
941
+ }
942
+
943
+ private computeSpectrum(left: Float32Array, right: Float32Array): void {
944
+ const n = Math.max(1, Math.min(left.length, right.length));
945
+ for (let band = 0; band < this.spectrumBands.length; band++) {
946
+ const bin = band + 1;
947
+ let real = 0;
948
+ let imag = 0;
949
+ for (let i = 0; i < n; i++) {
950
+ const sample = 0.5 * ((left[i] ?? 0) + (right[i] ?? 0));
951
+ const phase = (-2 * Math.PI * bin * i) / n;
952
+ real += sample * Math.cos(phase);
953
+ imag += sample * Math.sin(phase);
954
+ }
955
+ this.spectrumBands[band] = magnitudeToDb((2 * Math.hypot(real, imag)) / n);
956
+ }
957
+ }
958
+
959
+ private writeSpectrumRing(frame: number, bands: Float32Array): void {
960
+ const ring = this.spectrumRing;
961
+ if (!ring) {
962
+ return;
963
+ }
964
+ const writeIndex = Atomics.load(ring.header, 0);
965
+ const offset = (writeIndex % ring.capacity) * ring.recordFloats;
966
+ ring.records[offset] = frame;
967
+ ring.records[offset + 1] = bands.length;
968
+ ring.records.set(bands.subarray(0, ring.bands), offset + 2);
969
+ Atomics.store(ring.header, 0, writeIndex + 1);
970
+ if (writeIndex + 1 > ring.capacity) {
971
+ Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
972
+ }
973
+ }
974
+ }
975
+
976
+ /**
977
+ * AudioWorklet-style bridge for the DAW realtime engine facade.
978
+ *
979
+ * The default mode uses the existing `sonare.wasm` embind facade. The
980
+ * `sonare-rt` target is exposed as a selectable runtime target for hosts that
981
+ * load the dedicated Emscripten AudioWorklet module.
982
+ */
983
+ export class SonareRealtimeEngineWorkletProcessor {
984
+ readonly sampleRate: number;
985
+ readonly blockSize: number;
986
+ readonly channelCount: number;
987
+ readonly runtimeTarget: 'embind' | 'sonare-rt';
988
+ private engine: RealtimeEngine;
989
+ private closed = false;
990
+ private commandRing?: SonareEngineCommandRingBuffer;
991
+ private telemetryRing?: SonareEngineTelemetryRingBuffer;
992
+ private transport?: WorkletTransport;
993
+ private meterIntervalFrames: number;
994
+ private lastMeterFrame = Number.NEGATIVE_INFINITY;
995
+
996
+ constructor(
997
+ options: SonareRealtimeEngineWorkletProcessorOptions = {},
998
+ transport?: WorkletTransport,
999
+ ) {
1000
+ this.sampleRate = options.sampleRate ?? 48000;
1001
+ this.blockSize = options.blockSize ?? 128;
1002
+ this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
1003
+ this.runtimeTarget = options.runtimeTarget ?? 'embind';
1004
+ if (this.runtimeTarget === 'sonare-rt') {
1005
+ throw new Error(
1006
+ 'sonare-rt runtime is provided by the dedicated Emscripten AudioWorklet module; use SonareRealtimeEngineNode.create({ runtimeTarget: "sonare-rt", moduleUrl: ... }) to load it.',
1007
+ );
1008
+ }
1009
+ this.transport = transport;
1010
+ this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
1011
+ this.commandRing = options.commandSharedBuffer
1012
+ ? this.commandRingFromSharedBuffer(options.commandSharedBuffer, options.commandRingCapacity)
1013
+ : undefined;
1014
+ this.telemetryRing = options.telemetrySharedBuffer
1015
+ ? this.telemetryRingFromSharedBuffer(
1016
+ options.telemetrySharedBuffer,
1017
+ options.telemetryRingCapacity,
1018
+ )
1019
+ : undefined;
1020
+ this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
1021
+ }
1022
+
1023
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
1024
+ if (this.closed) {
1025
+ return false;
1026
+ }
1027
+ const output = outputs[0];
1028
+ const firstOutput = output?.[0];
1029
+ if (!firstOutput) {
1030
+ return true;
1031
+ }
1032
+ const frames = firstOutput.length;
1033
+ if (frames > this.blockSize) {
1034
+ for (const channel of output ?? []) {
1035
+ channel.fill(0);
1036
+ }
1037
+ this.publishTelemetry();
1038
+ return true;
1039
+ }
1040
+
1041
+ this.drainCommands();
1042
+
1043
+ const channels: Float32Array[] = [];
1044
+ const input = inputs[0];
1045
+ for (let ch = 0; ch < this.channelCount; ch++) {
1046
+ const source = input?.[ch];
1047
+ const channel = new Float32Array(frames);
1048
+ if (source && source.length === frames) {
1049
+ channel.set(source);
1050
+ }
1051
+ channels.push(channel);
1052
+ }
1053
+
1054
+ const processed = this.engine.process(channels);
1055
+ for (let ch = 0; ch < output.length; ch++) {
1056
+ const target = output[ch];
1057
+ const source = processed[ch] ?? processed[0];
1058
+ if (source) {
1059
+ target.set(source.subarray(0, target.length));
1060
+ } else {
1061
+ target.fill(0);
1062
+ }
1063
+ }
1064
+ this.publishTelemetry();
1065
+ this.publishMeters();
1066
+ return true;
1067
+ }
1068
+
1069
+ receiveCommand(command: SonareEngineCommandRecord): void {
1070
+ if (!this.closed) {
1071
+ this.applyCommand(command);
1072
+ }
1073
+ }
1074
+
1075
+ destroy(): void {
1076
+ if (!this.closed) {
1077
+ this.engine.destroy();
1078
+ this.closed = true;
1079
+ }
1080
+ }
1081
+
1082
+ private drainCommands(): void {
1083
+ if (!this.commandRing) {
1084
+ return;
1085
+ }
1086
+ for (let i = 0; i < 64; i++) {
1087
+ const command = popSonareEngineCommandRingBuffer(this.commandRing);
1088
+ if (!command) {
1089
+ return;
1090
+ }
1091
+ this.applyCommand(command);
1092
+ }
1093
+ }
1094
+
1095
+ private applyCommand(command: SonareEngineCommandRecord): void {
1096
+ const sampleTime = Number(command.sampleTime ?? -1);
1097
+ switch (command.type) {
1098
+ case SonareEngineCommandType.TransportPlay:
1099
+ this.engine.play(sampleTime);
1100
+ break;
1101
+ case SonareEngineCommandType.TransportStop:
1102
+ this.engine.stop(sampleTime);
1103
+ break;
1104
+ case SonareEngineCommandType.TransportSeekSample:
1105
+ this.engine.seekSample(Number(command.argInt ?? 0), sampleTime);
1106
+ break;
1107
+ case SonareEngineCommandType.TransportSeekPpq:
1108
+ this.engine.seekPpq(Number(command.argFloat ?? 0), sampleTime);
1109
+ break;
1110
+ case SonareEngineCommandType.SetTempoMap:
1111
+ this.engine.setTempo(Number(command.argFloat ?? 120));
1112
+ break;
1113
+ case SonareEngineCommandType.SetLoop:
1114
+ this.engine.setLoop(
1115
+ Number(command.argFloat ?? 0),
1116
+ Number(command.argInt ?? 0) / 1_000_000,
1117
+ command.targetId !== 0,
1118
+ );
1119
+ break;
1120
+ case SonareEngineCommandType.ArmRecord:
1121
+ this.engine.armCapture(Boolean(command.argInt));
1122
+ break;
1123
+ case SonareEngineCommandType.Punch:
1124
+ this.engine.setCapturePunch(
1125
+ Number(command.argInt ?? 0),
1126
+ Math.max(0, Math.round(Number(command.argFloat ?? 0) * this.sampleRate)),
1127
+ true,
1128
+ );
1129
+ break;
1130
+ case SonareEngineCommandType.SetMetronome:
1131
+ this.engine.setMetronome({
1132
+ enabled: Boolean(command.argInt),
1133
+ beatGain: 0.25,
1134
+ accentGain: 0.75,
1135
+ clickSamples: 64,
1136
+ });
1137
+ break;
1138
+ default:
1139
+ this.publishTelemetryRecord({
1140
+ type: SonareEngineTelemetryType.Error,
1141
+ error: SonareEngineTelemetryError.UnknownTarget,
1142
+ renderFrame: 0,
1143
+ timelineSample: 0,
1144
+ audibleTimelineSample: 0,
1145
+ graphLatencySamplesQ8: 0,
1146
+ value: Number(command.type),
1147
+ });
1148
+ break;
1149
+ }
1150
+ }
1151
+
1152
+ private publishTelemetry(): void {
1153
+ for (const item of this.engine.drainTelemetry(64)) {
1154
+ this.publishTelemetryRecord(telemetryFromEngine(item));
1155
+ }
1156
+ }
1157
+
1158
+ private publishTelemetryRecord(record: SonareEngineTelemetryRecord): void {
1159
+ if (this.telemetryRing) {
1160
+ writeSonareEngineTelemetryRingBuffer(this.telemetryRing, record);
1161
+ return;
1162
+ }
1163
+ this.transport?.postMessage?.(record);
1164
+ }
1165
+
1166
+ private publishMeters(): void {
1167
+ if (!this.transport || this.meterIntervalFrames <= 0) {
1168
+ return;
1169
+ }
1170
+ for (const item of this.engine.drainMeterTelemetry(64)) {
1171
+ const meter = meterFromEngine(item);
1172
+ if (meter.frame - this.lastMeterFrame < this.meterIntervalFrames) {
1173
+ continue;
1174
+ }
1175
+ this.lastMeterFrame = meter.frame;
1176
+ this.transport.onMeter?.(meter);
1177
+ this.transport.postMessage?.(meter);
1178
+ }
1179
+ }
1180
+
1181
+ private commandRingFromSharedBuffer(
1182
+ sharedBuffer: SharedArrayBuffer,
1183
+ fallbackCapacity?: number,
1184
+ ): SonareEngineCommandRingBuffer {
1185
+ const ring = engineRingFromSharedBuffer(
1186
+ sharedBuffer,
1187
+ SONARE_ENGINE_COMMAND_RECORD_BYTES,
1188
+ fallbackCapacity,
1189
+ );
1190
+ return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1191
+ }
1192
+
1193
+ private telemetryRingFromSharedBuffer(
1194
+ sharedBuffer: SharedArrayBuffer,
1195
+ fallbackCapacity?: number,
1196
+ ): SonareEngineTelemetryRingBuffer {
1197
+ const ring = engineRingFromSharedBuffer(
1198
+ sharedBuffer,
1199
+ SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
1200
+ fallbackCapacity,
1201
+ );
1202
+ return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1203
+ }
1204
+ }
1205
+
1206
+ export class SonareRtRealtimeEngineRuntime {
1207
+ readonly sampleRate: number;
1208
+ readonly blockSize: number;
1209
+ readonly channelCount: number;
1210
+ private readonly module: SonareRtModule;
1211
+ private readonly memory: WebAssembly.Memory;
1212
+ private readonly engine: number;
1213
+ private readonly channelPointerTable: number;
1214
+ private readonly channelBuffers: number[];
1215
+ private readonly telemetryIntsPtr: number;
1216
+ private readonly telemetryFramesPtr: number;
1217
+ private readonly commandRing?: SonareEngineCommandRingBuffer;
1218
+ private readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
1219
+ private closed = false;
1220
+
1221
+ constructor(options: SonareRtRealtimeEngineRuntimeOptions) {
1222
+ this.module = options.module;
1223
+ this.memory = options.memory;
1224
+ this.sampleRate = options.sampleRate ?? 48000;
1225
+ this.blockSize = options.blockSize ?? 128;
1226
+ this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
1227
+ this.commandRing = options.commandSharedBuffer
1228
+ ? this.commandRingFromSharedBuffer(options.commandSharedBuffer, options.commandRingCapacity)
1229
+ : undefined;
1230
+ this.telemetryRing = options.telemetrySharedBuffer
1231
+ ? this.telemetryRingFromSharedBuffer(
1232
+ options.telemetrySharedBuffer,
1233
+ options.telemetryRingCapacity,
1234
+ )
1235
+ : undefined;
1236
+
1237
+ this.engine = this.module._sonare_rt_engine_create();
1238
+ if (this.engine <= 0) {
1239
+ throw new Error('failed to create sonare-rt engine');
1240
+ }
1241
+ if (
1242
+ this.module._sonare_rt_engine_prepare(
1243
+ this.engine,
1244
+ this.sampleRate,
1245
+ this.blockSize,
1246
+ 1024,
1247
+ 1024,
1248
+ ) !== 1
1249
+ ) {
1250
+ this.module._sonare_rt_engine_destroy(this.engine);
1251
+ throw new Error('failed to prepare sonare-rt engine');
1252
+ }
1253
+ this.channelPointerTable = this.module._malloc(
1254
+ this.channelCount * Uint32Array.BYTES_PER_ELEMENT,
1255
+ );
1256
+ this.channelBuffers = [];
1257
+ for (let ch = 0; ch < this.channelCount; ch++) {
1258
+ this.channelBuffers.push(
1259
+ this.module._malloc(this.blockSize * Float32Array.BYTES_PER_ELEMENT),
1260
+ );
1261
+ }
1262
+ this.telemetryIntsPtr = this.module._malloc(64 * 4 * Int32Array.BYTES_PER_ELEMENT);
1263
+ this.telemetryFramesPtr = this.module._malloc(64 * 3 * Float64Array.BYTES_PER_ELEMENT);
1264
+ this.writeChannelPointers();
1265
+ }
1266
+
1267
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
1268
+ if (this.closed) {
1269
+ return false;
1270
+ }
1271
+ const output = outputs[0];
1272
+ const firstOutput = output?.[0];
1273
+ if (!firstOutput) {
1274
+ return true;
1275
+ }
1276
+ const frames = firstOutput.length;
1277
+ if (frames > this.blockSize) {
1278
+ for (const channel of output) {
1279
+ channel.fill(0);
1280
+ }
1281
+ return true;
1282
+ }
1283
+
1284
+ this.drainCommands();
1285
+ const heap = new Float32Array(this.memory.buffer);
1286
+ const input = inputs[0];
1287
+ for (let ch = 0; ch < this.channelCount; ch++) {
1288
+ const ptr = this.channelBuffers[ch] ?? this.channelBuffers[0];
1289
+ const offset = ptr >> 2;
1290
+ const source = input?.[ch];
1291
+ if (source && source.length === frames) {
1292
+ heap.set(source, offset);
1293
+ } else {
1294
+ heap.fill(0, offset, offset + frames);
1295
+ }
1296
+ }
1297
+
1298
+ this.module._sonare_rt_engine_process(
1299
+ this.engine,
1300
+ this.channelPointerTable,
1301
+ this.channelCount,
1302
+ frames,
1303
+ );
1304
+
1305
+ for (let ch = 0; ch < output.length; ch++) {
1306
+ const target = output[ch];
1307
+ const ptr = this.channelBuffers[ch] ?? this.channelBuffers[0];
1308
+ target.set(heap.subarray(ptr >> 2, (ptr >> 2) + target.length));
1309
+ }
1310
+ this.publishTelemetry();
1311
+ return true;
1312
+ }
1313
+
1314
+ destroy(): void {
1315
+ if (this.closed) {
1316
+ return;
1317
+ }
1318
+ this.module._free(this.telemetryFramesPtr);
1319
+ this.module._free(this.telemetryIntsPtr);
1320
+ for (const ptr of this.channelBuffers) {
1321
+ this.module._free(ptr);
1322
+ }
1323
+ this.module._free(this.channelPointerTable);
1324
+ this.module._sonare_rt_engine_destroy(this.engine);
1325
+ this.closed = true;
1326
+ }
1327
+
1328
+ private writeChannelPointers(): void {
1329
+ const pointers = new Uint32Array(this.memory.buffer);
1330
+ const offset = this.channelPointerTable >> 2;
1331
+ for (let ch = 0; ch < this.channelBuffers.length; ch++) {
1332
+ pointers[offset + ch] = this.channelBuffers[ch];
1333
+ }
1334
+ }
1335
+
1336
+ private drainCommands(): void {
1337
+ if (!this.commandRing) {
1338
+ return;
1339
+ }
1340
+ for (let i = 0; i < 64; i++) {
1341
+ const command = popSonareEngineCommandRingBuffer(this.commandRing);
1342
+ if (!command) {
1343
+ return;
1344
+ }
1345
+ this.applyCommand(command);
1346
+ }
1347
+ }
1348
+
1349
+ private applyCommand(command: SonareEngineCommandRecord): void {
1350
+ const sampleTime = toBigInt64(command.sampleTime, -1n);
1351
+ switch (command.type) {
1352
+ case SonareEngineCommandType.TransportPlay:
1353
+ this.module._sonare_rt_engine_play(this.engine, sampleTime);
1354
+ break;
1355
+ case SonareEngineCommandType.TransportStop:
1356
+ this.module._sonare_rt_engine_stop(this.engine, sampleTime);
1357
+ break;
1358
+ case SonareEngineCommandType.TransportSeekSample:
1359
+ this.module._sonare_rt_engine_seek_sample(
1360
+ this.engine,
1361
+ toBigInt64(command.argInt, 0n),
1362
+ sampleTime,
1363
+ );
1364
+ break;
1365
+ case SonareEngineCommandType.TransportSeekPpq:
1366
+ this.module._sonare_rt_engine_seek_ppq(
1367
+ this.engine,
1368
+ Number(command.argFloat ?? 0),
1369
+ sampleTime,
1370
+ );
1371
+ break;
1372
+ case SonareEngineCommandType.SetTempoMap:
1373
+ this.module._sonare_rt_engine_set_tempo(this.engine, Number(command.argFloat ?? 120));
1374
+ break;
1375
+ case SonareEngineCommandType.SetLoop:
1376
+ this.module._sonare_rt_engine_set_loop(
1377
+ this.engine,
1378
+ Number(command.argFloat ?? 0),
1379
+ Number(command.argInt ?? 0) / 1_000_000,
1380
+ command.targetId ? 1 : 0,
1381
+ );
1382
+ break;
1383
+ case SonareEngineCommandType.ArmRecord:
1384
+ this.module._sonare_rt_engine_set_capture_armed(this.engine, command.argInt ? 1 : 0);
1385
+ break;
1386
+ case SonareEngineCommandType.Punch:
1387
+ this.module._sonare_rt_engine_set_capture_punch(
1388
+ this.engine,
1389
+ toBigInt64(command.argInt, 0n),
1390
+ BigInt(Math.trunc(Number(command.argFloat ?? 0) * this.sampleRate)),
1391
+ 1,
1392
+ );
1393
+ break;
1394
+ case SonareEngineCommandType.SetMetronome:
1395
+ this.module._sonare_rt_engine_set_metronome_enabled(
1396
+ this.engine,
1397
+ command.argInt ? 1 : 0,
1398
+ 0.25,
1399
+ 0.75,
1400
+ 64,
1401
+ );
1402
+ break;
1403
+ case SonareEngineCommandType.SeekMarker:
1404
+ this.module._sonare_rt_engine_seek_marker(
1405
+ this.engine,
1406
+ Math.trunc(command.targetId ?? 0),
1407
+ sampleTime,
1408
+ );
1409
+ break;
1410
+ default:
1411
+ if (this.telemetryRing) {
1412
+ writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1413
+ type: SonareEngineTelemetryType.Error,
1414
+ error: SonareEngineTelemetryError.UnknownTarget,
1415
+ renderFrame: 0,
1416
+ timelineSample: 0,
1417
+ audibleTimelineSample: 0,
1418
+ graphLatencySamplesQ8: 0,
1419
+ value: Number(command.type),
1420
+ });
1421
+ }
1422
+ break;
1423
+ }
1424
+ }
1425
+
1426
+ private publishTelemetry(): void {
1427
+ if (!this.telemetryRing) {
1428
+ this.module._sonare_rt_engine_drain_telemetry(
1429
+ this.engine,
1430
+ this.telemetryIntsPtr,
1431
+ this.telemetryFramesPtr,
1432
+ 64,
1433
+ );
1434
+ return;
1435
+ }
1436
+ const count = this.module._sonare_rt_engine_drain_telemetry(
1437
+ this.engine,
1438
+ this.telemetryIntsPtr,
1439
+ this.telemetryFramesPtr,
1440
+ 64,
1441
+ );
1442
+ const ints = new Int32Array(this.memory.buffer);
1443
+ const frames = new Float64Array(this.memory.buffer);
1444
+ const intBase = this.telemetryIntsPtr >> 2;
1445
+ const frameBase = this.telemetryFramesPtr >> 3;
1446
+ for (let i = 0; i < count; i++) {
1447
+ writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1448
+ type: ints[intBase + i * 4],
1449
+ error: ints[intBase + i * 4 + 1],
1450
+ renderFrame: frames[frameBase + i * 3],
1451
+ timelineSample: frames[frameBase + i * 3 + 1],
1452
+ audibleTimelineSample: frames[frameBase + i * 3 + 2],
1453
+ graphLatencySamplesQ8: ints[intBase + i * 4 + 2],
1454
+ value: ints[intBase + i * 4 + 3],
1455
+ });
1456
+ }
1457
+ }
1458
+
1459
+ private commandRingFromSharedBuffer(
1460
+ sharedBuffer: SharedArrayBuffer,
1461
+ fallbackCapacity?: number,
1462
+ ): SonareEngineCommandRingBuffer {
1463
+ const ring = engineRingFromSharedBuffer(
1464
+ sharedBuffer,
1465
+ SONARE_ENGINE_COMMAND_RECORD_BYTES,
1466
+ fallbackCapacity,
1467
+ );
1468
+ return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1469
+ }
1470
+
1471
+ private telemetryRingFromSharedBuffer(
1472
+ sharedBuffer: SharedArrayBuffer,
1473
+ fallbackCapacity?: number,
1474
+ ): SonareEngineTelemetryRingBuffer {
1475
+ const ring = engineRingFromSharedBuffer(
1476
+ sharedBuffer,
1477
+ SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
1478
+ fallbackCapacity,
1479
+ );
1480
+ return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1481
+ }
1482
+ }
1483
+
1484
+ export class SonareRealtimeEngineNode {
1485
+ readonly node: AudioWorkletNode;
1486
+ readonly capabilities: SonareRealtimeEngineNodeCapabilities;
1487
+ readonly commandRing?: SonareEngineCommandRingBuffer;
1488
+ readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
1489
+ readonly ready: Promise<void>;
1490
+ private telemetryReadIndex = 0;
1491
+ private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
1492
+ private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
1493
+ private resolveReady!: () => void;
1494
+ private rejectReady!: (reason?: unknown) => void;
1495
+ private destroyed = false;
1496
+
1497
+ private constructor(
1498
+ node: AudioWorkletNode,
1499
+ capabilities: SonareRealtimeEngineNodeCapabilities,
1500
+ commandRing?: SonareEngineCommandRingBuffer,
1501
+ telemetryRing?: SonareEngineTelemetryRingBuffer,
1502
+ ) {
1503
+ this.node = node;
1504
+ this.capabilities = capabilities;
1505
+ this.commandRing = commandRing;
1506
+ this.telemetryRing = telemetryRing;
1507
+ this.ready = new Promise((resolve, reject) => {
1508
+ this.resolveReady = resolve;
1509
+ this.rejectReady = reject;
1510
+ });
1511
+ if (capabilities.runtimeTarget !== 'sonare-rt') {
1512
+ this.resolveReady();
1513
+ }
1514
+ this.node.port.onmessage = (event: MessageEvent<unknown>) => {
1515
+ if (isEngineTelemetryRecord(event.data)) {
1516
+ this.emitTelemetry(event.data);
1517
+ } else if (isMeterSnapshot(event.data)) {
1518
+ this.emitMeter(event.data);
1519
+ } else if (isRecord(event.data) && event.data.type === 'ready') {
1520
+ this.resolveReady();
1521
+ } else if (isRecord(event.data) && event.data.type === 'error') {
1522
+ this.rejectReady(new Error(String(event.data.message ?? 'AudioWorklet error')));
1523
+ }
1524
+ };
1525
+ }
1526
+
1527
+ static async create(
1528
+ context: BaseAudioContext,
1529
+ options: SonareRealtimeEngineNodeOptions = {},
1530
+ ): Promise<SonareRealtimeEngineNode> {
1531
+ const runtimeTarget = options.runtimeTarget ?? 'embind';
1532
+ const processorName = options.processorName ?? 'sonare-realtime-engine-processor';
1533
+ const moduleUrl = options.moduleUrl;
1534
+ if (moduleUrl && context.audioWorklet?.addModule) {
1535
+ await context.audioWorklet.addModule(moduleUrl);
1536
+ }
1537
+ const detectedCapabilities =
1538
+ options.engineAbiVersion !== undefined
1539
+ ? {
1540
+ engineAbiVersion: options.engineAbiVersion,
1541
+ expectedEngineAbiVersion: options.expectedEngineAbiVersion ?? options.engineAbiVersion,
1542
+ abiCompatible:
1543
+ options.engineAbiVersion ===
1544
+ (options.expectedEngineAbiVersion ?? options.engineAbiVersion),
1545
+ }
1546
+ : runtimeTarget === 'embind'
1547
+ ? engineCapabilities()
1548
+ : undefined;
1549
+ if (options.requireAbiCompatible !== false && detectedCapabilities?.abiCompatible === false) {
1550
+ throw new Error(
1551
+ `Engine ABI mismatch: wasm=${detectedCapabilities.engineAbiVersion}, expected=${detectedCapabilities.expectedEngineAbiVersion}`,
1552
+ );
1553
+ }
1554
+ const sharedArrayBuffer = typeof globalThis.SharedArrayBuffer === 'function';
1555
+ const atomics = typeof globalThis.Atomics === 'object';
1556
+ const audioWorklet = typeof AudioWorkletNode !== 'undefined' || !!options.nodeFactory;
1557
+ const degradedReason =
1558
+ options.mode !== 'postMessage' && (!sharedArrayBuffer || !atomics)
1559
+ ? 'SharedArrayBuffer or Atomics unavailable; using postMessage transport.'
1560
+ : undefined;
1561
+ const mode =
1562
+ options.mode === 'postMessage' || !sharedArrayBuffer || !atomics ? 'postMessage' : 'sab';
1563
+ if (options.mode === 'sab' && mode !== 'sab') {
1564
+ throw new Error(
1565
+ 'SharedArrayBuffer mode requested but SharedArrayBuffer/Atomics are unavailable.',
1566
+ );
1567
+ }
1568
+
1569
+ const commandRing =
1570
+ mode === 'sab'
1571
+ ? createSonareEngineCommandRingBuffer(options.commandRingCapacity ?? 128)
1572
+ : undefined;
1573
+ const telemetryRing =
1574
+ mode === 'sab'
1575
+ ? createSonareEngineTelemetryRingBuffer(options.telemetryRingCapacity ?? 128)
1576
+ : undefined;
1577
+ const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
1578
+ const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
1579
+ runtimeTarget,
1580
+ rtModuleUrl: options.rtModuleUrl,
1581
+ rtWasmBinary: options.rtWasmBinary,
1582
+ sampleRate: options.sampleRate ?? context.sampleRate,
1583
+ blockSize: options.blockSize,
1584
+ channelCount,
1585
+ commandSharedBuffer: commandRing?.sharedBuffer,
1586
+ commandRingCapacity: commandRing?.capacity,
1587
+ telemetrySharedBuffer: telemetryRing?.sharedBuffer,
1588
+ telemetryRingCapacity: telemetryRing?.capacity,
1589
+ };
1590
+ const factory =
1591
+ options.nodeFactory ??
1592
+ ((ctx: BaseAudioContext, name: string, nodeOptions: AudioWorkletNodeOptions) =>
1593
+ new AudioWorkletNode(ctx, name, nodeOptions));
1594
+ const node = factory(context, processorName, {
1595
+ numberOfInputs: 1,
1596
+ numberOfOutputs: 1,
1597
+ outputChannelCount: [channelCount],
1598
+ processorOptions,
1599
+ });
1600
+ return new SonareRealtimeEngineNode(
1601
+ node,
1602
+ {
1603
+ mode,
1604
+ runtimeTarget,
1605
+ sharedArrayBuffer,
1606
+ atomics,
1607
+ audioWorklet,
1608
+ engineAbiVersion: detectedCapabilities?.engineAbiVersion,
1609
+ expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
1610
+ abiCompatible: detectedCapabilities?.abiCompatible,
1611
+ degradedReason,
1612
+ },
1613
+ commandRing,
1614
+ telemetryRing,
1615
+ );
1616
+ }
1617
+
1618
+ play(sampleTime = -1): boolean {
1619
+ return this.sendCommand({ type: SonareEngineCommandType.TransportPlay, sampleTime });
1620
+ }
1621
+
1622
+ stop(sampleTime = -1): boolean {
1623
+ return this.sendCommand({ type: SonareEngineCommandType.TransportStop, sampleTime });
1624
+ }
1625
+
1626
+ seekSample(timelineSample: number, sampleTime = -1): boolean {
1627
+ return this.sendCommand({
1628
+ type: SonareEngineCommandType.TransportSeekSample,
1629
+ sampleTime,
1630
+ argInt: timelineSample,
1631
+ });
1632
+ }
1633
+
1634
+ seekPpq(ppq: number, sampleTime = -1): boolean {
1635
+ return this.sendCommand({
1636
+ type: SonareEngineCommandType.TransportSeekPpq,
1637
+ sampleTime,
1638
+ argFloat: ppq,
1639
+ });
1640
+ }
1641
+
1642
+ sendCommand(command: SonareEngineCommandRecord): boolean {
1643
+ if (this.destroyed) {
1644
+ return false;
1645
+ }
1646
+ if (this.commandRing) {
1647
+ return pushSonareEngineCommandRingBuffer(this.commandRing, command);
1648
+ }
1649
+ this.node.port.postMessage(command);
1650
+ return true;
1651
+ }
1652
+
1653
+ pollTelemetry(): SonareEngineTelemetryRecord[] {
1654
+ if (!this.telemetryRing) {
1655
+ return [];
1656
+ }
1657
+ const read = readSonareEngineTelemetryRingBuffer(this.telemetryRing, this.telemetryReadIndex);
1658
+ this.telemetryReadIndex = read.nextReadIndex;
1659
+ for (const telemetry of read.telemetry) {
1660
+ this.emitTelemetry(telemetry);
1661
+ }
1662
+ return read.telemetry;
1663
+ }
1664
+
1665
+ onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
1666
+ this.telemetryListeners.add(callback);
1667
+ return () => {
1668
+ this.telemetryListeners.delete(callback);
1669
+ };
1670
+ }
1671
+
1672
+ onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
1673
+ this.meterListeners.add(callback);
1674
+ return () => {
1675
+ this.meterListeners.delete(callback);
1676
+ };
1677
+ }
1678
+
1679
+ destroy(): void {
1680
+ if (this.destroyed) {
1681
+ return;
1682
+ }
1683
+ this.destroyed = true;
1684
+ this.node.port.postMessage({ type: SonareEngineCommandType.TransportStop, sampleTime: -1 });
1685
+ this.node.disconnect();
1686
+ this.telemetryListeners.clear();
1687
+ this.meterListeners.clear();
1688
+ }
1689
+
1690
+ private emitTelemetry(telemetry: SonareEngineTelemetryRecord): void {
1691
+ for (const listener of this.telemetryListeners) {
1692
+ listener(telemetry);
1693
+ }
1694
+ }
1695
+
1696
+ private emitMeter(meter: SonareWorkletMeterSnapshot): void {
1697
+ for (const listener of this.meterListeners) {
1698
+ listener(meter);
1699
+ }
1700
+ }
1701
+ }
1702
+
1703
+ export class SonareEngine {
1704
+ readonly node: AudioWorkletNode;
1705
+ readonly capabilities: SonareRealtimeEngineNodeCapabilities;
1706
+ readonly transport: SonareEngineTransportFacade;
1707
+ private readonly realtimeNode: SonareRealtimeEngineNode;
1708
+ private readonly offlineEngine: RealtimeEngine;
1709
+ private readonly context: SuspendableAudioContext;
1710
+ private readonly sampleRate: number;
1711
+ private readonly offlineBlockSize: number;
1712
+ private readonly offlineChannelCount: number;
1713
+ private readonly automationLanes = new Map<number, EngineAutomationPoint[]>();
1714
+ private readonly clips = new Map<number, EngineClip>();
1715
+ private readonly markers = new Map<number, EngineMarker>();
1716
+ private nextClipId = 1;
1717
+ private nextMarkerId = 1;
1718
+ private destroyed = false;
1719
+
1720
+ private constructor(
1721
+ context: BaseAudioContext,
1722
+ realtimeNode: SonareRealtimeEngineNode,
1723
+ offlineEngine: RealtimeEngine,
1724
+ sampleRate: number,
1725
+ offlineBlockSize: number,
1726
+ offlineChannelCount: number,
1727
+ ) {
1728
+ this.context = context;
1729
+ this.realtimeNode = realtimeNode;
1730
+ this.offlineEngine = offlineEngine;
1731
+ this.node = realtimeNode.node;
1732
+ this.capabilities = realtimeNode.capabilities;
1733
+ this.sampleRate = sampleRate;
1734
+ this.offlineBlockSize = offlineBlockSize;
1735
+ this.offlineChannelCount = offlineChannelCount;
1736
+ this.transport = {
1737
+ play: (sampleTime = -1) => this.realtimeNode.play(sampleTime),
1738
+ stop: (sampleTime = -1) => this.realtimeNode.stop(sampleTime),
1739
+ seekPpq: (ppq, sampleTime = -1) => {
1740
+ this.offlineEngine.seekPpq(ppq, sampleTime);
1741
+ return this.realtimeNode.seekPpq(ppq, sampleTime);
1742
+ },
1743
+ seekSeconds: (seconds, sampleTime = -1) => {
1744
+ const timelineSample = Math.max(0, Math.round(seconds * this.sampleRate));
1745
+ this.offlineEngine.seekSample(timelineSample, sampleTime);
1746
+ return this.realtimeNode.seekSample(timelineSample, sampleTime);
1747
+ },
1748
+ setTempo: (bpm) => this.setTempo(bpm),
1749
+ setLoop: (startPpq, endPpq, enabled = true) => this.setLoop(startPpq, endPpq, enabled),
1750
+ };
1751
+ }
1752
+
1753
+ static async create(
1754
+ context: BaseAudioContext,
1755
+ options: SonareEngineOptions = {},
1756
+ ): Promise<SonareEngine> {
1757
+ const sampleRate = options.sampleRate ?? context.sampleRate;
1758
+ const blockSize = options.offlineBlockSize ?? options.blockSize ?? 128;
1759
+ const channelCount = Math.max(
1760
+ 1,
1761
+ Math.floor(options.offlineChannelCount ?? options.channelCount ?? 2),
1762
+ );
1763
+ const realtimeNode = await SonareRealtimeEngineNode.create(context, options);
1764
+ const offlineEngine = options.offlineEngine ?? new RealtimeEngine(sampleRate, blockSize);
1765
+ return new SonareEngine(
1766
+ context,
1767
+ realtimeNode,
1768
+ offlineEngine,
1769
+ sampleRate,
1770
+ blockSize,
1771
+ channelCount,
1772
+ );
1773
+ }
1774
+
1775
+ async suspend(): Promise<void> {
1776
+ if (this.destroyed) {
1777
+ return;
1778
+ }
1779
+ await this.context.suspend?.();
1780
+ }
1781
+
1782
+ async resume(): Promise<void> {
1783
+ if (this.destroyed) {
1784
+ return;
1785
+ }
1786
+ await this.context.resume?.();
1787
+ }
1788
+
1789
+ setTempo(bpm: number): void {
1790
+ this.offlineEngine.setTempo(bpm);
1791
+ this.realtimeNode.sendCommand({
1792
+ type: SonareEngineCommandType.SetTempoMap,
1793
+ sampleTime: -1,
1794
+ argFloat: bpm,
1795
+ });
1796
+ }
1797
+
1798
+ setLoop(startPpq: number, endPpq: number, enabled = true): boolean {
1799
+ this.offlineEngine.setLoop(startPpq, endPpq, enabled);
1800
+ return this.realtimeNode.sendCommand({
1801
+ type: SonareEngineCommandType.SetLoop,
1802
+ targetId: enabled ? 1 : 0,
1803
+ sampleTime: -1,
1804
+ argFloat: startPpq,
1805
+ argInt: Math.round(endPpq * 1_000_000),
1806
+ });
1807
+ }
1808
+
1809
+ setParam(nodeId: string, param: string | number, value: number): boolean {
1810
+ void nodeId;
1811
+ void param;
1812
+ void value;
1813
+ return false;
1814
+ }
1815
+
1816
+ scheduleParam(
1817
+ nodeId: string,
1818
+ param: string | number,
1819
+ ppq: number,
1820
+ value: number,
1821
+ curve: number | 'linear' | 'exponential' = 'linear',
1822
+ ): void {
1823
+ const paramId = this.resolveParamId(nodeId, param);
1824
+ const lane = this.automationLanes.get(paramId) ?? [];
1825
+ lane.push({ ppq, value, curveToNext: this.curveCode(curve) });
1826
+ lane.sort((a, b) => a.ppq - b.ppq);
1827
+ this.automationLanes.set(paramId, lane);
1828
+ this.offlineEngine.setAutomationLane(paramId, lane);
1829
+ }
1830
+
1831
+ addAutomationPoint(
1832
+ laneId: string | number,
1833
+ ppq: number,
1834
+ value: number,
1835
+ curve: number | 'linear' | 'exponential' = 'linear',
1836
+ ): void {
1837
+ this.scheduleParam('', laneId, ppq, value, curve);
1838
+ }
1839
+
1840
+ listParameters(): EngineParameterInfo[] {
1841
+ const parameters: EngineParameterInfo[] = [];
1842
+ for (let index = 0; index < this.offlineEngine.parameterCount(); index++) {
1843
+ parameters.push(this.offlineEngine.parameterInfoByIndex(index));
1844
+ }
1845
+ return parameters;
1846
+ }
1847
+
1848
+ setSoloMute(target: string | number, solo: boolean, mute: boolean): boolean {
1849
+ void target;
1850
+ void solo;
1851
+ void mute;
1852
+ return false;
1853
+ }
1854
+
1855
+ addClip(
1856
+ trackId: string | number,
1857
+ buffer: Float32Array[],
1858
+ startPpq: number,
1859
+ opts: Partial<Omit<EngineClip, 'channels' | 'startPpq'>> = {},
1860
+ ): number {
1861
+ const id = opts.id ?? this.nextClipId++;
1862
+ const clip: EngineClip = {
1863
+ ...opts,
1864
+ id,
1865
+ channels: buffer,
1866
+ startPpq,
1867
+ };
1868
+ this.clips.set(id, clip);
1869
+ this.syncClips();
1870
+ void trackId;
1871
+ return id;
1872
+ }
1873
+
1874
+ removeClip(clipId: number): void {
1875
+ this.clips.delete(clipId);
1876
+ this.syncClips();
1877
+ }
1878
+
1879
+ armRecord(trackId: string | number, enabled: boolean): boolean {
1880
+ this.offlineEngine.armCapture(enabled);
1881
+ return this.realtimeNode.sendCommand({
1882
+ type: SonareEngineCommandType.ArmRecord,
1883
+ targetId: this.resolveTargetId(trackId),
1884
+ sampleTime: -1,
1885
+ argInt: enabled ? 1 : 0,
1886
+ });
1887
+ }
1888
+
1889
+ punch(inPpq: number, outPpq: number): boolean {
1890
+ const inSample = this.ppqToApproxSample(inPpq);
1891
+ const outSample = this.ppqToApproxSample(outPpq);
1892
+ this.offlineEngine.setCapturePunch(inSample, outSample, true);
1893
+ return this.realtimeNode.sendCommand({
1894
+ type: SonareEngineCommandType.Punch,
1895
+ sampleTime: -1,
1896
+ argInt: inSample,
1897
+ argFloat: outPpq,
1898
+ });
1899
+ }
1900
+
1901
+ setMetronome(opts: EngineMetronomeConfig): void {
1902
+ this.offlineEngine.setMetronome(opts);
1903
+ this.realtimeNode.sendCommand({
1904
+ type: SonareEngineCommandType.SetMetronome,
1905
+ sampleTime: -1,
1906
+ argInt: opts.enabled ? 1 : 0,
1907
+ });
1908
+ }
1909
+
1910
+ addMarker(ppq: number, name = ''): number {
1911
+ const id = this.nextMarkerId++;
1912
+ this.markers.set(id, { id, ppq, name });
1913
+ this.syncMarkers();
1914
+ return id;
1915
+ }
1916
+
1917
+ seekMarker(markerId: number): boolean {
1918
+ this.offlineEngine.seekMarker(markerId);
1919
+ return false;
1920
+ }
1921
+
1922
+ async renderOffline(totalFrames: number): Promise<Float32Array[]> {
1923
+ const frames = Math.max(0, Math.floor(totalFrames));
1924
+ const inputs: Float32Array[] = [];
1925
+ for (let ch = 0; ch < this.offlineChannelCount; ch++) {
1926
+ inputs.push(new Float32Array(frames));
1927
+ }
1928
+ return this.offlineEngine.renderOffline(inputs, this.offlineBlockSize);
1929
+ }
1930
+
1931
+ onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
1932
+ return this.realtimeNode.onMeter(callback);
1933
+ }
1934
+
1935
+ onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
1936
+ return this.realtimeNode.onTelemetry(callback);
1937
+ }
1938
+
1939
+ pollTelemetry(): SonareEngineTelemetryRecord[] {
1940
+ return this.realtimeNode.pollTelemetry();
1941
+ }
1942
+
1943
+ destroy(): void {
1944
+ if (this.destroyed) {
1945
+ return;
1946
+ }
1947
+ this.destroyed = true;
1948
+ this.transport.stop();
1949
+ this.realtimeNode.pollTelemetry();
1950
+ this.realtimeNode.destroy();
1951
+ this.offlineEngine.destroy();
1952
+ }
1953
+
1954
+ private syncClips(): void {
1955
+ this.offlineEngine.setClips(Array.from(this.clips.values()));
1956
+ }
1957
+
1958
+ private syncMarkers(): void {
1959
+ this.offlineEngine.setMarkers(Array.from(this.markers.values()).sort((a, b) => a.ppq - b.ppq));
1960
+ }
1961
+
1962
+ private resolveParamId(nodeId: string, param: string | number): number {
1963
+ if (typeof param === 'number') {
1964
+ return param;
1965
+ }
1966
+ const byName = this.listParameters().find((info) => info.name === param);
1967
+ if (byName) {
1968
+ return byName.id;
1969
+ }
1970
+ return this.resolveTargetId(param || nodeId);
1971
+ }
1972
+
1973
+ private resolveTargetId(target: string | number): number {
1974
+ if (typeof target === 'number') {
1975
+ return target;
1976
+ }
1977
+ const parsed = Number.parseInt(target, 10);
1978
+ return Number.isFinite(parsed) ? parsed : 0;
1979
+ }
1980
+
1981
+ private curveCode(curve: number | 'linear' | 'exponential'): number {
1982
+ if (typeof curve === 'number') {
1983
+ return curve;
1984
+ }
1985
+ return curve === 'exponential' ? 1 : 0;
1986
+ }
1987
+
1988
+ private ppqToApproxSample(ppq: number): number {
1989
+ return Math.max(0, Math.round(((ppq * 60) / 120) * this.sampleRate));
1990
+ }
1991
+ }
1992
+
1993
+ export function registerSonareWorkletProcessor(name = 'sonare-worklet-processor'): void {
1994
+ const scope = globalThis as unknown as {
1995
+ AudioWorkletProcessor?: new () => object;
1996
+ registerProcessor?: (processorName: string, processorCtor: unknown) => void;
1997
+ };
1998
+ if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
1999
+ throw new Error('AudioWorkletProcessor is not available in this context.');
2000
+ }
2001
+ const Base = scope.AudioWorkletProcessor;
2002
+ class RegisteredSonareWorkletProcessor extends Base {
2003
+ private bridge: SonareWorkletProcessor;
2004
+ readonly port?: WorkletPort;
2005
+
2006
+ constructor(options?: { processorOptions?: SonareWorkletProcessorOptions }) {
2007
+ super();
2008
+ const port = this.port;
2009
+ this.bridge = new SonareWorkletProcessor(options?.processorOptions ?? { sceneJson: '' }, {
2010
+ postMessage: (message) => port?.postMessage?.(message),
2011
+ });
2012
+ const onMessage = (event: { data: unknown }) => {
2013
+ if (isWorkletMessage(event.data)) {
2014
+ this.bridge.receiveMessage(event.data);
2015
+ }
2016
+ };
2017
+ if (port?.addEventListener) {
2018
+ port.addEventListener('message', onMessage);
2019
+ port.start?.();
2020
+ } else if (port) {
2021
+ port.onmessage = onMessage;
2022
+ }
2023
+ }
2024
+
2025
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
2026
+ return this.bridge.process(inputs, outputs);
2027
+ }
2028
+ }
2029
+ scope.registerProcessor(name, RegisteredSonareWorkletProcessor);
2030
+ }
2031
+
2032
+ export function registerSonareRealtimeEngineWorkletProcessor(
2033
+ name = 'sonare-realtime-engine-processor',
2034
+ ): void {
2035
+ const scope = globalThis as unknown as {
2036
+ AudioWorkletProcessor?: new () => object;
2037
+ registerProcessor?: (processorName: string, processorCtor: unknown) => void;
2038
+ };
2039
+ if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
2040
+ throw new Error('AudioWorkletProcessor is not available in this context.');
2041
+ }
2042
+ const Base = scope.AudioWorkletProcessor;
2043
+ class RegisteredSonareRealtimeEngineWorkletProcessor extends Base {
2044
+ private bridge?: SonareRealtimeEngineWorkletProcessor;
2045
+ private rtBridge?: SonareRtRealtimeEngineRuntime;
2046
+ readonly port?: WorkletPort;
2047
+
2048
+ constructor(options?: { processorOptions?: SonareRealtimeEngineWorkletProcessorOptions }) {
2049
+ super();
2050
+ const port = this.port;
2051
+ const processorOptions = options?.processorOptions ?? {};
2052
+ if (processorOptions.runtimeTarget === 'sonare-rt') {
2053
+ void this.initializeSonareRt(processorOptions, port);
2054
+ } else {
2055
+ this.bridge = new SonareRealtimeEngineWorkletProcessor(processorOptions, {
2056
+ postMessage: (message) => port?.postMessage?.(message),
2057
+ onMeter: (meter) => port?.postMessage?.(meter),
2058
+ });
2059
+ }
2060
+ const onMessage = (event: { data: unknown }) => {
2061
+ if (isEngineCommandRecord(event.data)) {
2062
+ this.bridge?.receiveCommand(event.data);
2063
+ }
2064
+ };
2065
+ if (port?.addEventListener) {
2066
+ port.addEventListener('message', onMessage);
2067
+ port.start?.();
2068
+ } else if (port) {
2069
+ port.onmessage = onMessage;
2070
+ }
2071
+ }
2072
+
2073
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
2074
+ if (this.rtBridge) {
2075
+ return this.rtBridge.process(inputs, outputs);
2076
+ }
2077
+ if (this.bridge) {
2078
+ return this.bridge.process(inputs, outputs);
2079
+ }
2080
+ const output = outputs[0];
2081
+ for (const channel of output ?? []) {
2082
+ channel.fill(0);
2083
+ }
2084
+ return true;
2085
+ }
2086
+
2087
+ private async initializeSonareRt(
2088
+ options: SonareRealtimeEngineWorkletProcessorOptions,
2089
+ port?: WorkletPort,
2090
+ ): Promise<void> {
2091
+ try {
2092
+ if (!options.rtModuleUrl) {
2093
+ throw new Error('rtModuleUrl is required for sonare-rt AudioWorklet runtime.');
2094
+ }
2095
+ const memory = new WebAssembly.Memory({ initial: 1024, maximum: 1024, shared: true });
2096
+ const globalFactory = (
2097
+ globalThis as typeof globalThis & {
2098
+ SonareRtModuleFactory?: (options?: {
2099
+ wasmMemory?: WebAssembly.Memory;
2100
+ wasmBinary?: ArrayBuffer | Uint8Array;
2101
+ locateFile?: (path: string) => string;
2102
+ }) => Promise<SonareRtModule>;
2103
+ }
2104
+ ).SonareRtModuleFactory;
2105
+ const moduleFactory = globalFactory
2106
+ ? { default: globalFactory }
2107
+ : ((await import(options.rtModuleUrl)) as {
2108
+ default: (options?: {
2109
+ wasmMemory?: WebAssembly.Memory;
2110
+ wasmBinary?: ArrayBuffer | Uint8Array;
2111
+ locateFile?: (path: string) => string;
2112
+ }) => Promise<SonareRtModule>;
2113
+ });
2114
+ const module = await moduleFactory.default({
2115
+ wasmMemory: memory,
2116
+ wasmBinary: options.rtWasmBinary,
2117
+ locateFile: (path) => options.rtModuleUrl!.replace(/[^/]*$/, path),
2118
+ });
2119
+ this.rtBridge = new SonareRtRealtimeEngineRuntime({
2120
+ module,
2121
+ memory,
2122
+ sampleRate: options.sampleRate,
2123
+ blockSize: options.blockSize,
2124
+ channelCount: options.channelCount,
2125
+ commandSharedBuffer: options.commandSharedBuffer,
2126
+ commandRingCapacity: options.commandRingCapacity,
2127
+ telemetrySharedBuffer: options.telemetrySharedBuffer,
2128
+ telemetryRingCapacity: options.telemetryRingCapacity,
2129
+ });
2130
+ port?.postMessage?.({ type: 'ready', runtimeTarget: 'sonare-rt' });
2131
+ } catch (error) {
2132
+ port?.postMessage?.({
2133
+ type: 'error',
2134
+ message: error instanceof Error ? error.message : String(error),
2135
+ });
2136
+ }
2137
+ }
2138
+ }
2139
+ scope.registerProcessor(name, RegisteredSonareRealtimeEngineWorkletProcessor);
2140
+ }