@libraz/libsonare 1.5.5 → 1.7.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.
Files changed (80) hide show
  1. package/README.md +109 -20
  2. package/dist/analysis.d.ts +5809 -0
  3. package/dist/analysis.js +2574 -0
  4. package/dist/analysis.js.map +1 -0
  5. package/dist/index.d.ts +884 -55
  6. package/dist/index.js +1302 -292
  7. package/dist/index.js.map +1 -1
  8. package/dist/schemas/realtime-voice-changer-preset-pack.schema.json +27 -0
  9. package/dist/schemas/realtime-voice-changer-preset.schema.json +140 -0
  10. package/dist/sonare-analysis.js +2 -0
  11. package/dist/sonare-analysis.wasm +0 -0
  12. package/dist/sonare.js +1 -1
  13. package/dist/sonare.wasm +0 -0
  14. package/dist/worker.d.ts +65 -0
  15. package/dist/worker.js +734 -0
  16. package/dist/worker.js.map +1 -0
  17. package/dist/worklet.d.ts +628 -88
  18. package/dist/worklet.js +1816 -266
  19. package/dist/worklet.js.map +1 -1
  20. package/package.json +23 -11
  21. package/src/_chain_config.ts +5 -3
  22. package/src/analysis.ts +137 -0
  23. package/src/analysis_helpers.ts +40 -29
  24. package/src/audio.ts +7 -2
  25. package/src/clip_page_streamer.ts +62 -8
  26. package/src/codes.ts +87 -64
  27. package/src/effects_mastering.ts +1 -0
  28. package/src/effects_transform.ts +179 -10
  29. package/src/errors.ts +1 -0
  30. package/src/feature_core.ts +140 -2
  31. package/src/feature_music.ts +25 -3
  32. package/src/feature_pitch.ts +70 -1
  33. package/src/feature_spectral.ts +295 -7
  34. package/src/feature_spectrogram.ts +203 -3
  35. package/src/features.ts +16 -1
  36. package/src/index.ts +120 -8
  37. package/src/mastering_chain.ts +20 -8
  38. package/src/mastering_core.ts +5 -0
  39. package/src/mastering_dynamics.ts +7 -1
  40. package/src/metering.ts +6 -1
  41. package/src/mixer.ts +3 -3
  42. package/src/opfs_clip_pages.ts +26 -1
  43. package/src/project.ts +13 -0
  44. package/src/project_class.ts +204 -23
  45. package/src/project_internal.ts +38 -34
  46. package/src/project_types.ts +114 -11
  47. package/src/public_types.ts +68 -0
  48. package/src/public_types_mastering.ts +22 -0
  49. package/src/public_types_mixing.ts +27 -2
  50. package/src/public_types_music.ts +9 -0
  51. package/src/public_types_realtime.ts +23 -5
  52. package/src/public_types_spectral.ts +33 -0
  53. package/src/quick_analysis.ts +46 -10
  54. package/src/realtime_engine.ts +167 -6
  55. package/src/realtime_voice_changer.ts +51 -77
  56. package/src/sonare-analysis.js.d.ts +8 -0
  57. package/src/sonare.js.d.ts +340 -6
  58. package/src/stream_analyzer.ts +6 -1
  59. package/src/streaming_processors.ts +15 -0
  60. package/src/web_midi.ts +46 -11
  61. package/src/worker.ts +196 -0
  62. package/src/worker_client.ts +352 -0
  63. package/src/worker_protocol.ts +59 -0
  64. package/src/worklet/engine-automation.ts +2 -1
  65. package/src/worklet/engine-capture-facade.ts +13 -4
  66. package/src/worklet/engine-clips.ts +12 -3
  67. package/src/worklet/engine-node.ts +264 -21
  68. package/src/worklet/engine-offline.ts +7 -2
  69. package/src/worklet/engine-parameter-facade.ts +55 -5
  70. package/src/worklet/engine-processor.ts +435 -62
  71. package/src/worklet/engine-register.ts +9 -1
  72. package/src/worklet/engine-strips.ts +19 -3
  73. package/src/worklet/engine-sync.ts +2 -6
  74. package/src/worklet/engine-tempo-facade.ts +8 -6
  75. package/src/worklet/engine.ts +390 -13
  76. package/src/worklet/guards.ts +121 -8
  77. package/src/worklet/messages.ts +166 -8
  78. package/src/worklet/protocol.ts +305 -17
  79. package/src/worklet/voice-changer-processor.ts +12 -15
  80. package/src/worklet.ts +20 -1
