@libraz/libsonare 1.3.1 → 1.3.3

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 CHANGED
@@ -1,16 +1,33 @@
1
1
  import type {
2
2
  EngineAutomationPoint,
3
+ EngineBus,
4
+ EngineCaptureStatus,
3
5
  EngineClip,
4
6
  EngineMarker,
5
7
  EngineMeterTelemetry,
6
8
  EngineMetronomeConfig,
9
+ EngineMidiClipSchedule,
7
10
  EngineParameterInfo,
8
11
  EngineTelemetry,
12
+ EngineTempoSegment,
13
+ EngineTimeSignatureSegment,
14
+ EngineTrackLane,
15
+ EngineTrackSend,
16
+ EngineTransportState,
17
+ EqBand,
9
18
  MixerRealtimeBuffer,
10
19
  RealtimeVoiceChangerConfigInput,
11
20
  } from './index';
12
- import { engineCapabilities, Mixer, RealtimeEngine, RealtimeVoiceChanger } from './index';
21
+ import {
22
+ engineCapabilities,
23
+ init as initSonareModule,
24
+ isInitialized,
25
+ Mixer,
26
+ RealtimeEngine,
27
+ RealtimeVoiceChanger,
28
+ } from './index';
13
29
  import type { AutomationCurve } from './public_types';
30
+ import type { SonareModule } from './sonare.js';
14
31
  import type { SonareRtModule } from './sonare-rt';
15
32
 
16
33
  // With code-splitting disabled, the worklet bundle carries its own copy of the
@@ -38,6 +55,9 @@ export interface SonareRealtimeEngineWorkletProcessorOptions {
38
55
  runtimeTarget?: 'embind' | 'sonare-rt';
39
56
  rtModuleUrl?: string;
40
57
  rtWasmBinary?: ArrayBuffer | Uint8Array;
58
+ wasmBinary?: ArrayBuffer | Uint8Array;
59
+ initialSyncMessages?: SonareEngineSyncMessage[];
60
+ initialCommands?: SonareEngineCommandRecord[];
41
61
  sampleRate?: number;
42
62
  blockSize?: number;
43
63
  channelCount?: number;
@@ -85,6 +105,7 @@ export interface SonareRealtimeEngineNodeCapabilities {
85
105
  expectedEngineAbiVersion?: number;
86
106
  abiCompatible?: boolean;
87
107
  degradedReason?: string;
108
+ readyMessage?: boolean;
88
109
  }
89
110
 
90
111
  export interface SonareRealtimeEngineNodeOptions
@@ -127,6 +148,7 @@ export interface SonareEngineTransportFacade {
127
148
  seekPpq(ppq: number, sampleTime?: number): boolean;
128
149
  seekSeconds(seconds: number, sampleTime?: number): boolean;
129
150
  setTempo(bpm: number): void;
151
+ setTempoSegments(segments: readonly EngineTempoSegment[]): void;
130
152
  setLoop(startPpq: number, endPpq: number, enabled?: boolean): boolean;
131
153
  }
132
154
 
@@ -135,6 +157,22 @@ type SuspendableAudioContext = BaseAudioContext & {
135
157
  resume?: () => Promise<void>;
136
158
  };
137
159
 
160
+ const ENGINE_MIXER_TARGET_BASE = 0x4d580000;
161
+ const ENGINE_MIXER_PARAM_FADER_DB = 1;
162
+ const ENGINE_MIXER_PARAM_PAN = 2;
163
+
164
+ function engineMixerLaneTarget(laneIndex: number, paramKind: number): number {
165
+ return ENGINE_MIXER_TARGET_BASE | ((laneIndex & 0xff) << 8) | (paramKind & 0xff);
166
+ }
167
+
168
+ function engineMixerBusTarget(busIndex: number, paramKind: number): number {
169
+ return ENGINE_MIXER_TARGET_BASE | (((0xfe - busIndex) & 0xff) << 8) | (paramKind & 0xff);
170
+ }
171
+
172
+ function engineMixerMasterTarget(paramKind: number): number {
173
+ return ENGINE_MIXER_TARGET_BASE | (0xff << 8) | (paramKind & 0xff);
174
+ }
175
+
138
176
  type WorkletInput = readonly (readonly Float32Array[])[];
139
177
  type WorkletOutput = Float32Array[][];
140
178
 
@@ -164,12 +202,19 @@ export type SonareWorkletMessage =
164
202
 
165
203
  export interface SonareWorkletMeterSnapshot {
166
204
  type: 'meter';
205
+ targetId: number;
167
206
  frame: number;
168
207
  peakDbL: number;
169
208
  peakDbR: number;
170
209
  rmsDbL: number;
171
210
  rmsDbR: number;
172
211
  correlation: number;
212
+ truePeakDbL: number;
213
+ truePeakDbR: number;
214
+ momentaryLufs: number;
215
+ shortTermLufs: number;
216
+ integratedLufs: number;
217
+ gainReductionDb: number;
173
218
  }
174
219
 
175
220
  export interface SonareWorkletSpectrumSnapshot {
@@ -184,12 +229,14 @@ export type SonareWorkletTransportMessage =
184
229
  | SonareEngineTelemetryRecord;
185
230
 
186
231
  export const SONARE_METER_RING_HEADER_INTS = 4;
187
- // Record layout: [frameLo, peakDbL, peakDbR, rmsDbL, rmsDbR, correlation, frameHi].
232
+ // Record layout: [frameLo, frameHi, targetId, peakDbL, peakDbR, rmsDbL, rmsDbR,
233
+ // correlation, truePeakDbL, truePeakDbR, momentaryLufs, shortTermLufs,
234
+ // integratedLufs, gainReductionDb].
188
235
  // The sample-frame index is monotonically increasing and quickly exceeds the
189
236
  // 2^24 exact-integer range of a single Float32 slot (~349 s at 48 kHz), so it is
190
237
  // stored split across two Float32 lanes (low 24 bits + high bits) for exact
191
238
  // reconstruction. See encodeFrameLo/encodeFrameHi/decodeFrame.
192
- export const SONARE_METER_RING_RECORD_FLOATS = 7;
239
+ export const SONARE_METER_RING_RECORD_FLOATS = 14;
193
240
  export const SONARE_SPECTRUM_RING_HEADER_INTS = 5;
194
241
 
195
242
  /** Base for splitting a frame index into two exactly-representable Float32 lanes. */
@@ -259,7 +306,13 @@ export enum SonareEngineTelemetryError {
259
306
  }
260
307
 
261
308
  interface WorkletTransport {
262
- postMessage?: (message: SonareWorkletTransportMessage) => void;
309
+ postMessage?: (
310
+ message:
311
+ | SonareWorkletTransportMessage
312
+ | SonareEngineCaptureResponseMessage
313
+ | SonareEngineTransportResponseMessage,
314
+ transfer?: Transferable[],
315
+ ) => void;
263
316
  onMeter?: (meter: SonareWorkletMeterSnapshot) => void;
264
317
  onSpectrum?: (spectrum: SonareWorkletSpectrumSnapshot) => void;
265
318
  }
@@ -331,6 +384,17 @@ export interface SonareEngineSyncClipsMessage {
331
384
  clips: EngineClip[];
332
385
  }
333
386
 
387
+ export interface SonareEngineSyncClipsDeltaMessage {
388
+ type: 'syncClipsDelta';
389
+ upserts: EngineClip[];
390
+ removeIds: number[];
391
+ }
392
+
393
+ export interface SonareEngineSyncMidiClipsMessage {
394
+ type: 'syncMidiClips';
395
+ clips: EngineMidiClipSchedule[];
396
+ }
397
+
334
398
  export interface SonareEngineSyncMarkersMessage {
335
399
  type: 'syncMarkers';
336
400
  markers: EngineMarker[];
@@ -347,11 +411,135 @@ export interface SonareEngineSyncAutomationMessage {
347
411
  points: EngineAutomationPoint[];
348
412
  }
349
413
 
414
+ export interface SonareEngineSyncTempoMessage {
415
+ type: 'syncTempo';
416
+ bpm: number;
417
+ timeSignature: { numerator: number; denominator: number };
418
+ tempoSegments?: EngineTempoSegment[];
419
+ timeSignatureSegments?: EngineTimeSignatureSegment[];
420
+ }
421
+
422
+ export interface SonareEngineSyncMixerMessage {
423
+ type: 'syncMixer';
424
+ lanes: EngineTrackLane[];
425
+ buses?: EngineBus[];
426
+ trackStrips?: Array<{ trackId: number; sceneJson: string }>;
427
+ busStrips?: Array<{ busId: number; sceneJson: string }>;
428
+ masterStripJson?: string;
429
+ }
430
+
431
+ export interface SonareEngineSyncCaptureMessage {
432
+ type: 'syncCapture';
433
+ bufferFrames: number;
434
+ channels: number;
435
+ source: EngineCaptureStatus['source'];
436
+ recordOffsetSamples: number;
437
+ inputMonitor: { enabled: boolean; gain: number };
438
+ }
439
+
440
+ export interface SonareEngineSyncTrackStripEqBandMessage {
441
+ type: 'syncTrackStripEqBand';
442
+ trackId: number;
443
+ bandIndex: number;
444
+ bandJson: string;
445
+ }
446
+
447
+ export interface SonareEngineSyncMasterStripEqBandMessage {
448
+ type: 'syncMasterStripEqBand';
449
+ bandIndex: number;
450
+ bandJson: string;
451
+ }
452
+
453
+ export interface SonareEngineSyncTrackStripInsertBypassedMessage {
454
+ type: 'syncTrackStripInsertBypassed';
455
+ trackId: number;
456
+ insertIndex: number;
457
+ bypassed: boolean;
458
+ resetOnBypass: boolean;
459
+ }
460
+
461
+ export interface SonareEngineSyncMasterStripInsertBypassedMessage {
462
+ type: 'syncMasterStripInsertBypassed';
463
+ insertIndex: number;
464
+ bypassed: boolean;
465
+ resetOnBypass: boolean;
466
+ }
467
+
468
+ export interface SonareEngineSyncBuiltinInstrumentMessage {
469
+ type: 'syncBuiltinInstrument';
470
+ destinationId: number;
471
+ config: { destinationId?: number } & Record<string, unknown>;
472
+ }
473
+
474
+ export interface SonareEngineSyncSynthInstrumentMessage {
475
+ type: 'syncSynthInstrument';
476
+ destinationId: number;
477
+ patch: Record<string, unknown> | string;
478
+ }
479
+
480
+ export interface SonareEngineSyncSf2InstrumentMessage {
481
+ type: 'syncSf2Instrument';
482
+ destinationId: number;
483
+ config: { destinationId?: number; gain?: number; polyphony?: number };
484
+ }
485
+
486
+ export interface SonareEngineSyncLoadSoundFontMessage {
487
+ type: 'syncLoadSoundFont';
488
+ data: Uint8Array;
489
+ }
490
+
491
+ export interface SonareEngineSyncMidiNoteMessage {
492
+ type: 'syncMidiNoteOn' | 'syncMidiNoteOff';
493
+ destinationId: number;
494
+ group: number;
495
+ channel: number;
496
+ note: number;
497
+ velocity: number;
498
+ renderFrame: number;
499
+ }
500
+
501
+ export interface SonareEngineSyncMidiCcMessage {
502
+ type: 'syncMidiCc';
503
+ destinationId: number;
504
+ group: number;
505
+ channel: number;
506
+ controller: number;
507
+ value: number;
508
+ renderFrame: number;
509
+ }
510
+
511
+ export interface SonareEngineSyncMidiPanicMessage {
512
+ type: 'syncMidiPanic';
513
+ renderFrame: number;
514
+ }
515
+
516
+ type SonareEngineInstrumentSyncMessage =
517
+ | SonareEngineSyncBuiltinInstrumentMessage
518
+ | SonareEngineSyncSynthInstrumentMessage
519
+ | SonareEngineSyncSf2InstrumentMessage
520
+ | SonareEngineSyncLoadSoundFontMessage;
521
+
350
522
  export type SonareEngineSyncMessage =
351
523
  | SonareEngineSyncClipsMessage
524
+ | SonareEngineSyncClipsDeltaMessage
525
+ | SonareEngineSyncMidiClipsMessage
352
526
  | SonareEngineSyncMarkersMessage
353
527
  | SonareEngineSyncMetronomeMessage
354
- | SonareEngineSyncAutomationMessage;
528
+ | SonareEngineSyncAutomationMessage
529
+ | SonareEngineSyncTempoMessage
530
+ | SonareEngineSyncMixerMessage
531
+ | SonareEngineSyncCaptureMessage
532
+ | SonareEngineSyncTrackStripEqBandMessage
533
+ | SonareEngineSyncMasterStripEqBandMessage
534
+ | SonareEngineSyncTrackStripInsertBypassedMessage
535
+ | SonareEngineSyncMasterStripInsertBypassedMessage
536
+ | SonareEngineSyncBuiltinInstrumentMessage
537
+ | SonareEngineSyncSynthInstrumentMessage
538
+ | SonareEngineSyncSf2InstrumentMessage
539
+ | SonareEngineSyncLoadSoundFontMessage
540
+ | SonareEngineSyncMidiNoteMessage
541
+ | SonareEngineSyncMidiCcMessage
542
+ | SonareEngineSyncMidiPanicMessage;
355
543
 
356
544
  export interface SonareEngineTelemetryRecord {
357
545
  type: SonareEngineTelemetryType | number;
@@ -397,12 +585,41 @@ interface SharedSpectrumRingWriter {
397
585
  }
398
586
 
399
587
  interface WorkletPort {
400
- postMessage?: (message: unknown) => void;
588
+ postMessage?: (message: unknown, transfer?: Transferable[]) => void;
401
589
  onmessage?: (event: { data: unknown }) => void;
402
590
  addEventListener?: (type: 'message', listener: (event: { data: unknown }) => void) => void;
403
591
  start?: () => void;
404
592
  }
405
593
 
594
+ export interface SonareEngineCaptureRequestMessage {
595
+ type: 'captureRequest';
596
+ requestId: number;
597
+ op: 'status' | 'read' | 'reset';
598
+ }
599
+
600
+ export interface SonareEngineCaptureResponseMessage {
601
+ type: 'captureResponse';
602
+ requestId: number;
603
+ ok: boolean;
604
+ status?: EngineCaptureStatus;
605
+ channels?: Float32Array[] | number[][];
606
+ error?: string;
607
+ }
608
+
609
+ export interface SonareEngineTransportRequestMessage {
610
+ type: 'transportRequest';
611
+ requestId: number;
612
+ op: 'state';
613
+ }
614
+
615
+ export interface SonareEngineTransportResponseMessage {
616
+ type: 'transportResponse';
617
+ requestId: number;
618
+ ok: boolean;
619
+ state?: EngineTransportState;
620
+ error?: string;
621
+ }
622
+
406
623
  function toDb(value: number): number {
407
624
  return value > 0 ? 20 * Math.log10(value) : Number.NEGATIVE_INFINITY;
408
625
  }
@@ -432,9 +649,68 @@ function isEngineSyncMessage(value: unknown): value is SonareEngineSyncMessage {
432
649
  }
433
650
  return (
434
651
  value.type === 'syncClips' ||
652
+ value.type === 'syncClipsDelta' ||
653
+ value.type === 'syncMidiClips' ||
435
654
  value.type === 'syncMarkers' ||
436
655
  value.type === 'syncMetronome' ||
437
- value.type === 'syncAutomation'
656
+ value.type === 'syncAutomation' ||
657
+ value.type === 'syncTempo' ||
658
+ value.type === 'syncMixer' ||
659
+ value.type === 'syncCapture' ||
660
+ value.type === 'syncTrackStripEqBand' ||
661
+ value.type === 'syncMasterStripEqBand' ||
662
+ value.type === 'syncTrackStripInsertBypassed' ||
663
+ value.type === 'syncMasterStripInsertBypassed' ||
664
+ value.type === 'syncBuiltinInstrument' ||
665
+ value.type === 'syncSynthInstrument' ||
666
+ value.type === 'syncSf2Instrument' ||
667
+ value.type === 'syncLoadSoundFont' ||
668
+ value.type === 'syncMidiNoteOn' ||
669
+ value.type === 'syncMidiNoteOff' ||
670
+ value.type === 'syncMidiCc' ||
671
+ value.type === 'syncMidiPanic'
672
+ );
673
+ }
674
+
675
+ function isEngineCaptureRequestMessage(value: unknown): value is SonareEngineCaptureRequestMessage {
676
+ return (
677
+ isRecord(value) &&
678
+ value.type === 'captureRequest' &&
679
+ typeof value.requestId === 'number' &&
680
+ (value.op === 'status' || value.op === 'read' || value.op === 'reset')
681
+ );
682
+ }
683
+
684
+ function isEngineCaptureResponseMessage(
685
+ value: unknown,
686
+ ): value is SonareEngineCaptureResponseMessage {
687
+ return (
688
+ isRecord(value) &&
689
+ value.type === 'captureResponse' &&
690
+ typeof value.requestId === 'number' &&
691
+ typeof value.ok === 'boolean'
692
+ );
693
+ }
694
+
695
+ function isEngineTransportRequestMessage(
696
+ value: unknown,
697
+ ): value is SonareEngineTransportRequestMessage {
698
+ return (
699
+ isRecord(value) &&
700
+ value.type === 'transportRequest' &&
701
+ typeof value.requestId === 'number' &&
702
+ value.op === 'state'
703
+ );
704
+ }
705
+
706
+ function isEngineTransportResponseMessage(
707
+ value: unknown,
708
+ ): value is SonareEngineTransportResponseMessage {
709
+ return (
710
+ isRecord(value) &&
711
+ value.type === 'transportResponse' &&
712
+ typeof value.requestId === 'number' &&
713
+ typeof value.ok === 'boolean'
438
714
  );
439
715
  }
440
716
 
@@ -467,7 +743,8 @@ function isMeterSnapshot(value: unknown): value is SonareWorkletMeterSnapshot {
467
743
  typeof value.peakDbR === 'number' &&
468
744
  typeof value.rmsDbL === 'number' &&
469
745
  typeof value.rmsDbR === 'number' &&
470
- typeof value.correlation === 'number'
746
+ typeof value.correlation === 'number' &&
747
+ (typeof value.targetId === 'number' || value.targetId === undefined)
471
748
  );
472
749
  }
473
750
 
@@ -495,19 +772,27 @@ export function readSonareMeterRingBuffer(
495
772
  readIndex = 0,
496
773
  ): SonareMeterRingReadResult {
497
774
  const writeIndex = Atomics.load(ring.header, 0);
775
+ const recordFloats = Atomics.load(ring.header, 2) || SONARE_METER_RING_RECORD_FLOATS;
498
776
  const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
499
777
  const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
500
778
  const meters: SonareWorkletMeterSnapshot[] = [];
501
779
  for (let index = firstReadable; index < writeIndex; index++) {
502
- const offset = (index % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
780
+ const offset = (index % ring.capacity) * recordFloats;
503
781
  meters.push({
504
782
  type: 'meter',
505
- frame: decodeFrame(ring.records[offset], ring.records[offset + 6]),
506
- peakDbL: ring.records[offset + 1],
507
- peakDbR: ring.records[offset + 2],
508
- rmsDbL: ring.records[offset + 3],
509
- rmsDbR: ring.records[offset + 4],
510
- correlation: ring.records[offset + 5],
783
+ frame: decodeFrame(ring.records[offset], ring.records[offset + 1]),
784
+ targetId: ring.records[offset + 2],
785
+ peakDbL: ring.records[offset + 3],
786
+ peakDbR: ring.records[offset + 4],
787
+ rmsDbL: ring.records[offset + 5],
788
+ rmsDbR: ring.records[offset + 6],
789
+ correlation: ring.records[offset + 7],
790
+ truePeakDbL: ring.records[offset + 8],
791
+ truePeakDbR: ring.records[offset + 9],
792
+ momentaryLufs: ring.records[offset + 10],
793
+ shortTermLufs: ring.records[offset + 11],
794
+ integratedLufs: ring.records[offset + 12],
795
+ gainReductionDb: ring.records[offset + 13],
511
796
  });
512
797
  }
513
798
  return { nextReadIndex: writeIndex, meters };
@@ -841,12 +1126,19 @@ function telemetryFromEngine(telemetry: EngineTelemetry): SonareEngineTelemetryR
841
1126
  function meterFromEngine(meter: EngineMeterTelemetry): SonareWorkletMeterSnapshot {
842
1127
  return {
843
1128
  type: 'meter',
1129
+ targetId: meter.targetId,
844
1130
  frame: meter.renderFrame,
845
1131
  peakDbL: meter.peakDbL,
846
1132
  peakDbR: meter.peakDbR,
847
1133
  rmsDbL: meter.rmsDbL,
848
1134
  rmsDbR: meter.rmsDbR,
849
1135
  correlation: meter.correlation,
1136
+ truePeakDbL: meter.truePeakDbL,
1137
+ truePeakDbR: meter.truePeakDbR,
1138
+ momentaryLufs: meter.momentaryLufs,
1139
+ shortTermLufs: meter.shortTermLufs,
1140
+ integratedLufs: meter.integratedLufs,
1141
+ gainReductionDb: meter.gainReductionDb,
850
1142
  };
851
1143
  }
852
1144
 
@@ -1041,12 +1333,19 @@ export class SonareWorkletProcessor {
1041
1333
  const denominator = Math.sqrt(sumL * sumR);
1042
1334
  const meter: SonareWorkletMeterSnapshot = {
1043
1335
  type: 'meter',
1336
+ targetId: 0,
1044
1337
  frame: this.processedFrames,
1045
1338
  peakDbL: toDb(peakL),
1046
1339
  peakDbR: toDb(peakR),
1047
1340
  rmsDbL: toDb(rmsL),
1048
1341
  rmsDbR: toDb(rmsR),
1049
1342
  correlation: denominator > 0 ? sumLR / denominator : 0,
1343
+ truePeakDbL: toDb(peakL),
1344
+ truePeakDbR: toDb(peakR),
1345
+ momentaryLufs: Number.NaN,
1346
+ shortTermLufs: Number.NaN,
1347
+ integratedLufs: Number.NaN,
1348
+ gainReductionDb: Number.NaN,
1050
1349
  };
1051
1350
  this.transport.onMeter?.(meter);
1052
1351
  if (this.meterRing) {
@@ -1064,12 +1363,19 @@ export class SonareWorkletProcessor {
1064
1363
  const writeIndex = Atomics.load(ring.header, 0);
1065
1364
  const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
1066
1365
  ring.records[offset] = encodeFrameLo(meter.frame);
1067
- ring.records[offset + 1] = meter.peakDbL;
1068
- ring.records[offset + 2] = meter.peakDbR;
1069
- ring.records[offset + 3] = meter.rmsDbL;
1070
- ring.records[offset + 4] = meter.rmsDbR;
1071
- ring.records[offset + 5] = meter.correlation;
1072
- ring.records[offset + 6] = encodeFrameHi(meter.frame);
1366
+ ring.records[offset + 1] = encodeFrameHi(meter.frame);
1367
+ ring.records[offset + 2] = meter.targetId;
1368
+ ring.records[offset + 3] = meter.peakDbL;
1369
+ ring.records[offset + 4] = meter.peakDbR;
1370
+ ring.records[offset + 5] = meter.rmsDbL;
1371
+ ring.records[offset + 6] = meter.rmsDbR;
1372
+ ring.records[offset + 7] = meter.correlation;
1373
+ ring.records[offset + 8] = meter.truePeakDbL;
1374
+ ring.records[offset + 9] = meter.truePeakDbR;
1375
+ ring.records[offset + 10] = meter.momentaryLufs;
1376
+ ring.records[offset + 11] = meter.shortTermLufs;
1377
+ ring.records[offset + 12] = meter.integratedLufs;
1378
+ ring.records[offset + 13] = meter.gainReductionDb;
1073
1379
  Atomics.store(ring.header, 0, writeIndex + 1);
1074
1380
  // writeIndex is a free-running monotonic counter, so an overflow guard here
1075
1381
  // would fire on essentially every write past the first `capacity` records
@@ -1179,6 +1485,7 @@ export class SonareRealtimeEngineWorkletProcessor {
1179
1485
  // allocated per render quantum (the old engine.process() round-tripped fresh
1180
1486
  // arrays on both heaps every block, an RT-safety hazard).
1181
1487
  private channelBuffers: Float32Array[];
1488
+ private readonly liveClips = new Map<number, EngineClip>();
1182
1489
 
1183
1490
  constructor(
1184
1491
  options: SonareRealtimeEngineWorkletProcessorOptions = {},
@@ -1318,8 +1625,28 @@ export class SonareRealtimeEngineWorkletProcessor {
1318
1625
  }
1319
1626
  switch (message.type) {
1320
1627
  case 'syncClips':
1628
+ this.liveClips.clear();
1629
+ for (const clip of message.clips) {
1630
+ if (clip.id !== undefined) {
1631
+ this.liveClips.set(clip.id, clip);
1632
+ }
1633
+ }
1321
1634
  this.engine.setClips(message.clips);
1322
1635
  break;
1636
+ case 'syncClipsDelta':
1637
+ for (const clipId of message.removeIds) {
1638
+ this.liveClips.delete(clipId);
1639
+ }
1640
+ for (const clip of message.upserts) {
1641
+ if (clip.id !== undefined) {
1642
+ this.liveClips.set(clip.id, clip);
1643
+ }
1644
+ }
1645
+ this.engine.setClips(Array.from(this.liveClips.values()));
1646
+ break;
1647
+ case 'syncMidiClips':
1648
+ this.engine.setMidiClips(message.clips);
1649
+ break;
1323
1650
  case 'syncMarkers':
1324
1651
  this.engine.setMarkers(message.markers);
1325
1652
  break;
@@ -1330,6 +1657,186 @@ export class SonareRealtimeEngineWorkletProcessor {
1330
1657
  case 'syncAutomation':
1331
1658
  this.engine.setAutomationLane(message.paramId, message.points);
1332
1659
  break;
1660
+ case 'syncTempo':
1661
+ if (message.tempoSegments) {
1662
+ this.engine.setTempoSegments(message.tempoSegments);
1663
+ } else {
1664
+ this.engine.setTempo(message.bpm);
1665
+ }
1666
+ if (message.timeSignatureSegments) {
1667
+ this.engine.setTimeSignatureSegments(message.timeSignatureSegments);
1668
+ } else {
1669
+ this.engine.setTimeSignature(
1670
+ message.timeSignature.numerator,
1671
+ message.timeSignature.denominator,
1672
+ );
1673
+ }
1674
+ break;
1675
+ case 'syncMixer':
1676
+ if (message.buses) {
1677
+ this.engine.setTrackBuses(message.buses);
1678
+ }
1679
+ this.engine.setTrackLanes(message.lanes);
1680
+ for (const strip of message.trackStrips ?? []) {
1681
+ this.engine.setTrackStripJson(strip.trackId, strip.sceneJson);
1682
+ }
1683
+ for (const strip of message.busStrips ?? []) {
1684
+ this.engine.setBusStripJson(strip.busId, strip.sceneJson);
1685
+ }
1686
+ if (message.masterStripJson) {
1687
+ this.engine.setMasterStripJson(message.masterStripJson);
1688
+ }
1689
+ break;
1690
+ case 'syncCapture':
1691
+ this.engine.setCaptureBuffer(message.channels, message.bufferFrames);
1692
+ this.engine.setCaptureSource(message.source);
1693
+ this.engine.setRecordOffsetSamples(message.recordOffsetSamples);
1694
+ this.engine.setInputMonitor(message.inputMonitor.enabled, message.inputMonitor.gain);
1695
+ break;
1696
+ case 'syncTrackStripEqBand':
1697
+ this.engine.setTrackStripEqBandJson(message.trackId, message.bandIndex, message.bandJson);
1698
+ break;
1699
+ case 'syncMasterStripEqBand':
1700
+ this.engine.setMasterStripEqBandJson(message.bandIndex, message.bandJson);
1701
+ break;
1702
+ case 'syncTrackStripInsertBypassed':
1703
+ this.engine.setTrackStripInsertBypassed(
1704
+ message.trackId,
1705
+ message.insertIndex,
1706
+ message.bypassed,
1707
+ message.resetOnBypass,
1708
+ );
1709
+ break;
1710
+ case 'syncMasterStripInsertBypassed':
1711
+ this.engine.setMasterStripInsertBypassed(
1712
+ message.insertIndex,
1713
+ message.bypassed,
1714
+ message.resetOnBypass,
1715
+ );
1716
+ break;
1717
+ case 'syncBuiltinInstrument':
1718
+ this.engine.setBuiltinInstrument(message.config, message.destinationId);
1719
+ break;
1720
+ case 'syncSynthInstrument':
1721
+ this.engine.setSynthInstrument(message.patch, message.destinationId);
1722
+ break;
1723
+ case 'syncLoadSoundFont':
1724
+ this.engine.loadSoundFont(message.data);
1725
+ break;
1726
+ case 'syncSf2Instrument':
1727
+ this.engine.setSf2Instrument(message.config, message.destinationId);
1728
+ break;
1729
+ case 'syncMidiNoteOn':
1730
+ this.engine.pushMidiNoteOn(
1731
+ message.destinationId,
1732
+ message.group,
1733
+ message.channel,
1734
+ message.note,
1735
+ message.velocity,
1736
+ message.renderFrame,
1737
+ );
1738
+ break;
1739
+ case 'syncMidiNoteOff':
1740
+ this.engine.pushMidiNoteOff(
1741
+ message.destinationId,
1742
+ message.group,
1743
+ message.channel,
1744
+ message.note,
1745
+ message.velocity,
1746
+ message.renderFrame,
1747
+ );
1748
+ break;
1749
+ case 'syncMidiCc':
1750
+ this.engine.pushMidiCc(
1751
+ message.destinationId,
1752
+ message.group,
1753
+ message.channel,
1754
+ message.controller,
1755
+ message.value,
1756
+ message.renderFrame,
1757
+ );
1758
+ break;
1759
+ case 'syncMidiPanic':
1760
+ this.engine.pushMidiPanic(message.renderFrame);
1761
+ break;
1762
+ }
1763
+ }
1764
+
1765
+ receiveCaptureRequest(message: SonareEngineCaptureRequestMessage): void {
1766
+ if (this.closed) {
1767
+ return;
1768
+ }
1769
+ try {
1770
+ if (message.op === 'status') {
1771
+ const status = this.engine.captureStatus();
1772
+ this.transport?.postMessage?.({
1773
+ type: 'captureResponse',
1774
+ requestId: message.requestId,
1775
+ ok: true,
1776
+ status: {
1777
+ capturedFrames: status.capturedFrames,
1778
+ overflowCount: status.overflowCount,
1779
+ armed: status.armed,
1780
+ punchEnabled: status.punchEnabled,
1781
+ source: status.source,
1782
+ recordOffsetSamples: status.recordOffsetSamples,
1783
+ },
1784
+ } satisfies SonareEngineCaptureResponseMessage);
1785
+ return;
1786
+ }
1787
+ if (message.op === 'read') {
1788
+ const captured = this.engine.capturedAudio();
1789
+ const channels: number[][] = [];
1790
+ for (let ch = 0; ch < captured.length; ch++) {
1791
+ const source = captured[ch];
1792
+ const copy: number[] = [];
1793
+ for (let i = 0; i < source.length; i++) {
1794
+ copy.push(Number(source[i]));
1795
+ }
1796
+ channels.push(copy);
1797
+ }
1798
+ this.transport?.postMessage?.({
1799
+ type: 'captureResponse',
1800
+ requestId: message.requestId,
1801
+ ok: true,
1802
+ channels,
1803
+ } satisfies SonareEngineCaptureResponseMessage);
1804
+ return;
1805
+ }
1806
+ this.engine.resetCapture();
1807
+ this.transport?.postMessage?.({
1808
+ type: 'captureResponse',
1809
+ requestId: message.requestId,
1810
+ ok: true,
1811
+ } satisfies SonareEngineCaptureResponseMessage);
1812
+ } catch (error) {
1813
+ this.transport?.postMessage?.({
1814
+ type: 'captureResponse',
1815
+ requestId: message.requestId,
1816
+ ok: false,
1817
+ error: error instanceof Error ? error.message : String(error),
1818
+ } satisfies SonareEngineCaptureResponseMessage);
1819
+ }
1820
+ }
1821
+
1822
+ receiveTransportRequest(message: SonareEngineTransportRequestMessage): void {
1823
+ if (this.closed) {
1824
+ return;
1825
+ }
1826
+ try {
1827
+ this.transport?.postMessage?.({
1828
+ type: 'transportResponse',
1829
+ requestId: message.requestId,
1830
+ ok: true,
1831
+ state: this.engine.getTransportState(),
1832
+ } satisfies SonareEngineTransportResponseMessage);
1833
+ } catch (error) {
1834
+ this.transport?.postMessage?.({
1835
+ type: 'transportResponse',
1836
+ requestId: message.requestId,
1837
+ ok: false,
1838
+ error: error instanceof Error ? error.message : String(error),
1839
+ } satisfies SonareEngineTransportResponseMessage);
1333
1840
  }
1334
1841
  }
1335
1842
 
@@ -1423,6 +1930,14 @@ export class SonareRealtimeEngineWorkletProcessor {
1423
1930
  // (RtPublisher-style swap), so a queued kSeekMarker resolves correctly.
1424
1931
  this.engine.seekMarker(Math.trunc(Number(command.targetId ?? 0)), sampleTime);
1425
1932
  break;
1933
+ case SonareEngineCommandType.SetSoloMute:
1934
+ this.engine.setSoloMute(
1935
+ Math.trunc(Number(command.targetId ?? 0)),
1936
+ Boolean((Number(command.argInt ?? 0) & 0x2) !== 0),
1937
+ Boolean((Number(command.argInt ?? 0) & 0x1) !== 0),
1938
+ sampleTime,
1939
+ );
1940
+ break;
1426
1941
  default:
1427
1942
  this.publishTelemetryRecord({
1428
1943
  type: SonareEngineTelemetryType.Error,
@@ -1457,10 +1972,15 @@ export class SonareRealtimeEngineWorkletProcessor {
1457
1972
  }
1458
1973
  for (const item of this.engine.drainMeterTelemetry(64)) {
1459
1974
  const meter = meterFromEngine(item);
1460
- if (meter.frame - this.lastMeterFrame < this.meterIntervalFrames) {
1975
+ if (
1976
+ meter.frame !== this.lastMeterFrame &&
1977
+ meter.frame - this.lastMeterFrame < this.meterIntervalFrames
1978
+ ) {
1461
1979
  continue;
1462
1980
  }
1463
- this.lastMeterFrame = meter.frame;
1981
+ if (meter.frame !== this.lastMeterFrame) {
1982
+ this.lastMeterFrame = meter.frame;
1983
+ }
1464
1984
  // Prefer the lock-free SAB meter ring (matching the telemetry path and
1465
1985
  // SonareWorkletProcessor); only fall back to structured-clone postMessage
1466
1986
  // when no ring was provided, so we do not allocate/post from the audio
@@ -1482,12 +2002,19 @@ export class SonareRealtimeEngineWorkletProcessor {
1482
2002
  const writeIndex = Atomics.load(ring.header, 0);
1483
2003
  const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
1484
2004
  ring.records[offset] = encodeFrameLo(meter.frame);
1485
- ring.records[offset + 1] = meter.peakDbL;
1486
- ring.records[offset + 2] = meter.peakDbR;
1487
- ring.records[offset + 3] = meter.rmsDbL;
1488
- ring.records[offset + 4] = meter.rmsDbR;
1489
- ring.records[offset + 5] = meter.correlation;
1490
- ring.records[offset + 6] = encodeFrameHi(meter.frame);
2005
+ ring.records[offset + 1] = encodeFrameHi(meter.frame);
2006
+ ring.records[offset + 2] = meter.targetId;
2007
+ ring.records[offset + 3] = meter.peakDbL;
2008
+ ring.records[offset + 4] = meter.peakDbR;
2009
+ ring.records[offset + 5] = meter.rmsDbL;
2010
+ ring.records[offset + 6] = meter.rmsDbR;
2011
+ ring.records[offset + 7] = meter.correlation;
2012
+ ring.records[offset + 8] = meter.truePeakDbL;
2013
+ ring.records[offset + 9] = meter.truePeakDbR;
2014
+ ring.records[offset + 10] = meter.momentaryLufs;
2015
+ ring.records[offset + 11] = meter.shortTermLufs;
2016
+ ring.records[offset + 12] = meter.integratedLufs;
2017
+ ring.records[offset + 13] = meter.gainReductionDb;
1491
2018
  Atomics.store(ring.header, 0, writeIndex + 1);
1492
2019
  // writeIndex is a free-running monotonic counter, so an overflow guard here
1493
2020
  // would fire on essentially every write past the first `capacity` records
@@ -1657,12 +2184,32 @@ export class SonareRtRealtimeEngineRuntime {
1657
2184
  this.metronomeConfig.clickSamples,
1658
2185
  );
1659
2186
  break;
2187
+ case 'syncTempo':
2188
+ this.module._sonare_rt_engine_set_tempo(this.engine, message.bpm);
2189
+ break;
1660
2190
  case 'syncClips':
2191
+ case 'syncClipsDelta':
2192
+ case 'syncMidiClips':
1661
2193
  case 'syncMarkers':
1662
2194
  case 'syncAutomation':
2195
+ case 'syncMixer':
2196
+ case 'syncCapture':
2197
+ case 'syncTrackStripEqBand':
2198
+ case 'syncMasterStripEqBand':
2199
+ case 'syncTrackStripInsertBypassed':
2200
+ case 'syncMasterStripInsertBypassed':
2201
+ case 'syncBuiltinInstrument':
2202
+ case 'syncSynthInstrument':
2203
+ case 'syncSf2Instrument':
2204
+ case 'syncLoadSoundFont':
2205
+ case 'syncMidiNoteOn':
2206
+ case 'syncMidiNoteOff':
2207
+ case 'syncMidiCc':
2208
+ case 'syncMidiPanic':
1663
2209
  // The sonare-rt C ABI exposes no set_clips / set_markers /
1664
- // set_automation_lane, so these mutations cannot reach a live sonare-rt
1665
- // engine. Surface a clear telemetry error rather than silently dropping.
2210
+ // set_automation_lane / set_track_lanes, so these mutations cannot
2211
+ // reach a live sonare-rt engine. Surface a clear telemetry error rather
2212
+ // than silently dropping.
1666
2213
  if (this.telemetryRing) {
1667
2214
  writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1668
2215
  type: SonareEngineTelemetryType.Error,
@@ -1678,6 +2225,30 @@ export class SonareRtRealtimeEngineRuntime {
1678
2225
  }
1679
2226
  }
1680
2227
 
2228
+ receiveCaptureRequest(message: SonareEngineCaptureRequestMessage, port?: WorkletPort): void {
2229
+ if (this.closed) {
2230
+ return;
2231
+ }
2232
+ port?.postMessage?.({
2233
+ type: 'captureResponse',
2234
+ requestId: message.requestId,
2235
+ ok: false,
2236
+ error: 'Capture read-back is not supported by the sonare-rt runtime.',
2237
+ } satisfies SonareEngineCaptureResponseMessage);
2238
+ }
2239
+
2240
+ receiveTransportRequest(message: SonareEngineTransportRequestMessage, port?: WorkletPort): void {
2241
+ if (this.closed) {
2242
+ return;
2243
+ }
2244
+ port?.postMessage?.({
2245
+ type: 'transportResponse',
2246
+ requestId: message.requestId,
2247
+ ok: false,
2248
+ error: 'Transport state read-back is not supported by the sonare-rt runtime.',
2249
+ } satisfies SonareEngineTransportResponseMessage);
2250
+ }
2251
+
1681
2252
  destroy(): void {
1682
2253
  if (this.closed) {
1683
2254
  return;
@@ -1880,6 +2451,22 @@ export class SonareRealtimeEngineNode {
1880
2451
  private meterReadIndex = 0;
1881
2452
  private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
1882
2453
  private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
2454
+ private captureRequestId = 1;
2455
+ private readonly captureRequests = new Map<
2456
+ number,
2457
+ {
2458
+ resolve: (response: SonareEngineCaptureResponseMessage) => void;
2459
+ reject: (reason?: unknown) => void;
2460
+ }
2461
+ >();
2462
+ private transportRequestId = 1;
2463
+ private readonly transportRequests = new Map<
2464
+ number,
2465
+ {
2466
+ resolve: (response: SonareEngineTransportResponseMessage) => void;
2467
+ reject: (reason?: unknown) => void;
2468
+ }
2469
+ >();
1883
2470
  private resolveReady!: () => void;
1884
2471
  private rejectReady!: (reason?: unknown) => void;
1885
2472
  private destroyed = false;
@@ -1900,11 +2487,31 @@ export class SonareRealtimeEngineNode {
1900
2487
  this.resolveReady = resolve;
1901
2488
  this.rejectReady = reject;
1902
2489
  });
1903
- if (capabilities.runtimeTarget !== 'sonare-rt') {
2490
+ if (!capabilities.readyMessage) {
1904
2491
  this.resolveReady();
1905
2492
  }
1906
2493
  this.node.port.onmessage = (event: MessageEvent<unknown>) => {
1907
- if (isEngineTelemetryRecord(event.data)) {
2494
+ if (isEngineCaptureResponseMessage(event.data)) {
2495
+ const pending = this.captureRequests.get(event.data.requestId);
2496
+ if (pending) {
2497
+ this.captureRequests.delete(event.data.requestId);
2498
+ if (event.data.ok) {
2499
+ pending.resolve(event.data);
2500
+ } else {
2501
+ pending.reject(new Error(event.data.error ?? 'Capture request failed'));
2502
+ }
2503
+ }
2504
+ } else if (isEngineTransportResponseMessage(event.data)) {
2505
+ const pending = this.transportRequests.get(event.data.requestId);
2506
+ if (pending) {
2507
+ this.transportRequests.delete(event.data.requestId);
2508
+ if (event.data.ok) {
2509
+ pending.resolve(event.data);
2510
+ } else {
2511
+ pending.reject(new Error(event.data.error ?? 'Transport request failed'));
2512
+ }
2513
+ }
2514
+ } else if (isEngineTelemetryRecord(event.data)) {
1908
2515
  this.emitTelemetry(event.data);
1909
2516
  } else if (isMeterSnapshot(event.data)) {
1910
2517
  this.emitMeter(event.data);
@@ -1988,6 +2595,9 @@ export class SonareRealtimeEngineNode {
1988
2595
  telemetryRingCapacity: telemetryRing?.capacity,
1989
2596
  meterSharedBuffer: meterRing?.sharedBuffer,
1990
2597
  meterRingCapacity: meterRing?.capacity,
2598
+ wasmBinary: options.wasmBinary,
2599
+ initialSyncMessages: options.initialSyncMessages,
2600
+ initialCommands: options.initialCommands,
1991
2601
  };
1992
2602
  const factory =
1993
2603
  options.nodeFactory ??
@@ -2011,6 +2621,9 @@ export class SonareRealtimeEngineNode {
2011
2621
  expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
2012
2622
  abiCompatible: detectedCapabilities?.abiCompatible,
2013
2623
  degradedReason,
2624
+ readyMessage:
2625
+ runtimeTarget === 'sonare-rt' ||
2626
+ (runtimeTarget === 'embind' && moduleUrl !== undefined && !options.nodeFactory),
2014
2627
  },
2015
2628
  commandRing,
2016
2629
  telemetryRing,
@@ -2053,6 +2666,36 @@ export class SonareRealtimeEngineNode {
2053
2666
  return true;
2054
2667
  }
2055
2668
 
2669
+ requestCaptureStatus(): Promise<EngineCaptureStatus> {
2670
+ return this.sendCaptureRequest('status').then((response) => {
2671
+ if (!response.status) {
2672
+ throw new Error('Capture status response is missing status.');
2673
+ }
2674
+ return response.status;
2675
+ });
2676
+ }
2677
+
2678
+ requestCapturedAudio(): Promise<Float32Array[]> {
2679
+ return this.sendCaptureRequest('read').then((response) =>
2680
+ (response.channels ?? []).map((channel) =>
2681
+ channel instanceof Float32Array ? channel : new Float32Array(channel),
2682
+ ),
2683
+ );
2684
+ }
2685
+
2686
+ requestCaptureReset(): Promise<void> {
2687
+ return this.sendCaptureRequest('reset').then(() => undefined);
2688
+ }
2689
+
2690
+ requestTransportState(): Promise<EngineTransportState> {
2691
+ return this.sendTransportRequest().then((response) => {
2692
+ if (!response.state) {
2693
+ throw new Error('Transport state response is missing state.');
2694
+ }
2695
+ return response.state;
2696
+ });
2697
+ }
2698
+
2056
2699
  pollTelemetry(): SonareEngineTelemetryRecord[] {
2057
2700
  if (!this.telemetryRing) {
2058
2701
  return [];
@@ -2101,6 +2744,14 @@ export class SonareRealtimeEngineNode {
2101
2744
  this.destroyed = true;
2102
2745
  this.node.port.postMessage({ type: SonareEngineCommandType.TransportStop, sampleTime: -1 });
2103
2746
  this.node.disconnect();
2747
+ for (const pending of this.captureRequests.values()) {
2748
+ pending.reject(new Error('Realtime engine node is destroyed.'));
2749
+ }
2750
+ this.captureRequests.clear();
2751
+ for (const pending of this.transportRequests.values()) {
2752
+ pending.reject(new Error('Realtime engine node is destroyed.'));
2753
+ }
2754
+ this.transportRequests.clear();
2104
2755
  this.telemetryListeners.clear();
2105
2756
  this.meterListeners.clear();
2106
2757
  }
@@ -2116,6 +2767,32 @@ export class SonareRealtimeEngineNode {
2116
2767
  listener(meter);
2117
2768
  }
2118
2769
  }
2770
+
2771
+ private sendCaptureRequest(
2772
+ op: SonareEngineCaptureRequestMessage['op'],
2773
+ ): Promise<SonareEngineCaptureResponseMessage> {
2774
+ if (this.destroyed) {
2775
+ return Promise.reject(new Error('Realtime engine node is destroyed.'));
2776
+ }
2777
+ const requestId = this.captureRequestId++;
2778
+ const promise = new Promise<SonareEngineCaptureResponseMessage>((resolve, reject) => {
2779
+ this.captureRequests.set(requestId, { resolve, reject });
2780
+ });
2781
+ this.node.port.postMessage({ type: 'captureRequest', requestId, op });
2782
+ return promise;
2783
+ }
2784
+
2785
+ private sendTransportRequest(): Promise<SonareEngineTransportResponseMessage> {
2786
+ if (this.destroyed) {
2787
+ return Promise.reject(new Error('Realtime engine node is destroyed.'));
2788
+ }
2789
+ const requestId = this.transportRequestId++;
2790
+ const promise = new Promise<SonareEngineTransportResponseMessage>((resolve, reject) => {
2791
+ this.transportRequests.set(requestId, { resolve, reject });
2792
+ });
2793
+ this.node.port.postMessage({ type: 'transportRequest', requestId, op: 'state' });
2794
+ return promise;
2795
+ }
2119
2796
  }
2120
2797
 
2121
2798
  export class SonareEngine {
@@ -2130,9 +2807,26 @@ export class SonareEngine {
2130
2807
  private readonly offlineChannelCount: number;
2131
2808
  private readonly automationLanes = new Map<number, EngineAutomationPoint[]>();
2132
2809
  private readonly clips = new Map<number, EngineClip>();
2810
+ private readonly midiClips = new Map<number, EngineMidiClipSchedule>();
2133
2811
  private readonly markers = new Map<number, EngineMarker>();
2812
+ private readonly trackLaneIds: number[] = [];
2813
+ private readonly trackSends = new Map<number, EngineTrackSend[]>();
2814
+ private readonly buses: EngineBus[] = [];
2815
+ private readonly trackStripJson = new Map<number, string>();
2816
+ private readonly busStripJson = new Map<number, string>();
2817
+ private masterStripJson: string | undefined;
2818
+ private captureConfig: Omit<SonareEngineSyncCaptureMessage, 'type'> | undefined;
2819
+ private tempoBpm = 120;
2820
+ private timeSignature = { numerator: 4, denominator: 4 };
2821
+ private tempoSegments: EngineTempoSegment[] = [{ startPpq: 0, bpm: 120 }];
2822
+ private timeSignatureSegments: EngineTimeSignatureSegment[] = [
2823
+ { startPpq: 0, numerator: 4, denominator: 4 },
2824
+ ];
2825
+ private latestTransportState: EngineTransportState | undefined;
2134
2826
  private nextClipId = 1;
2135
2827
  private nextMarkerId = 1;
2828
+ private transportPlaying = false;
2829
+ private readonly pendingInstrumentSync: SonareEngineInstrumentSyncMessage[] = [];
2136
2830
  private destroyed = false;
2137
2831
 
2138
2832
  private constructor(
@@ -2152,8 +2846,21 @@ export class SonareEngine {
2152
2846
  this.offlineBlockSize = offlineBlockSize;
2153
2847
  this.offlineChannelCount = offlineChannelCount;
2154
2848
  this.transport = {
2155
- play: (sampleTime = -1) => this.realtimeNode.play(sampleTime),
2156
- stop: (sampleTime = -1) => this.realtimeNode.stop(sampleTime),
2849
+ play: (sampleTime = -1) => {
2850
+ const ok = this.realtimeNode.play(sampleTime);
2851
+ if (ok) {
2852
+ this.transportPlaying = true;
2853
+ }
2854
+ return ok;
2855
+ },
2856
+ stop: (sampleTime = -1) => {
2857
+ const ok = this.realtimeNode.stop(sampleTime);
2858
+ if (ok) {
2859
+ this.transportPlaying = false;
2860
+ this.flushPendingInstrumentSync();
2861
+ }
2862
+ return ok;
2863
+ },
2157
2864
  seekPpq: (ppq, sampleTime = -1) => {
2158
2865
  this.offlineEngine.seekPpq(ppq, sampleTime);
2159
2866
  return this.realtimeNode.seekPpq(ppq, sampleTime);
@@ -2164,6 +2871,7 @@ export class SonareEngine {
2164
2871
  return this.realtimeNode.seekSample(timelineSample, sampleTime);
2165
2872
  },
2166
2873
  setTempo: (bpm) => this.setTempo(bpm),
2874
+ setTempoSegments: (segments) => this.setTempoSegments(segments),
2167
2875
  setLoop: (startPpq, endPpq, enabled = true) => this.setLoop(startPpq, endPpq, enabled),
2168
2876
  };
2169
2877
  }
@@ -2205,7 +2913,10 @@ export class SonareEngine {
2205
2913
  }
2206
2914
 
2207
2915
  setTempo(bpm: number): void {
2916
+ this.tempoBpm = bpm;
2917
+ this.tempoSegments = [{ startPpq: 0, bpm }];
2208
2918
  this.offlineEngine.setTempo(bpm);
2919
+ this.postTempoSync();
2209
2920
  this.realtimeNode.sendCommand({
2210
2921
  type: SonareEngineCommandType.SetTempoMap,
2211
2922
  sampleTime: -1,
@@ -2213,6 +2924,30 @@ export class SonareEngine {
2213
2924
  });
2214
2925
  }
2215
2926
 
2927
+ setTempoSegments(segments: readonly EngineTempoSegment[]): void {
2928
+ this.tempoSegments = segments.map((segment) => ({ ...segment }));
2929
+ this.tempoBpm = this.tempoSegments[0]?.bpm ?? this.tempoBpm;
2930
+ this.offlineEngine.setTempoSegments(this.tempoSegments);
2931
+ this.postTempoSync();
2932
+ }
2933
+
2934
+ setTimeSignature(numerator: number, denominator: number): void {
2935
+ this.timeSignature = { numerator, denominator };
2936
+ this.timeSignatureSegments = [{ startPpq: 0, numerator, denominator }];
2937
+ this.offlineEngine.setTimeSignature(numerator, denominator);
2938
+ this.postTempoSync();
2939
+ }
2940
+
2941
+ setTimeSignatureSegments(segments: readonly EngineTimeSignatureSegment[]): void {
2942
+ this.timeSignatureSegments = segments.map((segment) => ({ ...segment }));
2943
+ const first = this.timeSignatureSegments[0];
2944
+ if (first) {
2945
+ this.timeSignature = { numerator: first.numerator, denominator: first.denominator };
2946
+ }
2947
+ this.offlineEngine.setTimeSignatureSegments(this.timeSignatureSegments);
2948
+ this.postTempoSync();
2949
+ }
2950
+
2216
2951
  setLoop(startPpq: number, endPpq: number, enabled = true): boolean {
2217
2952
  this.offlineEngine.setLoop(startPpq, endPpq, enabled);
2218
2953
  // Transport precision contract: the SAB command record carries exactly one
@@ -2233,6 +2968,20 @@ export class SonareEngine {
2233
2968
  });
2234
2969
  }
2235
2970
 
2971
+ countInEndSample(startSample: number, bars: number): number {
2972
+ return this.offlineEngine.countInEndSample(startSample, bars);
2973
+ }
2974
+
2975
+ async getTransportState(): Promise<EngineTransportState> {
2976
+ const state = await this.realtimeNode.requestTransportState();
2977
+ this.latestTransportState = state;
2978
+ return state;
2979
+ }
2980
+
2981
+ cachedTransportState(): EngineTransportState | undefined {
2982
+ return this.latestTransportState;
2983
+ }
2984
+
2236
2985
  setParam(nodeId: string, param: string | number, value: number): boolean {
2237
2986
  const paramId = this.resolveParamId(nodeId, param);
2238
2987
  // Mirror the change into the offline engine so a subsequent offline render
@@ -2285,19 +3034,229 @@ export class SonareEngine {
2285
3034
  }
2286
3035
 
2287
3036
  setSoloMute(target: string | number, solo: boolean, mute: boolean): boolean {
2288
- void target;
2289
- void solo;
2290
- void mute;
2291
- // Per-track solo/mute is a Mixer concept (strip-indexed setSoloed/setMuted),
2292
- // not an engine command: the WASM SonareEngine facade does not own a Mixer
2293
- // node, so there is no realtime transport for solo/mute on this surface.
2294
- // Throw a clear error instead of silently returning false (a silent no-op
2295
- // would leave callers believing the change took effect). Apply solo/mute
2296
- // directly on a Mixer instance (Mixer.setSoloed/Mixer.setMuted) instead.
2297
- throw new Error(
2298
- 'SonareEngine.setSoloMute is not supported: solo/mute is a Mixer feature; ' +
2299
- 'use Mixer.setSoloed(stripIndex, ...) / Mixer.setMuted(stripIndex, ...) instead.',
3037
+ const laneIndex = this.ensureTrackLane(target);
3038
+ this.offlineEngine.setSoloMute(laneIndex, solo, mute);
3039
+ return this.realtimeNode.sendCommand({
3040
+ type: SonareEngineCommandType.SetSoloMute,
3041
+ targetId: laneIndex,
3042
+ sampleTime: -1,
3043
+ argInt: (mute ? 0x1 : 0) | (solo ? 0x2 : 0),
3044
+ });
3045
+ }
3046
+
3047
+ setStripGain(target: string | number, db: number): boolean {
3048
+ if (target === 'master') {
3049
+ const paramId = engineMixerMasterTarget(ENGINE_MIXER_PARAM_FADER_DB);
3050
+ this.offlineEngine.setParameter(paramId, db);
3051
+ return this.realtimeNode.sendCommand({
3052
+ type: SonareEngineCommandType.SetParamSmoothed,
3053
+ targetId: paramId,
3054
+ sampleTime: -1,
3055
+ argFloat: db,
3056
+ });
3057
+ }
3058
+ const laneIndex = this.ensureTrackLane(target);
3059
+ const paramId = engineMixerLaneTarget(laneIndex, ENGINE_MIXER_PARAM_FADER_DB);
3060
+ this.offlineEngine.setParameter(paramId, db);
3061
+ return this.realtimeNode.sendCommand({
3062
+ type: SonareEngineCommandType.SetParamSmoothed,
3063
+ targetId: paramId,
3064
+ sampleTime: -1,
3065
+ argFloat: db,
3066
+ });
3067
+ }
3068
+
3069
+ setStripPan(target: string | number, pan: number): boolean {
3070
+ if (target === 'master') {
3071
+ const paramId = engineMixerMasterTarget(ENGINE_MIXER_PARAM_PAN);
3072
+ this.offlineEngine.setParameter(paramId, pan);
3073
+ return this.realtimeNode.sendCommand({
3074
+ type: SonareEngineCommandType.SetParamSmoothed,
3075
+ targetId: paramId,
3076
+ sampleTime: -1,
3077
+ argFloat: pan,
3078
+ });
3079
+ }
3080
+ const laneIndex = this.ensureTrackLane(target);
3081
+ const paramId = engineMixerLaneTarget(laneIndex, ENGINE_MIXER_PARAM_PAN);
3082
+ this.offlineEngine.setParameter(paramId, pan);
3083
+ return this.realtimeNode.sendCommand({
3084
+ type: SonareEngineCommandType.SetParamSmoothed,
3085
+ targetId: paramId,
3086
+ sampleTime: -1,
3087
+ argFloat: pan,
3088
+ });
3089
+ }
3090
+
3091
+ /**
3092
+ * Declares the mixer track lanes in an explicit order.
3093
+ *
3094
+ * Lane indices are append-only: once a track id occupies a lane, its index
3095
+ * stays fixed for the engine's lifetime. The given list must therefore start
3096
+ * with the already-declared lane ids in their current order and may only
3097
+ * append new track ids after them. Entries carrying `sends` replace that
3098
+ * track's send list; entries without `sends` leave existing sends untouched.
3099
+ *
3100
+ * @param lanes Track ids or lane descriptors in the desired lane order.
3101
+ */
3102
+ setTrackLanes(lanes: ReadonlyArray<number | EngineTrackLane>): void {
3103
+ const entries = lanes.map((lane) => (typeof lane === 'number' ? { trackId: lane } : lane));
3104
+ const ids: number[] = [];
3105
+ for (const entry of entries) {
3106
+ if (!Number.isInteger(entry.trackId) || entry.trackId <= 0) {
3107
+ throw new Error(`Invalid track id for mixer lane: ${String(entry.trackId)}`);
3108
+ }
3109
+ ids.push(entry.trackId);
3110
+ }
3111
+ if (new Set(ids).size !== ids.length) {
3112
+ throw new Error('Duplicate track id in mixer lane list');
3113
+ }
3114
+ for (let index = 0; index < this.trackLaneIds.length; index++) {
3115
+ if (ids[index] !== this.trackLaneIds[index]) {
3116
+ throw new Error(
3117
+ 'Mixer lanes are append-only: keep existing lanes in order and only append new track ids',
3118
+ );
3119
+ }
3120
+ }
3121
+ for (const entry of entries) {
3122
+ if (entry.sends) {
3123
+ this.trackSends.set(
3124
+ entry.trackId,
3125
+ entry.sends.map((send) => ({ ...send })),
3126
+ );
3127
+ }
3128
+ }
3129
+ this.trackLaneIds.splice(0, this.trackLaneIds.length, ...ids);
3130
+ this.syncMixer();
3131
+ }
3132
+
3133
+ setSends(target: string | number, sends: EngineTrackSend[]): void {
3134
+ const laneIndex = this.ensureTrackLane(target);
3135
+ const trackId = this.trackLaneIds[laneIndex];
3136
+ this.trackSends.set(
3137
+ trackId,
3138
+ sends.map((send) => ({ ...send })),
2300
3139
  );
3140
+ this.syncMixer();
3141
+ }
3142
+
3143
+ setTrackBuses(buses: EngineBus[]): void {
3144
+ this.buses.splice(0, this.buses.length, ...buses.map((bus) => ({ ...bus })));
3145
+ this.syncMixer();
3146
+ }
3147
+
3148
+ setBusGain(busId: number, db: number): boolean {
3149
+ const busIndex = this.ensureBus(busId);
3150
+ this.buses[busIndex] = { ...this.buses[busIndex], busId, gainDb: db };
3151
+ this.offlineEngine.setTrackBuses(this.buses);
3152
+ const paramId = engineMixerBusTarget(busIndex, ENGINE_MIXER_PARAM_FADER_DB);
3153
+ this.offlineEngine.setParameter(paramId, db);
3154
+ return this.realtimeNode.sendCommand({
3155
+ type: SonareEngineCommandType.SetParamSmoothed,
3156
+ targetId: paramId,
3157
+ sampleTime: -1,
3158
+ argFloat: db,
3159
+ });
3160
+ }
3161
+
3162
+ setTrackStripJson(target: string | number, sceneJson: string): void {
3163
+ const laneIndex = this.ensureTrackLane(target);
3164
+ const trackId = this.trackLaneIds[laneIndex];
3165
+ this.offlineEngine.setTrackStripJson(trackId, sceneJson);
3166
+ this.trackStripJson.set(trackId, sceneJson);
3167
+ this.syncMixer();
3168
+ }
3169
+
3170
+ setTrackStripEqBand(target: string | number, bandIndex: number, band: EqBand | string): void {
3171
+ const laneIndex = this.ensureTrackLane(target);
3172
+ const trackId = this.trackLaneIds[laneIndex];
3173
+ const bandJson = typeof band === 'string' ? band : JSON.stringify(band);
3174
+ this.offlineEngine.setTrackStripEqBandJson(trackId, bandIndex, bandJson);
3175
+ this.postSync({ type: 'syncTrackStripEqBand', trackId, bandIndex, bandJson });
3176
+ }
3177
+
3178
+ setTrackStripInsertBypassed(
3179
+ target: string | number,
3180
+ insertIndex: number,
3181
+ bypassed: boolean,
3182
+ resetOnBypass = false,
3183
+ ): void {
3184
+ const laneIndex = this.ensureTrackLane(target);
3185
+ const trackId = this.trackLaneIds[laneIndex];
3186
+ this.offlineEngine.setTrackStripInsertBypassed(trackId, insertIndex, bypassed, resetOnBypass);
3187
+ this.postSync({
3188
+ type: 'syncTrackStripInsertBypassed',
3189
+ trackId,
3190
+ insertIndex,
3191
+ bypassed,
3192
+ resetOnBypass,
3193
+ });
3194
+ }
3195
+
3196
+ setStripEq(target: string | number, bandIndex: number, band: EqBand | string): void {
3197
+ if (target === 'master') {
3198
+ this.setMasterStripEqBand(bandIndex, band);
3199
+ return;
3200
+ }
3201
+ this.setTrackStripEqBand(target, bandIndex, band);
3202
+ }
3203
+
3204
+ setStripInsertBypassed(
3205
+ target: string | number,
3206
+ insertIndex: number,
3207
+ bypassed: boolean,
3208
+ resetOnBypass = false,
3209
+ ): void {
3210
+ if (target === 'master') {
3211
+ this.setMasterStripInsertBypassed(insertIndex, bypassed, resetOnBypass);
3212
+ return;
3213
+ }
3214
+ this.setTrackStripInsertBypassed(target, insertIndex, bypassed, resetOnBypass);
3215
+ }
3216
+
3217
+ setStripInserts(target: string | number, sceneJson: string): void {
3218
+ if (target === 'master') {
3219
+ this.setMasterStripJson(sceneJson);
3220
+ return;
3221
+ }
3222
+ this.setTrackStripJson(target, sceneJson);
3223
+ }
3224
+
3225
+ setBusStripJson(busId: number, sceneJson: string): void {
3226
+ this.ensureBus(busId);
3227
+ this.offlineEngine.setBusStripJson(busId, sceneJson);
3228
+ this.busStripJson.set(busId, sceneJson);
3229
+ this.syncMixer();
3230
+ }
3231
+
3232
+ setMasterStripJson(sceneJson: string): void {
3233
+ this.offlineEngine.setMasterStripJson(sceneJson);
3234
+ this.masterStripJson = sceneJson;
3235
+ this.syncMixer();
3236
+ }
3237
+
3238
+ setMasterStripEqBand(bandIndex: number, band: EqBand | string): void {
3239
+ const bandJson = typeof band === 'string' ? band : JSON.stringify(band);
3240
+ this.offlineEngine.setMasterStripEqBandJson(bandIndex, bandJson);
3241
+ this.postSync({ type: 'syncMasterStripEqBand', bandIndex, bandJson });
3242
+ }
3243
+
3244
+ setMasterStripInsertBypassed(
3245
+ insertIndex: number,
3246
+ bypassed: boolean,
3247
+ resetOnBypass = false,
3248
+ ): void {
3249
+ this.offlineEngine.setMasterStripInsertBypassed(insertIndex, bypassed, resetOnBypass);
3250
+ this.postSync({
3251
+ type: 'syncMasterStripInsertBypassed',
3252
+ insertIndex,
3253
+ bypassed,
3254
+ resetOnBypass,
3255
+ });
3256
+ }
3257
+
3258
+ setMasterChain(sceneJson: string): void {
3259
+ this.setMasterStripJson(sceneJson);
2301
3260
  }
2302
3261
 
2303
3262
  addClip(
@@ -2312,19 +3271,152 @@ export class SonareEngine {
2312
3271
  id,
2313
3272
  channels: buffer,
2314
3273
  startPpq,
3274
+ trackId: this.resolveTargetId(trackId),
2315
3275
  };
3276
+ this.ensureTrackLane(trackId);
2316
3277
  this.clips.set(id, clip);
2317
- this.syncClips();
2318
- void trackId;
3278
+ this.syncClipsDelta([clip], []);
2319
3279
  return id;
2320
3280
  }
2321
3281
 
2322
3282
  removeClip(clipId: number): void {
2323
3283
  this.clips.delete(clipId);
2324
- this.syncClips();
3284
+ this.syncClipsDelta([], [clipId]);
3285
+ }
3286
+
3287
+ setMidiClips(clips: readonly EngineMidiClipSchedule[]): void {
3288
+ this.midiClips.clear();
3289
+ for (const clip of clips) {
3290
+ const id = clip.id ?? this.nextClipId++;
3291
+ this.midiClips.set(id, { ...clip, id, events: clip.events.map((event) => ({ ...event })) });
3292
+ }
3293
+ this.syncMidiClips();
3294
+ }
3295
+
3296
+ setBuiltinInstrument(
3297
+ trackId: string | number,
3298
+ config: { destinationId?: number } & Record<string, unknown> = {},
3299
+ ): void {
3300
+ const destinationId = this.resolveTargetId(trackId);
3301
+ this.offlineEngine.setBuiltinInstrument(config, destinationId);
3302
+ this.postInstrumentSync({ type: 'syncBuiltinInstrument', destinationId, config });
3303
+ }
3304
+
3305
+ setSynthInstrument(trackId: string | number, patch: Record<string, unknown> | string = {}): void {
3306
+ const destinationId = this.resolveTargetId(trackId);
3307
+ this.offlineEngine.setSynthInstrument(patch, destinationId);
3308
+ this.postInstrumentSync({ type: 'syncSynthInstrument', destinationId, patch });
3309
+ }
3310
+
3311
+ loadSoundFont(data: Uint8Array): void {
3312
+ this.offlineEngine.loadSoundFont(data);
3313
+ this.postInstrumentSync({ type: 'syncLoadSoundFont', data });
3314
+ }
3315
+
3316
+ setSf2Instrument(
3317
+ trackId: string | number,
3318
+ config: { destinationId?: number; gain?: number; polyphony?: number } = {},
3319
+ ): void {
3320
+ const destinationId = this.resolveTargetId(trackId);
3321
+ this.offlineEngine.setSf2Instrument(config, destinationId);
3322
+ this.postInstrumentSync({ type: 'syncSf2Instrument', destinationId, config });
3323
+ }
3324
+
3325
+ pushMidiNoteOn(
3326
+ trackId: string | number,
3327
+ group: number,
3328
+ channel: number,
3329
+ note: number,
3330
+ velocity: number,
3331
+ renderFrame = -1,
3332
+ ): void {
3333
+ const destinationId = this.resolveTargetId(trackId);
3334
+ this.offlineEngine.pushMidiNoteOn(destinationId, group, channel, note, velocity, renderFrame);
3335
+ this.postSync({
3336
+ type: 'syncMidiNoteOn',
3337
+ destinationId,
3338
+ group,
3339
+ channel,
3340
+ note,
3341
+ velocity,
3342
+ renderFrame,
3343
+ });
3344
+ }
3345
+
3346
+ pushMidiNoteOff(
3347
+ trackId: string | number,
3348
+ group: number,
3349
+ channel: number,
3350
+ note: number,
3351
+ velocity = 0,
3352
+ renderFrame = -1,
3353
+ ): void {
3354
+ const destinationId = this.resolveTargetId(trackId);
3355
+ this.offlineEngine.pushMidiNoteOff(destinationId, group, channel, note, velocity, renderFrame);
3356
+ this.postSync({
3357
+ type: 'syncMidiNoteOff',
3358
+ destinationId,
3359
+ group,
3360
+ channel,
3361
+ note,
3362
+ velocity,
3363
+ renderFrame,
3364
+ });
3365
+ }
3366
+
3367
+ pushMidiCc(
3368
+ trackId: string | number,
3369
+ group: number,
3370
+ channel: number,
3371
+ controller: number,
3372
+ value: number,
3373
+ renderFrame = -1,
3374
+ ): void {
3375
+ const destinationId = this.resolveTargetId(trackId);
3376
+ this.offlineEngine.pushMidiCc(destinationId, group, channel, controller, value, renderFrame);
3377
+ this.postSync({
3378
+ type: 'syncMidiCc',
3379
+ destinationId,
3380
+ group,
3381
+ channel,
3382
+ controller,
3383
+ value,
3384
+ renderFrame,
3385
+ });
3386
+ }
3387
+
3388
+ pushMidiPanic(renderFrame = -1): void {
3389
+ this.offlineEngine.pushMidiPanic(renderFrame);
3390
+ this.postSync({ type: 'syncMidiPanic', renderFrame });
3391
+ }
3392
+
3393
+ configureCapture(options: {
3394
+ bufferFrames: number;
3395
+ channels?: number;
3396
+ source?: EngineCaptureStatus['source'];
3397
+ recordOffsetSamples?: number;
3398
+ inputMonitor?: { enabled: boolean; gain?: number };
3399
+ }): void {
3400
+ const bufferFrames = Math.trunc(options.bufferFrames);
3401
+ const channels = Math.trunc(options.channels ?? this.offlineChannelCount);
3402
+ const source = options.source ?? 'output';
3403
+ const recordOffsetSamples = Math.trunc(options.recordOffsetSamples ?? 0);
3404
+ const inputMonitor = {
3405
+ enabled: Boolean(options.inputMonitor?.enabled),
3406
+ gain: options.inputMonitor?.gain ?? 1,
3407
+ };
3408
+ this.offlineEngine.setCaptureBuffer(channels, bufferFrames);
3409
+ this.offlineEngine.setCaptureSource(source);
3410
+ this.offlineEngine.setRecordOffsetSamples(recordOffsetSamples);
3411
+ this.offlineEngine.setInputMonitor(inputMonitor.enabled, inputMonitor.gain);
3412
+ this.captureConfig = { bufferFrames, channels, source, recordOffsetSamples, inputMonitor };
3413
+ this.postSync({ type: 'syncCapture', ...this.captureConfig });
2325
3414
  }
2326
3415
 
2327
3416
  armRecord(trackId: string | number, enabled: boolean): boolean {
3417
+ if (enabled && !this.captureConfig) {
3418
+ throw new Error('Capture buffer is not configured');
3419
+ }
2328
3420
  this.offlineEngine.armCapture(enabled);
2329
3421
  return this.realtimeNode.sendCommand({
2330
3422
  type: SonareEngineCommandType.ArmRecord,
@@ -2335,8 +3427,8 @@ export class SonareEngine {
2335
3427
  }
2336
3428
 
2337
3429
  punch(inPpq: number, outPpq: number): boolean {
2338
- const inSample = this.ppqToApproxSample(inPpq);
2339
- const outSample = this.ppqToApproxSample(outPpq);
3430
+ const inSample = this.offlineEngine.sampleAtPpq(inPpq);
3431
+ const outSample = this.offlineEngine.sampleAtPpq(outPpq);
2340
3432
  this.offlineEngine.setCapturePunch(inSample, outSample, true);
2341
3433
  // Carry BOTH endpoints as already-converted SAMPLES so the realtime engine
2342
3434
  // agrees with the offline engine. The previous code sent the raw PPQ out
@@ -2351,6 +3443,19 @@ export class SonareEngine {
2351
3443
  });
2352
3444
  }
2353
3445
 
3446
+ captureStatus(): Promise<EngineCaptureStatus> {
3447
+ return this.realtimeNode.requestCaptureStatus();
3448
+ }
3449
+
3450
+ capturedAudio(): Promise<Float32Array[]> {
3451
+ return this.realtimeNode.requestCapturedAudio();
3452
+ }
3453
+
3454
+ async resetCapture(): Promise<void> {
3455
+ this.offlineEngine.resetCapture();
3456
+ await this.realtimeNode.requestCaptureReset();
3457
+ }
3458
+
2354
3459
  setMetronome(opts: EngineMetronomeConfig): void {
2355
3460
  this.offlineEngine.setMetronome(opts);
2356
3461
  // The full config (beatGain/accentGain/clickSamples/clickSeconds) cannot fit
@@ -2371,6 +3476,59 @@ export class SonareEngine {
2371
3476
  return id;
2372
3477
  }
2373
3478
 
3479
+ /**
3480
+ * Replaces the whole marker set in one call.
3481
+ *
3482
+ * Entries without an `id` are assigned fresh ids; entries carrying an `id`
3483
+ * keep it (ids must be positive and unique within the list). Returns the
3484
+ * resolved markers in the order given, so a caller can map its own marker
3485
+ * identities to the engine ids used by `seekMarker`/`setLoopFromMarkers`.
3486
+ *
3487
+ * @param markers The full marker list (an empty list clears all markers).
3488
+ * @returns The markers with their resolved engine ids.
3489
+ */
3490
+ setMarkers(markers: ReadonlyArray<{ ppq: number; name?: string; id?: number }>): EngineMarker[] {
3491
+ const resolved: EngineMarker[] = [];
3492
+ const seen = new Set<number>();
3493
+ for (const marker of markers) {
3494
+ if (!Number.isFinite(marker.ppq)) {
3495
+ throw new Error(`Invalid marker ppq: ${String(marker.ppq)}`);
3496
+ }
3497
+ if (marker.id !== undefined) {
3498
+ if (!Number.isInteger(marker.id) || marker.id <= 0) {
3499
+ throw new Error(`Invalid marker id: ${String(marker.id)}`);
3500
+ }
3501
+ if (seen.has(marker.id)) {
3502
+ throw new Error(`Duplicate marker id: ${marker.id}`);
3503
+ }
3504
+ }
3505
+ const id = marker.id ?? this.nextMarkerId++;
3506
+ seen.add(id);
3507
+ if (id >= this.nextMarkerId) {
3508
+ this.nextMarkerId = id + 1;
3509
+ }
3510
+ resolved.push({ id, ppq: marker.ppq, name: marker.name ?? '' });
3511
+ }
3512
+ this.markers.clear();
3513
+ for (const marker of resolved) {
3514
+ this.markers.set(marker.id, marker);
3515
+ }
3516
+ this.syncMarkers();
3517
+ return resolved.map((marker) => ({ ...marker }));
3518
+ }
3519
+
3520
+ markerCount(): number {
3521
+ return this.offlineEngine.markerCount();
3522
+ }
3523
+
3524
+ markerByIndex(index: number): EngineMarker {
3525
+ return this.offlineEngine.markerByIndex(index);
3526
+ }
3527
+
3528
+ marker(markerId: number): EngineMarker {
3529
+ return this.offlineEngine.marker(markerId);
3530
+ }
3531
+
2374
3532
  seekMarker(markerId: number): boolean {
2375
3533
  this.offlineEngine.seekMarker(markerId);
2376
3534
  // Forward to the live worklet engine. Its marker set is kept in sync via the
@@ -2384,6 +3542,13 @@ export class SonareEngine {
2384
3542
  });
2385
3543
  }
2386
3544
 
3545
+ setLoopFromMarkers(startMarkerId: number, endMarkerId: number): boolean {
3546
+ this.offlineEngine.setLoopFromMarkers(startMarkerId, endMarkerId);
3547
+ const start = this.offlineEngine.marker(startMarkerId);
3548
+ const end = this.offlineEngine.marker(endMarkerId);
3549
+ return this.setLoop(start.ppq, end.ppq, true);
3550
+ }
3551
+
2387
3552
  async renderOffline(totalFrames: number): Promise<Float32Array[]> {
2388
3553
  const frames = Math.max(0, Math.floor(totalFrames));
2389
3554
  const inputs: Float32Array[] = [];
@@ -2420,14 +3585,50 @@ export class SonareEngine {
2420
3585
  this.offlineEngine.destroy();
2421
3586
  }
2422
3587
 
2423
- private syncClips(): void {
3588
+ private syncClipsDelta(upserts: EngineClip[], removeIds: number[]): void {
2424
3589
  const clips = Array.from(this.clips.values());
2425
3590
  this.offlineEngine.setClips(clips);
2426
- // Push the full clip set to the live worklet engine over the message port.
2427
- // Clip audio buffers are too large for the fixed-size SAB command record, so
2428
- // they ride a structured-clone 'syncClips' message applied OUTSIDE the audio
2429
- // render callback (the audio-thread equivalent of an RtPublisher swap).
2430
- this.postSync({ type: 'syncClips', clips });
3591
+ this.postSync({
3592
+ type: 'syncClipsDelta',
3593
+ upserts,
3594
+ removeIds,
3595
+ });
3596
+ }
3597
+
3598
+ private syncMidiClips(): void {
3599
+ const clips = Array.from(this.midiClips.values());
3600
+ this.offlineEngine.setMidiClips(clips);
3601
+ this.postSync({ type: 'syncMidiClips', clips });
3602
+ }
3603
+
3604
+ private syncMixer(): void {
3605
+ const lanes = this.trackLaneIds.map((trackId) => {
3606
+ const sends = this.trackSends.get(trackId);
3607
+ return sends && sends.length > 0
3608
+ ? { trackId, sends: sends.map((send) => ({ ...send })) }
3609
+ : { trackId };
3610
+ });
3611
+ const buses = this.buses.map((bus) => ({ ...bus }));
3612
+ this.offlineEngine.setTrackBuses(buses);
3613
+ if (lanes.length > 0) {
3614
+ this.offlineEngine.setTrackLanes(lanes);
3615
+ }
3616
+ const trackStrips = Array.from(this.trackStripJson, ([trackId, sceneJson]) => ({
3617
+ trackId,
3618
+ sceneJson,
3619
+ }));
3620
+ const busStrips = Array.from(this.busStripJson, ([busId, sceneJson]) => ({
3621
+ busId,
3622
+ sceneJson,
3623
+ }));
3624
+ this.postSync({
3625
+ type: 'syncMixer',
3626
+ lanes,
3627
+ buses,
3628
+ trackStrips,
3629
+ busStrips,
3630
+ masterStripJson: this.masterStripJson,
3631
+ });
2431
3632
  }
2432
3633
 
2433
3634
  private syncMarkers(): void {
@@ -2436,6 +3637,37 @@ export class SonareEngine {
2436
3637
  this.postSync({ type: 'syncMarkers', markers });
2437
3638
  }
2438
3639
 
3640
+ private postInstrumentSync(message: SonareEngineInstrumentSyncMessage): void {
3641
+ if (this.destroyed) {
3642
+ return;
3643
+ }
3644
+ if (this.transportPlaying) {
3645
+ this.pendingInstrumentSync.push(message);
3646
+ return;
3647
+ }
3648
+ this.postSync(message);
3649
+ }
3650
+
3651
+ private flushPendingInstrumentSync(): void {
3652
+ if (this.destroyed || this.pendingInstrumentSync.length === 0) {
3653
+ return;
3654
+ }
3655
+ const pending = this.pendingInstrumentSync.splice(0);
3656
+ for (const message of pending) {
3657
+ this.postSync(message);
3658
+ }
3659
+ }
3660
+
3661
+ private postTempoSync(): void {
3662
+ this.postSync({
3663
+ type: 'syncTempo',
3664
+ bpm: this.tempoBpm,
3665
+ timeSignature: { ...this.timeSignature },
3666
+ tempoSegments: this.tempoSegments.map((segment) => ({ ...segment })),
3667
+ timeSignatureSegments: this.timeSignatureSegments.map((segment) => ({ ...segment })),
3668
+ });
3669
+ }
3670
+
2439
3671
  // Posts an out-of-band control-sync message to the worklet engine processor.
2440
3672
  // Sync messages use a string `type` so the worklet's message handler routes
2441
3673
  // them to receiveSync() (numeric `type` is reserved for SonareEngineCommandRecord).
@@ -2465,16 +3697,40 @@ export class SonareEngine {
2465
3697
  return Number.isFinite(parsed) ? parsed : 0;
2466
3698
  }
2467
3699
 
3700
+ private ensureTrackLane(target: string | number): number {
3701
+ const trackId = this.resolveTargetId(target);
3702
+ if (!Number.isInteger(trackId) || trackId <= 0) {
3703
+ throw new Error(`Invalid track id for mixer lane: ${String(target)}`);
3704
+ }
3705
+ const existing = this.trackLaneIds.indexOf(trackId);
3706
+ if (existing >= 0) {
3707
+ return existing;
3708
+ }
3709
+ this.trackLaneIds.push(trackId);
3710
+ this.syncMixer();
3711
+ return this.trackLaneIds.length - 1;
3712
+ }
3713
+
3714
+ private ensureBus(busId: number): number {
3715
+ const resolved = Math.trunc(busId);
3716
+ if (!Number.isInteger(resolved) || resolved <= 0) {
3717
+ throw new Error(`Invalid bus id for mixer bus: ${String(busId)}`);
3718
+ }
3719
+ const existing = this.buses.findIndex((bus) => bus.busId === resolved);
3720
+ if (existing >= 0) {
3721
+ return existing;
3722
+ }
3723
+ this.buses.push({ busId: resolved });
3724
+ this.syncMixer();
3725
+ return this.buses.length - 1;
3726
+ }
3727
+
2468
3728
  private curveCode(curve: number | 'linear' | 'exponential'): number {
2469
3729
  if (typeof curve === 'number') {
2470
3730
  return curve;
2471
3731
  }
2472
3732
  return curve === 'exponential' ? 1 : 0;
2473
3733
  }
2474
-
2475
- private ppqToApproxSample(ppq: number): number {
2476
- return Math.max(0, Math.round(((ppq * 60) / 120) * this.sampleRate));
2477
- }
2478
3734
  }
2479
3735
 
2480
3736
  export class SonareRealtimeVoiceChangerWorkletProcessor {
@@ -2768,6 +4024,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2768
4024
  class RegisteredSonareRealtimeEngineWorkletProcessor extends Base {
2769
4025
  private bridge?: SonareRealtimeEngineWorkletProcessor;
2770
4026
  private rtBridge?: SonareRtRealtimeEngineRuntime;
4027
+ private readonly pendingMessages: unknown[] = [];
2771
4028
  readonly port?: WorkletPort;
2772
4029
 
2773
4030
  constructor(options?: { processorOptions?: SonareRealtimeEngineWorkletProcessorOptions }) {
@@ -2777,18 +4034,27 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2777
4034
  if (processorOptions.runtimeTarget === 'sonare-rt') {
2778
4035
  void this.initializeSonareRt(processorOptions, port);
2779
4036
  } else {
2780
- this.bridge = new SonareRealtimeEngineWorkletProcessor(processorOptions, {
2781
- postMessage: (message) => port?.postMessage?.(message),
2782
- onMeter: (meter) => port?.postMessage?.(meter),
2783
- });
4037
+ void this.initializeEmbind(processorOptions, port);
2784
4038
  }
2785
4039
  const onMessage = (event: { data: unknown }) => {
4040
+ if (!this.bridge && !this.rtBridge) {
4041
+ if (this.pendingMessages.length < 1024) {
4042
+ this.pendingMessages.push(event.data);
4043
+ }
4044
+ return;
4045
+ }
2786
4046
  if (isEngineCommandRecord(event.data)) {
2787
4047
  this.bridge?.receiveCommand(event.data);
2788
4048
  this.rtBridge?.receiveCommand(event.data);
2789
4049
  } else if (isEngineSyncMessage(event.data)) {
2790
4050
  this.bridge?.receiveSync(event.data);
2791
4051
  this.rtBridge?.receiveSync(event.data);
4052
+ } else if (isEngineCaptureRequestMessage(event.data)) {
4053
+ this.bridge?.receiveCaptureRequest(event.data);
4054
+ this.rtBridge?.receiveCaptureRequest(event.data, port);
4055
+ } else if (isEngineTransportRequestMessage(event.data)) {
4056
+ this.bridge?.receiveTransportRequest(event.data);
4057
+ this.rtBridge?.receiveTransportRequest(event.data, port);
2792
4058
  }
2793
4059
  };
2794
4060
  if (port?.addEventListener) {
@@ -2813,6 +4079,75 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2813
4079
  return true;
2814
4080
  }
2815
4081
 
4082
+ private replayPendingMessages(port?: WorkletPort): void {
4083
+ const messages = this.pendingMessages.splice(0);
4084
+ for (const data of messages) {
4085
+ if (isEngineCommandRecord(data)) {
4086
+ this.bridge?.receiveCommand(data);
4087
+ this.rtBridge?.receiveCommand(data);
4088
+ } else if (isEngineSyncMessage(data)) {
4089
+ this.bridge?.receiveSync(data);
4090
+ this.rtBridge?.receiveSync(data);
4091
+ } else if (isEngineCaptureRequestMessage(data)) {
4092
+ this.bridge?.receiveCaptureRequest(data);
4093
+ this.rtBridge?.receiveCaptureRequest(data, port);
4094
+ } else if (isEngineTransportRequestMessage(data)) {
4095
+ this.bridge?.receiveTransportRequest(data);
4096
+ this.rtBridge?.receiveTransportRequest(data, port);
4097
+ }
4098
+ }
4099
+ }
4100
+
4101
+ private async initializeEmbind(
4102
+ options: SonareRealtimeEngineWorkletProcessorOptions,
4103
+ port?: WorkletPort,
4104
+ ): Promise<void> {
4105
+ try {
4106
+ const initPromise = (
4107
+ globalThis as typeof globalThis & { SonareEmbindInitPromise?: Promise<void> }
4108
+ ).SonareEmbindInitPromise;
4109
+ if (initPromise) {
4110
+ await initPromise;
4111
+ }
4112
+ if (!isInitialized()) {
4113
+ type EmbindModuleFactory = (options?: {
4114
+ locateFile?: (path: string, prefix: string) => string;
4115
+ wasmBinary?: ArrayBuffer | Uint8Array;
4116
+ }) => Promise<SonareModule>;
4117
+ const moduleFactory = (
4118
+ globalThis as typeof globalThis & {
4119
+ SonareEmbindModuleFactory?: EmbindModuleFactory;
4120
+ }
4121
+ ).SonareEmbindModuleFactory;
4122
+ if (!moduleFactory) {
4123
+ throw new Error('embind realtime engine module is not initialized.');
4124
+ }
4125
+ await initSonareModule({
4126
+ locateFile: (path) => path,
4127
+ wasmBinary: options.wasmBinary,
4128
+ moduleFactory,
4129
+ });
4130
+ }
4131
+ this.bridge = new SonareRealtimeEngineWorkletProcessor(options, {
4132
+ postMessage: (message) => port?.postMessage?.(message),
4133
+ onMeter: (meter) => port?.postMessage?.(meter),
4134
+ });
4135
+ for (const message of options.initialSyncMessages ?? []) {
4136
+ this.bridge.receiveSync(message);
4137
+ }
4138
+ for (const command of options.initialCommands ?? []) {
4139
+ this.bridge.receiveCommand(command);
4140
+ }
4141
+ this.replayPendingMessages(port);
4142
+ port?.postMessage?.({ type: 'ready', runtimeTarget: 'embind' });
4143
+ } catch (error) {
4144
+ port?.postMessage?.({
4145
+ type: 'error',
4146
+ message: error instanceof Error ? error.message : String(error),
4147
+ });
4148
+ }
4149
+ }
4150
+
2816
4151
  private async initializeSonareRt(
2817
4152
  options: SonareRealtimeEngineWorkletProcessorOptions,
2818
4153
  port?: WorkletPort,
@@ -2857,6 +4192,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2857
4192
  telemetrySharedBuffer: options.telemetrySharedBuffer,
2858
4193
  telemetryRingCapacity: options.telemetryRingCapacity,
2859
4194
  });
4195
+ this.replayPendingMessages(port);
2860
4196
  port?.postMessage?.({ type: 'ready', runtimeTarget: 'sonare-rt' });
2861
4197
  } catch (error) {
2862
4198
  port?.postMessage?.({