@@ -1,3 +1,8 @@
1
+ import {
2
+ ClipPageStreamer,
3
+ type ClipPageStreamerRequest,
4
+ type OpfsClipStreamOptions,
5
+ } from '../clip_page_streamer';
1
6
  import type {
2
7
  EngineAutomationPoint,
3
8
  EngineBus,
@@ -10,13 +15,17 @@ import type {
10
15
  EngineTempoSegment,
11
16
  EngineTimeSignatureSegment,
12
17
  EngineTrackLane,
18
+ EngineTrackMonitorMode,
13
19
  EngineTrackSend,
14
20
  EngineTransportState,
15
21
  EqBand,
22
+ MidiCcBindOptions,
16
23
  PanLaw,
17
24
  PanMode,
18
25
  } from '../index';
19
26
  import { RealtimeEngine } from '../index';
27
+ import { createOpfsClipPageProvider, type OpfsClipPageProviderBinding } from '../opfs_clip_pages';
28
+ import type { ClipPageProvider } from '../realtime_engine';
20
29
  import type { EngineAutomationContext } from './engine-automation';
21
30
  import * as automation from './engine-automation';
22
31
  import type { EngineCaptureContext } from './engine-capture-facade';
@@ -56,6 +65,24 @@ import {
56
65
  type SonareWorkletScopeSnapshot,
57
66
  } from './protocol';
58
67
 
68
+ const MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS = 256;
69
+
70
+ function transferableAudioBuffers(channels: readonly Float32Array[]): Transferable[] {
71
+ const transfers: ArrayBuffer[] = [];
72
+ const seen = new Set<ArrayBuffer>();
73
+ for (const channel of channels) {
74
+ const buffer = channel.buffer;
75
+ // SharedArrayBuffer is cloneable but cannot appear in a transfer list. A
76
+ // worker may intentionally return SAB-backed pages, so transfer only plain,
77
+ // distinct ArrayBuffers and let structured clone share SABs by reference.
78
+ if (buffer instanceof ArrayBuffer && !seen.has(buffer)) {
79
+ seen.add(buffer);
80
+ transfers.push(buffer);
81
+ }
82
+ }
83
+ return transfers;
84
+ }
85
+
59
86
  export class SonareEngine {
60
87
  readonly node: AudioWorkletNode;
61
88
  readonly capabilities: SonareRealtimeEngineNodeCapabilities;
@@ -93,6 +120,15 @@ export class SonareEngine {
93
120
  private nextMarkerId = 1;
94
121
  private transportPlaying = false;
95
122
  private readonly pendingInstrumentSync: SonareEngineInstrumentSyncMessage[] = [];
123
+ // One latest frontier per clip is sufficient: ClipPageStreamer expands it to
124
+ // the bounded read window. A Map both coalesces repeated page misses and
125
+ // places a hard cap on work queued while OPFS I/O is stalled.
126
+ private readonly workletClipPageRequests = new Map<number, ClipPageStreamerRequest>();
127
+ private readonly workletPageProviderClipIds = new Map<number, number>();
128
+ private workletClipStreamer: ClipPageStreamer | undefined;
129
+ private workletClipPump: Promise<void> | undefined;
130
+ private workletClipPagePollTimer: ReturnType<typeof setInterval> | undefined;
131
+ private unsubscribeWorkletClipRequests: (() => void) | undefined;
96
132
  private destroyed = false;
97
133
 
98
134
  private constructor(
@@ -111,10 +147,17 @@ export class SonareEngine {
111
147
  this.sampleRate = sampleRate;
112
148
  this.offlineBlockSize = offlineBlockSize;
113
149
  this.offlineChannelCount = offlineChannelCount;
150
+ this.unsubscribeWorkletClipRequests = this.realtimeNode.onClipPageRequests((message) => {
151
+ for (const request of message.requests) {
152
+ this.enqueueWorkletClipPageRequest(request);
153
+ }
154
+ this.pumpWorkletClipPages();
155
+ });
114
156
  this.transport = buildTransportFacade({
115
157
  sampleRate: this.sampleRate,
116
158
  realtimeNode: this.realtimeNode,
117
159
  offlineEngine: this.offlineEngine,
160
+ flushOfflineMirror: () => this.flushOfflineMirror(),
118
161
  setTransportPlaying: (playing) => {
119
162
  this.transportPlaying = playing;
120
163
  },
@@ -136,8 +179,17 @@ export class SonareEngine {
136
179
  Math.floor(options.offlineChannelCount ?? options.channelCount ?? 2),
137
180
  );
138
181
  const realtimeNode = await SonareRealtimeEngineNode.create(context, options);
182
+ try {
183
+ // Do not expose the facade while the registered processor is still
184
+ // buffering messages. Long clip sync must either begin after the bridge
185
+ // is ready or fail explicitly; it may never be silently truncated.
186
+ await realtimeNode.ready;
187
+ } catch (error) {
188
+ realtimeNode.destroy();
189
+ throw error;
190
+ }
139
191
  const offlineEngine = options.offlineEngine ?? new RealtimeEngine(sampleRate, blockSize);
140
- return new SonareEngine(
192
+ const engine = new SonareEngine(
141
193
  context,
142
194
  realtimeNode,
143
195
  offlineEngine,
@@ -145,6 +197,8 @@ export class SonareEngine {
145
197
  blockSize,
146
198
  channelCount,
147
199
  );
200
+ engine.syncParameters();
201
+ return engine;
148
202
  }
149
203
 
150
204
  async suspend(): Promise<void> {
@@ -319,10 +373,29 @@ export class SonareEngine {
319
373
  return parameter.listParameters(this.parameterContext);
320
374
  }
321
375
 
376
+ /** Registers a custom parameter on the offline mirror and worklet engine. */
377
+ addParameter(info: EngineParameterInfo): void {
378
+ parameter.addParameter(this.parameterContext, info);
379
+ }
380
+
381
+ /** Clears custom parameters and their automation lanes on both engines. */
382
+ clearParameters(): void {
383
+ parameter.clearParameters(this.parameterContext);
384
+ }
385
+
322
386
  setSoloMute(target: string | number, solo: boolean, mute: boolean): boolean {
323
387
  return parameter.setSoloMute(this.parameterContext, target, solo, mute);
324
388
  }
325
389
 
390
+ /** Queues a per-track PFL/AFL monitor tap mode change. */
391
+ setTrackMonitorMode(
392
+ target: string | number,
393
+ mode: EngineTrackMonitorMode,
394
+ renderFrame = -1,
395
+ ): boolean {
396
+ return parameter.setTrackMonitorMode(this.parameterContext, target, mode, renderFrame);
397
+ }
398
+
326
399
  setStripGain(target: string | number, db: number): boolean {
327
400
  return this.sendSmoothedParam(this.stripParamId(target, ENGINE_MIXER_PARAM_FADER_DB), db);
328
401
  }
@@ -516,11 +589,86 @@ export class SonareEngine {
516
589
  this.setMasterStripJson(sceneJson);
517
590
  }
518
591
 
592
+ /**
593
+ * Creates and primes an OPFS-backed page provider for this live worklet
594
+ * engine. Pass the returned `provider` to {@link addClip} in place of a
595
+ * `Float32Array[]`; the `clipId` in `options` must equal that clip's explicit
596
+ * `opts.id`. Subsequent cache misses are fetched on the main thread and
597
+ * supplied back to the worklet through its bounded pull protocol.
598
+ */
599
+ async attachOpfsClipStream(
600
+ options: OpfsClipStreamOptions,
601
+ ): Promise<{ binding: OpfsClipPageProviderBinding; provider: ClipPageProvider }> {
602
+ if (this.destroyed) {
603
+ throw new Error('SonareEngine is destroyed.');
604
+ }
605
+ if (!this.capabilities.clipPageRequestsRealtimeSafe) {
606
+ throw new Error(
607
+ 'OPFS clip streaming requires SharedArrayBuffer clip-page requests; the postMessage fallback is not realtime-safe.',
608
+ );
609
+ }
610
+ const { clipId, primePages = 1, ...providerOptions } = options;
611
+ if ([...this.workletPageProviderClipIds.values()].includes(clipId)) {
612
+ throw new Error(`An OPFS stream is already attached for clip ${clipId}.`);
613
+ }
614
+ const streamer = this.ensureWorkletClipStreamer();
615
+ // Allocate the worklet-owned provider before priming: port message order
616
+ // ensures every prime page is adopted before addClip commits its schedule.
617
+ this.postSync({
618
+ type: 'syncClipPageProvider',
619
+ clipId,
620
+ numChannels: providerOptions.numChannels,
621
+ numSamples: providerOptions.numSamples,
622
+ pageFrames: providerOptions.pageFrames,
623
+ });
624
+ let binding: OpfsClipPageProviderBinding;
625
+ binding = createOpfsClipPageProvider(this.offlineEngine, {
626
+ ...providerOptions,
627
+ onPageSupplied: (pageIndex, channels) => {
628
+ this.postSync(
629
+ { type: 'syncClipPage', clipId, pageIndex, channels },
630
+ transferableAudioBuffers(channels),
631
+ );
632
+ },
633
+ onPageCleared: (pageIndex) => {
634
+ this.postSync({ type: 'syncClipPageClear', clipId, pageIndex });
635
+ },
636
+ onClose: () => {
637
+ this.workletPageProviderClipIds.delete(binding.provider.id);
638
+ this.postSync({ type: 'syncClipPageDestroy', clipId });
639
+ },
640
+ });
641
+ this.workletPageProviderClipIds.set(binding.provider.id, clipId);
642
+ const lastPage = Math.ceil(providerOptions.numSamples / providerOptions.pageFrames) - 1;
643
+ const primed: number[] = [];
644
+ try {
645
+ for (let page = 0; page < primePages && page <= lastPage; ++page) {
646
+ if (await binding.supplyPage(page)) {
647
+ primed.push(page);
648
+ }
649
+ }
650
+ streamer.addSource(
651
+ {
652
+ clipId,
653
+ binding,
654
+ pageFrames: providerOptions.pageFrames,
655
+ numSamples: providerOptions.numSamples,
656
+ },
657
+ primed,
658
+ );
659
+ this.startWorkletClipPagePolling();
660
+ } catch (error) {
661
+ binding.close();
662
+ throw error;
663
+ }
664
+ return { binding, provider: binding.provider };
665
+ }
666
+
519
667
  addClip(
520
668
  trackId: string | number,
521
- buffer: Float32Array[],
669
+ buffer: Float32Array[] | ClipPageProvider,
522
670
  startPpq: number,
523
- opts: Partial<Omit<EngineClip, 'channels' | 'startPpq'>> = {},
671
+ opts: Partial<Omit<EngineClip, 'channels' | 'pageProvider' | 'startPpq'>> = {},
524
672
  ): number {
525
673
  return clips.addClip(this.clipContext, trackId, buffer, startPpq, opts);
526
674
  }
@@ -550,7 +698,12 @@ export class SonareEngine {
550
698
 
551
699
  setSf2Instrument(
552
700
  trackId: string | number,
553
- config: { destinationId?: number; gain?: number; polyphony?: number } = {},
701
+ config: {
702
+ destinationId?: number;
703
+ gain?: number;
704
+ polyphony?: number;
705
+ preferModelForModeledFamilies?: boolean;
706
+ } = {},
554
707
  ): void {
555
708
  strips.setSf2Instrument(this.stripContext, trackId, config);
556
709
  }
@@ -623,10 +776,90 @@ export class SonareEngine {
623
776
  strips.pushMidiCc(this.stripContext, trackId, group, channel, controller, value, renderFrame);
624
777
  }
625
778
 
779
+ pushMidiUmp(trackId: string | number, word0: number, renderFrame = -1): void {
780
+ strips.pushMidiUmp(this.stripContext, trackId, word0, renderFrame);
781
+ }
782
+
626
783
  pushMidiSysex(trackId: string | number, data: Uint8Array, renderFrame = -1): void {
627
784
  strips.pushMidiSysex(this.stripContext, trackId, data, renderFrame);
628
785
  }
629
786
 
787
+ bindMidiCc(
788
+ channel: number,
789
+ controller: number,
790
+ paramId: number,
791
+ options: MidiCcBindOptions = {},
792
+ ): void {
793
+ const minValue = options.minValue ?? 0;
794
+ const maxValue = options.maxValue ?? 1;
795
+ this.offlineEngine.bindMidiCc(channel, controller, paramId, { minValue, maxValue });
796
+ this.postSync({ type: 'syncMidiCcBinding', channel, controller, paramId, minValue, maxValue });
797
+ }
798
+
799
+ setMidiInputSource(destinationId = 0): void {
800
+ this.offlineEngine.setMidiInputSource(destinationId);
801
+ this.postSync({ type: 'syncMidiInputSource', destinationId });
802
+ }
803
+
804
+ clearMidiInputSource(): void {
805
+ this.offlineEngine.clearMidiInputSource();
806
+ this.postSync({ type: 'syncClearMidiInputSource' });
807
+ }
808
+
809
+ pushMidiInputNoteOn(
810
+ group: number,
811
+ channel: number,
812
+ note: number,
813
+ velocity: number,
814
+ portTimeSamples = 0,
815
+ ): void {
816
+ this.offlineEngine.pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples);
817
+ this.postSync({
818
+ type: 'syncMidiInputNoteOn',
819
+ group,
820
+ channel,
821
+ data0: note,
822
+ data1: velocity,
823
+ portTimeSamples,
824
+ });
825
+ }
826
+
827
+ pushMidiInputNoteOff(
828
+ group: number,
829
+ channel: number,
830
+ note: number,
831
+ velocity = 0,
832
+ portTimeSamples = 0,
833
+ ): void {
834
+ this.offlineEngine.pushMidiInputNoteOff(group, channel, note, velocity, portTimeSamples);
835
+ this.postSync({
836
+ type: 'syncMidiInputNoteOff',
837
+ group,
838
+ channel,
839
+ data0: note,
840
+ data1: velocity,
841
+ portTimeSamples,
842
+ });
843
+ }
844
+
845
+ pushMidiInputCc(
846
+ group: number,
847
+ channel: number,
848
+ controller: number,
849
+ value: number,
850
+ portTimeSamples = 0,
851
+ ): void {
852
+ this.offlineEngine.pushMidiInputCc(group, channel, controller, value, portTimeSamples);
853
+ this.postSync({
854
+ type: 'syncMidiInputCc',
855
+ group,
856
+ channel,
857
+ data0: controller,
858
+ data1: value,
859
+ portTimeSamples,
860
+ });
861
+ }
862
+
630
863
  pushMidiPanic(renderFrame = -1): void {
631
864
  this.offlineEngine.pushMidiPanic(renderFrame);
632
865
  this.postSync({ type: 'syncMidiPanic', renderFrame });
@@ -636,6 +869,10 @@ export class SonareEngine {
636
869
  capture.configureCapture(this.captureContext, options);
637
870
  }
638
871
 
872
+ /**
873
+ * Arms the engine-global capture path. `trackId` is retained for source
874
+ * compatibility and must be `0`; per-track capture is not implemented.
875
+ */
639
876
  armRecord(trackId: string | number, enabled: boolean): boolean {
640
877
  return capture.armRecord(this.captureContext, trackId, enabled);
641
878
  }
@@ -662,7 +899,7 @@ export class SonareEngine {
662
899
  // the fixed-size SAB command record, so it is delivered out-of-band; the
663
900
  // SetMetronome command then toggles enabled state on the audio thread.
664
901
  this.postSync({ type: 'syncMetronome', config: opts });
665
- this.realtimeNode.sendCommand({
902
+ this.sendMirroredCommand({
666
903
  type: SonareEngineCommandType.SetMetronome,
667
904
  sampleTime: -1,
668
905
  argInt: opts.enabled ? 1 : 0,
@@ -748,6 +985,15 @@ export class SonareEngine {
748
985
  if (this.destroyed) {
749
986
  return;
750
987
  }
988
+ this.unsubscribeWorkletClipRequests?.();
989
+ this.unsubscribeWorkletClipRequests = undefined;
990
+ this.workletClipStreamer?.close();
991
+ this.workletClipStreamer = undefined;
992
+ if (this.workletClipPagePollTimer !== undefined) {
993
+ clearInterval(this.workletClipPagePollTimer);
994
+ this.workletClipPagePollTimer = undefined;
995
+ }
996
+ this.workletClipPageRequests.clear();
751
997
  this.destroyed = true;
752
998
  this.transport.stop();
753
999
  this.realtimeNode.pollTelemetry();
@@ -755,6 +1001,107 @@ export class SonareEngine {
755
1001
  this.offlineEngine.destroy();
756
1002
  }
757
1003
 
1004
+ private ensureWorkletClipStreamer(): ClipPageStreamer {
1005
+ if (!this.workletClipStreamer) {
1006
+ this.workletClipStreamer = new ClipPageStreamer({
1007
+ popClipPageRequest: () => this.popWorkletClipPageRequest(),
1008
+ });
1009
+ }
1010
+ return this.workletClipStreamer;
1011
+ }
1012
+
1013
+ /**
1014
+ * SAB requests have no postMessage wake-up by design. Polling on the main
1015
+ * thread is therefore intentionally outside the audio callback; 8 ms keeps
1016
+ * the bounded OPFS prefetch frontier responsive without adding worklet GC.
1017
+ */
1018
+ private startWorkletClipPagePolling(): void {
1019
+ if (this.workletClipPagePollTimer !== undefined) {
1020
+ return;
1021
+ }
1022
+ const poll = () => {
1023
+ if (!this.destroyed) {
1024
+ this.realtimeNode.pollClipPageRequests();
1025
+ }
1026
+ };
1027
+ poll();
1028
+ this.workletClipPagePollTimer = setInterval(poll, 8);
1029
+ }
1030
+
1031
+ private pumpWorkletClipPages(): void {
1032
+ const streamer = this.workletClipStreamer;
1033
+ if (!streamer || this.workletClipPump) {
1034
+ return;
1035
+ }
1036
+ this.workletClipPump = streamer
1037
+ .pump()
1038
+ .catch((error: unknown) => {
1039
+ // The worklet keeps rendering silence for an unavailable page. Surface
1040
+ // I/O failures without converting them into an AudioWorklet exception.
1041
+ // biome-ignore lint/suspicious/noConsole: asynchronous OPFS diagnostic.
1042
+ console.error('Sonare OPFS clip-page supply failed:', error);
1043
+ })
1044
+ .finally(() => {
1045
+ this.workletClipPump = undefined;
1046
+ if (this.workletClipPageRequests.size > 0) {
1047
+ this.pumpWorkletClipPages();
1048
+ }
1049
+ });
1050
+ }
1051
+
1052
+ private enqueueWorkletClipPageRequest(request: ClipPageStreamerRequest): void {
1053
+ if (
1054
+ !Number.isInteger(request.clipId) ||
1055
+ request.clipId < 0 ||
1056
+ !Number.isInteger(request.pageIndex) ||
1057
+ (request.pageIndex ?? -1) < 0
1058
+ ) {
1059
+ return;
1060
+ }
1061
+ // Reinsert an existing id so iteration order represents newest frontiers.
1062
+ this.workletClipPageRequests.delete(request.clipId);
1063
+ if (this.workletClipPageRequests.size >= MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS) {
1064
+ const oldest = this.workletClipPageRequests.keys().next().value;
1065
+ if (oldest !== undefined) {
1066
+ this.workletClipPageRequests.delete(oldest);
1067
+ }
1068
+ }
1069
+ this.workletClipPageRequests.set(request.clipId, request);
1070
+ }
1071
+
1072
+ private popWorkletClipPageRequest(): ClipPageStreamerRequest | null {
1073
+ const entry = this.workletClipPageRequests.entries().next().value;
1074
+ if (!entry) {
1075
+ return null;
1076
+ }
1077
+ const [clipId, request] = entry;
1078
+ this.workletClipPageRequests.delete(clipId);
1079
+ return request;
1080
+ }
1081
+
1082
+ private commitWorkletClipPageProvider(clip: EngineClip): boolean {
1083
+ const providerId =
1084
+ typeof clip.pageProvider === 'object' && clip.pageProvider !== null
1085
+ ? clip.pageProvider.id
1086
+ : clip.pageProvider;
1087
+ if (providerId === undefined) {
1088
+ return false;
1089
+ }
1090
+ const clipId = this.workletPageProviderClipIds.get(providerId);
1091
+ if (clipId === undefined) {
1092
+ return false;
1093
+ }
1094
+ if (clip.id !== clipId) {
1095
+ throw new Error(`OPFS stream clipId ${clipId} must match addClip(..., { id: ${clipId} }).`);
1096
+ }
1097
+ this.postSync({
1098
+ type: 'syncClipPageCommit',
1099
+ clipId,
1100
+ clip: { ...clip, channels: undefined, pageProvider: undefined },
1101
+ });
1102
+ return true;
1103
+ }
1104
+
758
1105
  private mixerLanes(): EngineTrackLane[] {
759
1106
  return mixer.mixerLanes(this.mixerContext);
760
1107
  }
@@ -791,13 +1138,32 @@ export class SonareEngine {
791
1138
  if (this.destroyed) {
792
1139
  return;
793
1140
  }
794
- if (transfer && transfer.length > 0) {
795
- this.realtimeNode.node.port.postMessage(message, transfer);
796
- } else {
797
- this.realtimeNode.node.port.postMessage(message);
1141
+ try {
1142
+ if (transfer && transfer.length > 0) {
1143
+ this.realtimeNode.node.port.postMessage(message, transfer);
1144
+ } else {
1145
+ this.realtimeNode.node.port.postMessage(message);
1146
+ }
1147
+ } finally {
1148
+ this.flushOfflineMirror();
798
1149
  }
799
1150
  }
800
1151
 
1152
+ // The offline engine is a control-thread mirror. It has no render loop to
1153
+ // drain its command ring, so drain it after each control operation. Never
1154
+ // call this from the worklet/audio path: it is intentionally control-only.
1155
+ private flushOfflineMirror(): void {
1156
+ this.offlineEngine.flushControlCommands();
1157
+ }
1158
+
1159
+ private sendMirroredCommand(
1160
+ command: Parameters<SonareRealtimeEngineNode['sendCommand']>[0],
1161
+ ): boolean {
1162
+ const accepted = this.realtimeNode.sendCommand(command);
1163
+ this.flushOfflineMirror();
1164
+ return accepted;
1165
+ }
1166
+
801
1167
  // Collaborator surface handed to the mixer/routing free functions so they can
802
1168
  // mutate the routing stores (held by reference), mirror into the offline
803
1169
  // engine, post mixer-sync messages, and declare lanes/buses without a
@@ -855,13 +1221,13 @@ export class SonareEngine {
855
1221
  return {
856
1222
  offlineEngine: this.offlineEngine,
857
1223
  realtimeNode: this.realtimeNode,
1224
+ sendCommand: (command) => this.sendMirroredCommand(command),
858
1225
  offlineChannelCount: this.offlineChannelCount,
859
1226
  postSync: (message) => this.postSync(message),
860
1227
  getCaptureConfig: () => this.captureConfig,
861
1228
  setCaptureConfig: (config) => {
862
1229
  this.captureConfig = config;
863
1230
  },
864
- resolveTargetId: (target) => this.resolveTargetId(target),
865
1231
  };
866
1232
  }
867
1233
 
@@ -872,7 +1238,9 @@ export class SonareEngine {
872
1238
  private get parameterContext(): EngineParameterContext {
873
1239
  return {
874
1240
  offlineEngine: this.offlineEngine,
875
- realtimeNode: this.realtimeNode,
1241
+ sendCommand: (command) => this.sendMirroredCommand(command),
1242
+ postSync: (message) => this.postSync(message),
1243
+ automationLanes: this.automationLanes,
876
1244
  trackLaneIds: this.trackLaneIds,
877
1245
  resolveParamId: (nodeId, param) => this.resolveParamId(nodeId, param),
878
1246
  ensureTrackLane: (target) => this.ensureTrackLane(target),
@@ -887,6 +1255,7 @@ export class SonareEngine {
887
1255
  return {
888
1256
  offlineEngine: this.offlineEngine,
889
1257
  realtimeNode: this.realtimeNode,
1258
+ sendCommand: (command) => this.sendMirroredCommand(command),
890
1259
  postSync: (message) => this.postSync(message),
891
1260
  getTempoBpm: () => this.tempoBpm,
892
1261
  setTempoBpm: (bpm) => {
@@ -923,6 +1292,7 @@ export class SonareEngine {
923
1292
  postSync: (message, transfer) => this.postSync(message, transfer),
924
1293
  ensureTrackLane: (target) => this.ensureTrackLane(target),
925
1294
  resolveTargetId: (target) => this.resolveTargetId(target),
1295
+ commitWorkletClipPageProvider: (clip) => this.commitWorkletClipPageProvider(clip),
926
1296
  };
927
1297
  }
928
1298
 
@@ -938,7 +1308,7 @@ export class SonareEngine {
938
1308
  this.nextMarkerId = value;
939
1309
  },
940
1310
  postSync: (message) => this.postSync(message),
941
- sendCommand: (command) => this.realtimeNode.sendCommand(command),
1311
+ sendCommand: (command) => this.sendMirroredCommand(command),
942
1312
  setLoop: (startPpq, endPpq, enabled) => this.setLoop(startPpq, endPpq, enabled),
943
1313
  };
944
1314
  }
@@ -956,7 +1326,7 @@ export class SonareEngine {
956
1326
  // sample-accurate smoothed-param command to the realtime runtime.
957
1327
  private sendSmoothedParam(paramId: number, value: number): boolean {
958
1328
  this.offlineEngine.setParameter(paramId, value);
959
- return this.realtimeNode.sendCommand({
1329
+ return this.sendMirroredCommand({
960
1330
  type: SonareEngineCommandType.SetParamSmoothed,
961
1331
  targetId: paramId,
962
1332
  sampleTime: -1,
@@ -968,6 +1338,13 @@ export class SonareEngine {
968
1338
  return resolveParamId(this.listParameters(), nodeId, param);
969
1339
  }
970
1340
 
1341
+ private syncParameters(): void {
1342
+ const parameters = this.listParameters();
1343
+ if (parameters.length > 0) {
1344
+ this.postSync({ type: 'syncParameters', parameters });
1345
+ }
1346
+ }
1347
+
971
1348
  private resolveTargetId(target: string | number): number {
972
1349
  return resolveTargetId(target);
973
1350
  }