@libraz/libsonare 1.5.4 → 1.6.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 (79) hide show
  1. package/README.md +109 -20
  2. package/dist/analysis.d.ts +5730 -0
  3. package/dist/analysis.js +2464 -0
  4. package/dist/analysis.js.map +1 -0
  5. package/dist/index.d.ts +873 -63
  6. package/dist/index.js +1037 -265
  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 +707 -0
  16. package/dist/worker.js.map +1 -0
  17. package/dist/worklet.d.ts +596 -86
  18. package/dist/worklet.js +1658 -235
  19. package/dist/worklet.js.map +1 -1
  20. package/package.json +23 -11
  21. package/src/_chain_config.ts +5 -23
  22. package/src/analysis.ts +137 -0
  23. package/src/analysis_helpers.ts +29 -0
  24. package/src/audio.ts +5 -3
  25. package/src/clip_page_streamer.ts +62 -8
  26. package/src/codes.ts +51 -64
  27. package/src/effects_mastering.ts +1 -0
  28. package/src/effects_transform.ts +12 -6
  29. package/src/errors.ts +1 -0
  30. package/src/feature_core.ts +159 -3
  31. package/src/feature_music.ts +35 -16
  32. package/src/feature_pitch.ts +75 -6
  33. package/src/feature_spectral.ts +234 -8
  34. package/src/feature_spectrogram.ts +186 -5
  35. package/src/features.ts +16 -1
  36. package/src/index.ts +104 -7
  37. package/src/mastering_chain.ts +20 -8
  38. package/src/mastering_core.ts +7 -0
  39. package/src/mastering_dynamics.ts +7 -1
  40. package/src/metering.ts +51 -5
  41. package/src/mixer.ts +14 -1
  42. package/src/opfs_clip_pages.ts +26 -1
  43. package/src/project.ts +7 -0
  44. package/src/project_class.ts +74 -13
  45. package/src/project_internal.ts +24 -29
  46. package/src/project_types.ts +69 -10
  47. package/src/public_types.ts +68 -0
  48. package/src/public_types_mastering.ts +28 -0
  49. package/src/public_types_mixing.ts +11 -2
  50. package/src/public_types_music.ts +13 -0
  51. package/src/public_types_spectral.ts +43 -3
  52. package/src/quick_analysis.ts +48 -12
  53. package/src/realtime_engine.ts +162 -4
  54. package/src/realtime_voice_changer.ts +51 -77
  55. package/src/sonare-analysis.js.d.ts +8 -0
  56. package/src/sonare.js.d.ts +305 -9
  57. package/src/streaming_processors.ts +22 -1
  58. package/src/validation.ts +6 -0
  59. package/src/web_midi.ts +36 -4
  60. package/src/worker.ts +196 -0
  61. package/src/worker_client.ts +352 -0
  62. package/src/worker_protocol.ts +59 -0
  63. package/src/worklet/engine-automation.ts +2 -1
  64. package/src/worklet/engine-capture-facade.ts +13 -4
  65. package/src/worklet/engine-clips.ts +12 -3
  66. package/src/worklet/engine-node.ts +212 -4
  67. package/src/worklet/engine-offline.ts +7 -2
  68. package/src/worklet/engine-parameter-facade.ts +26 -5
  69. package/src/worklet/engine-processor.ts +383 -42
  70. package/src/worklet/engine-register.ts +8 -0
  71. package/src/worklet/engine-strips.ts +17 -1
  72. package/src/worklet/engine-sync.ts +2 -6
  73. package/src/worklet/engine-tempo-facade.ts +8 -6
  74. package/src/worklet/engine.ts +380 -13
  75. package/src/worklet/guards.ts +22 -1
  76. package/src/worklet/messages.ts +141 -9
  77. package/src/worklet/protocol.ts +303 -17
  78. package/src/worklet/voice-changer-processor.ts +12 -15
  79. package/src/worklet.ts +20 -1
@@ -65,11 +65,7 @@ export function resolveTargetId(target: string | number): number {
65
65
  return Number.isFinite(parsed) ? parsed : 0;
66
66
  }
67
67
 
68
- /**
69
- * Resolves a parameter id from a name or numeric id. Numeric params pass
70
- * through; named params are matched against the parameter list, then fall back
71
- * to a target-id resolution of the name (or the node id).
72
- */
68
+ /** Resolves a registered parameter name or passes through a numeric id. */
73
69
  export function resolveParamId(
74
70
  parameters: readonly EngineParameterInfo[],
75
71
  nodeId: string,
@@ -82,7 +78,7 @@ export function resolveParamId(
82
78
  if (byName) {
83
79
  return byName.id;
84
80
  }
85
- return resolveTargetId(param || nodeId);
81
+ throw new RangeError(`Unknown engine parameter ${JSON.stringify(param)} for node ${nodeId}`);
86
82
  }
87
83
 
88
84
  /** Encodes an automation curve descriptor to the engine's numeric curve code. */
@@ -24,6 +24,13 @@ interface TimeSignature {
24
24
  export interface EngineTempoContext {
25
25
  readonly offlineEngine: RealtimeEngine;
26
26
  readonly realtimeNode: SonareRealtimeEngineNode;
27
+ sendCommand(command: {
28
+ type: SonareEngineCommandType;
29
+ targetId?: number;
30
+ sampleTime?: number;
31
+ argFloat?: number;
32
+ argInt?: number;
33
+ }): boolean;
27
34
  postSync(message: SonareEngineSyncMessage): void;
28
35
  getTempoBpm(): number;
29
36
  setTempoBpm(bpm: number): void;
@@ -54,11 +61,6 @@ export function setTempo(ctx: EngineTempoContext, bpm: number): void {
54
61
  ctx.setTempoSegments([{ startPpq: 0, bpm }]);
55
62
  ctx.offlineEngine.setTempo(bpm);
56
63
  postTempoSync(ctx);
57
- ctx.realtimeNode.sendCommand({
58
- type: SonareEngineCommandType.SetTempoMap,
59
- sampleTime: -1,
60
- argFloat: bpm,
61
- });
62
64
  }
63
65
 
64
66
  export function setTempoSegments(
@@ -113,7 +115,7 @@ export function setLoop(
113
115
  // while loop STARTS and the offline path stay exact. This is intentional:
114
116
  // the record has no second free Float64 lane, and a micro-PPQ grid on the
115
117
  // loop end is well below audible/sample-accurate resolution at any tempo.
116
- return ctx.realtimeNode.sendCommand({
118
+ return ctx.sendCommand({
117
119
  type: SonareEngineCommandType.SetLoop,
118
120
  targetId: enabled ? 1 : 0,
119
121
  sampleTime: -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,
@@ -13,10 +18,13 @@ import type {
13
18
  EngineTrackSend,
14
19
  EngineTransportState,
15
20
  EqBand,
21
+ MidiCcBindOptions,
16
22
  PanLaw,
17
23
  PanMode,
18
24
  } from '../index';
19
25
  import { RealtimeEngine } from '../index';
26
+ import { createOpfsClipPageProvider, type OpfsClipPageProviderBinding } from '../opfs_clip_pages';
27
+ import type { ClipPageProvider } from '../realtime_engine';
20
28
  import type { EngineAutomationContext } from './engine-automation';
21
29
  import * as automation from './engine-automation';
22
30
  import type { EngineCaptureContext } from './engine-capture-facade';
@@ -56,6 +64,24 @@ import {
56
64
  type SonareWorkletScopeSnapshot,
57
65
  } from './protocol';
58
66
 
67
+ const MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS = 256;
68
+
69
+ function transferableAudioBuffers(channels: readonly Float32Array[]): Transferable[] {
70
+ const transfers: ArrayBuffer[] = [];
71
+ const seen = new Set<ArrayBuffer>();
72
+ for (const channel of channels) {
73
+ const buffer = channel.buffer;
74
+ // SharedArrayBuffer is cloneable but cannot appear in a transfer list. A
75
+ // worker may intentionally return SAB-backed pages, so transfer only plain,
76
+ // distinct ArrayBuffers and let structured clone share SABs by reference.
77
+ if (buffer instanceof ArrayBuffer && !seen.has(buffer)) {
78
+ seen.add(buffer);
79
+ transfers.push(buffer);
80
+ }
81
+ }
82
+ return transfers;
83
+ }
84
+
59
85
  export class SonareEngine {
60
86
  readonly node: AudioWorkletNode;
61
87
  readonly capabilities: SonareRealtimeEngineNodeCapabilities;
@@ -93,6 +119,15 @@ export class SonareEngine {
93
119
  private nextMarkerId = 1;
94
120
  private transportPlaying = false;
95
121
  private readonly pendingInstrumentSync: SonareEngineInstrumentSyncMessage[] = [];
122
+ // One latest frontier per clip is sufficient: ClipPageStreamer expands it to
123
+ // the bounded read window. A Map both coalesces repeated page misses and
124
+ // places a hard cap on work queued while OPFS I/O is stalled.
125
+ private readonly workletClipPageRequests = new Map<number, ClipPageStreamerRequest>();
126
+ private readonly workletPageProviderClipIds = new Map<number, number>();
127
+ private workletClipStreamer: ClipPageStreamer | undefined;
128
+ private workletClipPump: Promise<void> | undefined;
129
+ private workletClipPagePollTimer: ReturnType<typeof setInterval> | undefined;
130
+ private unsubscribeWorkletClipRequests: (() => void) | undefined;
96
131
  private destroyed = false;
97
132
 
98
133
  private constructor(
@@ -111,10 +146,17 @@ export class SonareEngine {
111
146
  this.sampleRate = sampleRate;
112
147
  this.offlineBlockSize = offlineBlockSize;
113
148
  this.offlineChannelCount = offlineChannelCount;
149
+ this.unsubscribeWorkletClipRequests = this.realtimeNode.onClipPageRequests((message) => {
150
+ for (const request of message.requests) {
151
+ this.enqueueWorkletClipPageRequest(request);
152
+ }
153
+ this.pumpWorkletClipPages();
154
+ });
114
155
  this.transport = buildTransportFacade({
115
156
  sampleRate: this.sampleRate,
116
157
  realtimeNode: this.realtimeNode,
117
158
  offlineEngine: this.offlineEngine,
159
+ flushOfflineMirror: () => this.flushOfflineMirror(),
118
160
  setTransportPlaying: (playing) => {
119
161
  this.transportPlaying = playing;
120
162
  },
@@ -136,8 +178,17 @@ export class SonareEngine {
136
178
  Math.floor(options.offlineChannelCount ?? options.channelCount ?? 2),
137
179
  );
138
180
  const realtimeNode = await SonareRealtimeEngineNode.create(context, options);
181
+ try {
182
+ // Do not expose the facade while the registered processor is still
183
+ // buffering messages. Long clip sync must either begin after the bridge
184
+ // is ready or fail explicitly; it may never be silently truncated.
185
+ await realtimeNode.ready;
186
+ } catch (error) {
187
+ realtimeNode.destroy();
188
+ throw error;
189
+ }
139
190
  const offlineEngine = options.offlineEngine ?? new RealtimeEngine(sampleRate, blockSize);
140
- return new SonareEngine(
191
+ const engine = new SonareEngine(
141
192
  context,
142
193
  realtimeNode,
143
194
  offlineEngine,
@@ -145,6 +196,8 @@ export class SonareEngine {
145
196
  blockSize,
146
197
  channelCount,
147
198
  );
199
+ engine.syncParameters();
200
+ return engine;
148
201
  }
149
202
 
150
203
  async suspend(): Promise<void> {
@@ -319,6 +372,16 @@ export class SonareEngine {
319
372
  return parameter.listParameters(this.parameterContext);
320
373
  }
321
374
 
375
+ /** Registers a custom parameter on the offline mirror and worklet engine. */
376
+ addParameter(info: EngineParameterInfo): void {
377
+ parameter.addParameter(this.parameterContext, info);
378
+ }
379
+
380
+ /** Clears custom parameters and their automation lanes on both engines. */
381
+ clearParameters(): void {
382
+ parameter.clearParameters(this.parameterContext);
383
+ }
384
+
322
385
  setSoloMute(target: string | number, solo: boolean, mute: boolean): boolean {
323
386
  return parameter.setSoloMute(this.parameterContext, target, solo, mute);
324
387
  }
@@ -516,11 +579,86 @@ export class SonareEngine {
516
579
  this.setMasterStripJson(sceneJson);
517
580
  }
518
581
 
582
+ /**
583
+ * Creates and primes an OPFS-backed page provider for this live worklet
584
+ * engine. Pass the returned `provider` to {@link addClip} in place of a
585
+ * `Float32Array[]`; the `clipId` in `options` must equal that clip's explicit
586
+ * `opts.id`. Subsequent cache misses are fetched on the main thread and
587
+ * supplied back to the worklet through its bounded pull protocol.
588
+ */
589
+ async attachOpfsClipStream(
590
+ options: OpfsClipStreamOptions,
591
+ ): Promise<{ binding: OpfsClipPageProviderBinding; provider: ClipPageProvider }> {
592
+ if (this.destroyed) {
593
+ throw new Error('SonareEngine is destroyed.');
594
+ }
595
+ if (!this.capabilities.clipPageRequestsRealtimeSafe) {
596
+ throw new Error(
597
+ 'OPFS clip streaming requires SharedArrayBuffer clip-page requests; the postMessage fallback is not realtime-safe.',
598
+ );
599
+ }
600
+ const { clipId, primePages = 1, ...providerOptions } = options;
601
+ if ([...this.workletPageProviderClipIds.values()].includes(clipId)) {
602
+ throw new Error(`An OPFS stream is already attached for clip ${clipId}.`);
603
+ }
604
+ const streamer = this.ensureWorkletClipStreamer();
605
+ // Allocate the worklet-owned provider before priming: port message order
606
+ // ensures every prime page is adopted before addClip commits its schedule.
607
+ this.postSync({
608
+ type: 'syncClipPageProvider',
609
+ clipId,
610
+ numChannels: providerOptions.numChannels,
611
+ numSamples: providerOptions.numSamples,
612
+ pageFrames: providerOptions.pageFrames,
613
+ });
614
+ let binding: OpfsClipPageProviderBinding;
615
+ binding = createOpfsClipPageProvider(this.offlineEngine, {
616
+ ...providerOptions,
617
+ onPageSupplied: (pageIndex, channels) => {
618
+ this.postSync(
619
+ { type: 'syncClipPage', clipId, pageIndex, channels },
620
+ transferableAudioBuffers(channels),
621
+ );
622
+ },
623
+ onPageCleared: (pageIndex) => {
624
+ this.postSync({ type: 'syncClipPageClear', clipId, pageIndex });
625
+ },
626
+ onClose: () => {
627
+ this.workletPageProviderClipIds.delete(binding.provider.id);
628
+ this.postSync({ type: 'syncClipPageDestroy', clipId });
629
+ },
630
+ });
631
+ this.workletPageProviderClipIds.set(binding.provider.id, clipId);
632
+ const lastPage = Math.ceil(providerOptions.numSamples / providerOptions.pageFrames) - 1;
633
+ const primed: number[] = [];
634
+ try {
635
+ for (let page = 0; page < primePages && page <= lastPage; ++page) {
636
+ if (await binding.supplyPage(page)) {
637
+ primed.push(page);
638
+ }
639
+ }
640
+ streamer.addSource(
641
+ {
642
+ clipId,
643
+ binding,
644
+ pageFrames: providerOptions.pageFrames,
645
+ numSamples: providerOptions.numSamples,
646
+ },
647
+ primed,
648
+ );
649
+ this.startWorkletClipPagePolling();
650
+ } catch (error) {
651
+ binding.close();
652
+ throw error;
653
+ }
654
+ return { binding, provider: binding.provider };
655
+ }
656
+
519
657
  addClip(
520
658
  trackId: string | number,
521
- buffer: Float32Array[],
659
+ buffer: Float32Array[] | ClipPageProvider,
522
660
  startPpq: number,
523
- opts: Partial<Omit<EngineClip, 'channels' | 'startPpq'>> = {},
661
+ opts: Partial<Omit<EngineClip, 'channels' | 'pageProvider' | 'startPpq'>> = {},
524
662
  ): number {
525
663
  return clips.addClip(this.clipContext, trackId, buffer, startPpq, opts);
526
664
  }
@@ -550,7 +688,12 @@ export class SonareEngine {
550
688
 
551
689
  setSf2Instrument(
552
690
  trackId: string | number,
553
- config: { destinationId?: number; gain?: number; polyphony?: number } = {},
691
+ config: {
692
+ destinationId?: number;
693
+ gain?: number;
694
+ polyphony?: number;
695
+ preferModelForModeledFamilies?: boolean;
696
+ } = {},
554
697
  ): void {
555
698
  strips.setSf2Instrument(this.stripContext, trackId, config);
556
699
  }
@@ -623,10 +766,90 @@ export class SonareEngine {
623
766
  strips.pushMidiCc(this.stripContext, trackId, group, channel, controller, value, renderFrame);
624
767
  }
625
768
 
769
+ pushMidiUmp(trackId: string | number, word0: number, renderFrame = -1): void {
770
+ strips.pushMidiUmp(this.stripContext, trackId, word0, renderFrame);
771
+ }
772
+
626
773
  pushMidiSysex(trackId: string | number, data: Uint8Array, renderFrame = -1): void {
627
774
  strips.pushMidiSysex(this.stripContext, trackId, data, renderFrame);
628
775
  }
629
776
 
777
+ bindMidiCc(
778
+ channel: number,
779
+ controller: number,
780
+ paramId: number,
781
+ options: MidiCcBindOptions = {},
782
+ ): void {
783
+ const minValue = options.minValue ?? 0;
784
+ const maxValue = options.maxValue ?? 1;
785
+ this.offlineEngine.bindMidiCc(channel, controller, paramId, { minValue, maxValue });
786
+ this.postSync({ type: 'syncMidiCcBinding', channel, controller, paramId, minValue, maxValue });
787
+ }
788
+
789
+ setMidiInputSource(destinationId = 0): void {
790
+ this.offlineEngine.setMidiInputSource(destinationId);
791
+ this.postSync({ type: 'syncMidiInputSource', destinationId });
792
+ }
793
+
794
+ clearMidiInputSource(): void {
795
+ this.offlineEngine.clearMidiInputSource();
796
+ this.postSync({ type: 'syncClearMidiInputSource' });
797
+ }
798
+
799
+ pushMidiInputNoteOn(
800
+ group: number,
801
+ channel: number,
802
+ note: number,
803
+ velocity: number,
804
+ portTimeSamples = 0,
805
+ ): void {
806
+ this.offlineEngine.pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples);
807
+ this.postSync({
808
+ type: 'syncMidiInputNoteOn',
809
+ group,
810
+ channel,
811
+ data0: note,
812
+ data1: velocity,
813
+ portTimeSamples,
814
+ });
815
+ }
816
+
817
+ pushMidiInputNoteOff(
818
+ group: number,
819
+ channel: number,
820
+ note: number,
821
+ velocity = 0,
822
+ portTimeSamples = 0,
823
+ ): void {
824
+ this.offlineEngine.pushMidiInputNoteOff(group, channel, note, velocity, portTimeSamples);
825
+ this.postSync({
826
+ type: 'syncMidiInputNoteOff',
827
+ group,
828
+ channel,
829
+ data0: note,
830
+ data1: velocity,
831
+ portTimeSamples,
832
+ });
833
+ }
834
+
835
+ pushMidiInputCc(
836
+ group: number,
837
+ channel: number,
838
+ controller: number,
839
+ value: number,
840
+ portTimeSamples = 0,
841
+ ): void {
842
+ this.offlineEngine.pushMidiInputCc(group, channel, controller, value, portTimeSamples);
843
+ this.postSync({
844
+ type: 'syncMidiInputCc',
845
+ group,
846
+ channel,
847
+ data0: controller,
848
+ data1: value,
849
+ portTimeSamples,
850
+ });
851
+ }
852
+
630
853
  pushMidiPanic(renderFrame = -1): void {
631
854
  this.offlineEngine.pushMidiPanic(renderFrame);
632
855
  this.postSync({ type: 'syncMidiPanic', renderFrame });
@@ -636,6 +859,10 @@ export class SonareEngine {
636
859
  capture.configureCapture(this.captureContext, options);
637
860
  }
638
861
 
862
+ /**
863
+ * Arms the engine-global capture path. `trackId` is retained for source
864
+ * compatibility and must be `0`; per-track capture is not implemented.
865
+ */
639
866
  armRecord(trackId: string | number, enabled: boolean): boolean {
640
867
  return capture.armRecord(this.captureContext, trackId, enabled);
641
868
  }
@@ -662,7 +889,7 @@ export class SonareEngine {
662
889
  // the fixed-size SAB command record, so it is delivered out-of-band; the
663
890
  // SetMetronome command then toggles enabled state on the audio thread.
664
891
  this.postSync({ type: 'syncMetronome', config: opts });
665
- this.realtimeNode.sendCommand({
892
+ this.sendMirroredCommand({
666
893
  type: SonareEngineCommandType.SetMetronome,
667
894
  sampleTime: -1,
668
895
  argInt: opts.enabled ? 1 : 0,
@@ -748,6 +975,15 @@ export class SonareEngine {
748
975
  if (this.destroyed) {
749
976
  return;
750
977
  }
978
+ this.unsubscribeWorkletClipRequests?.();
979
+ this.unsubscribeWorkletClipRequests = undefined;
980
+ this.workletClipStreamer?.close();
981
+ this.workletClipStreamer = undefined;
982
+ if (this.workletClipPagePollTimer !== undefined) {
983
+ clearInterval(this.workletClipPagePollTimer);
984
+ this.workletClipPagePollTimer = undefined;
985
+ }
986
+ this.workletClipPageRequests.clear();
751
987
  this.destroyed = true;
752
988
  this.transport.stop();
753
989
  this.realtimeNode.pollTelemetry();
@@ -755,6 +991,107 @@ export class SonareEngine {
755
991
  this.offlineEngine.destroy();
756
992
  }
757
993
 
994
+ private ensureWorkletClipStreamer(): ClipPageStreamer {
995
+ if (!this.workletClipStreamer) {
996
+ this.workletClipStreamer = new ClipPageStreamer({
997
+ popClipPageRequest: () => this.popWorkletClipPageRequest(),
998
+ });
999
+ }
1000
+ return this.workletClipStreamer;
1001
+ }
1002
+
1003
+ /**
1004
+ * SAB requests have no postMessage wake-up by design. Polling on the main
1005
+ * thread is therefore intentionally outside the audio callback; 8 ms keeps
1006
+ * the bounded OPFS prefetch frontier responsive without adding worklet GC.
1007
+ */
1008
+ private startWorkletClipPagePolling(): void {
1009
+ if (this.workletClipPagePollTimer !== undefined) {
1010
+ return;
1011
+ }
1012
+ const poll = () => {
1013
+ if (!this.destroyed) {
1014
+ this.realtimeNode.pollClipPageRequests();
1015
+ }
1016
+ };
1017
+ poll();
1018
+ this.workletClipPagePollTimer = setInterval(poll, 8);
1019
+ }
1020
+
1021
+ private pumpWorkletClipPages(): void {
1022
+ const streamer = this.workletClipStreamer;
1023
+ if (!streamer || this.workletClipPump) {
1024
+ return;
1025
+ }
1026
+ this.workletClipPump = streamer
1027
+ .pump()
1028
+ .catch((error: unknown) => {
1029
+ // The worklet keeps rendering silence for an unavailable page. Surface
1030
+ // I/O failures without converting them into an AudioWorklet exception.
1031
+ // biome-ignore lint/suspicious/noConsole: asynchronous OPFS diagnostic.
1032
+ console.error('Sonare OPFS clip-page supply failed:', error);
1033
+ })
1034
+ .finally(() => {
1035
+ this.workletClipPump = undefined;
1036
+ if (this.workletClipPageRequests.size > 0) {
1037
+ this.pumpWorkletClipPages();
1038
+ }
1039
+ });
1040
+ }
1041
+
1042
+ private enqueueWorkletClipPageRequest(request: ClipPageStreamerRequest): void {
1043
+ if (
1044
+ !Number.isInteger(request.clipId) ||
1045
+ request.clipId < 0 ||
1046
+ !Number.isInteger(request.pageIndex) ||
1047
+ (request.pageIndex ?? -1) < 0
1048
+ ) {
1049
+ return;
1050
+ }
1051
+ // Reinsert an existing id so iteration order represents newest frontiers.
1052
+ this.workletClipPageRequests.delete(request.clipId);
1053
+ if (this.workletClipPageRequests.size >= MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS) {
1054
+ const oldest = this.workletClipPageRequests.keys().next().value;
1055
+ if (oldest !== undefined) {
1056
+ this.workletClipPageRequests.delete(oldest);
1057
+ }
1058
+ }
1059
+ this.workletClipPageRequests.set(request.clipId, request);
1060
+ }
1061
+
1062
+ private popWorkletClipPageRequest(): ClipPageStreamerRequest | null {
1063
+ const entry = this.workletClipPageRequests.entries().next().value;
1064
+ if (!entry) {
1065
+ return null;
1066
+ }
1067
+ const [clipId, request] = entry;
1068
+ this.workletClipPageRequests.delete(clipId);
1069
+ return request;
1070
+ }
1071
+
1072
+ private commitWorkletClipPageProvider(clip: EngineClip): boolean {
1073
+ const providerId =
1074
+ typeof clip.pageProvider === 'object' && clip.pageProvider !== null
1075
+ ? clip.pageProvider.id
1076
+ : clip.pageProvider;
1077
+ if (providerId === undefined) {
1078
+ return false;
1079
+ }
1080
+ const clipId = this.workletPageProviderClipIds.get(providerId);
1081
+ if (clipId === undefined) {
1082
+ return false;
1083
+ }
1084
+ if (clip.id !== clipId) {
1085
+ throw new Error(`OPFS stream clipId ${clipId} must match addClip(..., { id: ${clipId} }).`);
1086
+ }
1087
+ this.postSync({
1088
+ type: 'syncClipPageCommit',
1089
+ clipId,
1090
+ clip: { ...clip, channels: undefined, pageProvider: undefined },
1091
+ });
1092
+ return true;
1093
+ }
1094
+
758
1095
  private mixerLanes(): EngineTrackLane[] {
759
1096
  return mixer.mixerLanes(this.mixerContext);
760
1097
  }
@@ -791,13 +1128,32 @@ export class SonareEngine {
791
1128
  if (this.destroyed) {
792
1129
  return;
793
1130
  }
794
- if (transfer && transfer.length > 0) {
795
- this.realtimeNode.node.port.postMessage(message, transfer);
796
- } else {
797
- this.realtimeNode.node.port.postMessage(message);
1131
+ try {
1132
+ if (transfer && transfer.length > 0) {
1133
+ this.realtimeNode.node.port.postMessage(message, transfer);
1134
+ } else {
1135
+ this.realtimeNode.node.port.postMessage(message);
1136
+ }
1137
+ } finally {
1138
+ this.flushOfflineMirror();
798
1139
  }
799
1140
  }
800
1141
 
1142
+ // The offline engine is a control-thread mirror. It has no render loop to
1143
+ // drain its command ring, so drain it after each control operation. Never
1144
+ // call this from the worklet/audio path: it is intentionally control-only.
1145
+ private flushOfflineMirror(): void {
1146
+ this.offlineEngine.flushControlCommands();
1147
+ }
1148
+
1149
+ private sendMirroredCommand(
1150
+ command: Parameters<SonareRealtimeEngineNode['sendCommand']>[0],
1151
+ ): boolean {
1152
+ const accepted = this.realtimeNode.sendCommand(command);
1153
+ this.flushOfflineMirror();
1154
+ return accepted;
1155
+ }
1156
+
801
1157
  // Collaborator surface handed to the mixer/routing free functions so they can
802
1158
  // mutate the routing stores (held by reference), mirror into the offline
803
1159
  // engine, post mixer-sync messages, and declare lanes/buses without a
@@ -855,13 +1211,13 @@ export class SonareEngine {
855
1211
  return {
856
1212
  offlineEngine: this.offlineEngine,
857
1213
  realtimeNode: this.realtimeNode,
1214
+ sendCommand: (command) => this.sendMirroredCommand(command),
858
1215
  offlineChannelCount: this.offlineChannelCount,
859
1216
  postSync: (message) => this.postSync(message),
860
1217
  getCaptureConfig: () => this.captureConfig,
861
1218
  setCaptureConfig: (config) => {
862
1219
  this.captureConfig = config;
863
1220
  },
864
- resolveTargetId: (target) => this.resolveTargetId(target),
865
1221
  };
866
1222
  }
867
1223
 
@@ -872,7 +1228,9 @@ export class SonareEngine {
872
1228
  private get parameterContext(): EngineParameterContext {
873
1229
  return {
874
1230
  offlineEngine: this.offlineEngine,
875
- realtimeNode: this.realtimeNode,
1231
+ sendCommand: (command) => this.sendMirroredCommand(command),
1232
+ postSync: (message) => this.postSync(message),
1233
+ automationLanes: this.automationLanes,
876
1234
  trackLaneIds: this.trackLaneIds,
877
1235
  resolveParamId: (nodeId, param) => this.resolveParamId(nodeId, param),
878
1236
  ensureTrackLane: (target) => this.ensureTrackLane(target),
@@ -887,6 +1245,7 @@ export class SonareEngine {
887
1245
  return {
888
1246
  offlineEngine: this.offlineEngine,
889
1247
  realtimeNode: this.realtimeNode,
1248
+ sendCommand: (command) => this.sendMirroredCommand(command),
890
1249
  postSync: (message) => this.postSync(message),
891
1250
  getTempoBpm: () => this.tempoBpm,
892
1251
  setTempoBpm: (bpm) => {
@@ -923,6 +1282,7 @@ export class SonareEngine {
923
1282
  postSync: (message, transfer) => this.postSync(message, transfer),
924
1283
  ensureTrackLane: (target) => this.ensureTrackLane(target),
925
1284
  resolveTargetId: (target) => this.resolveTargetId(target),
1285
+ commitWorkletClipPageProvider: (clip) => this.commitWorkletClipPageProvider(clip),
926
1286
  };
927
1287
  }
928
1288
 
@@ -938,7 +1298,7 @@ export class SonareEngine {
938
1298
  this.nextMarkerId = value;
939
1299
  },
940
1300
  postSync: (message) => this.postSync(message),
941
- sendCommand: (command) => this.realtimeNode.sendCommand(command),
1301
+ sendCommand: (command) => this.sendMirroredCommand(command),
942
1302
  setLoop: (startPpq, endPpq, enabled) => this.setLoop(startPpq, endPpq, enabled),
943
1303
  };
944
1304
  }
@@ -956,7 +1316,7 @@ export class SonareEngine {
956
1316
  // sample-accurate smoothed-param command to the realtime runtime.
957
1317
  private sendSmoothedParam(paramId: number, value: number): boolean {
958
1318
  this.offlineEngine.setParameter(paramId, value);
959
- return this.realtimeNode.sendCommand({
1319
+ return this.sendMirroredCommand({
960
1320
  type: SonareEngineCommandType.SetParamSmoothed,
961
1321
  targetId: paramId,
962
1322
  sampleTime: -1,
@@ -968,6 +1328,13 @@ export class SonareEngine {
968
1328
  return resolveParamId(this.listParameters(), nodeId, param);
969
1329
  }
970
1330
 
1331
+ private syncParameters(): void {
1332
+ const parameters = this.listParameters();
1333
+ if (parameters.length > 0) {
1334
+ this.postSync({ type: 'syncParameters', parameters });
1335
+ }
1336
+ }
1337
+
971
1338
  private resolveTargetId(target: string | number): number {
972
1339
  return resolveTargetId(target);
973
1340
  }
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  SonareEngineCaptureRequestMessage,
3
3
  SonareEngineCaptureResponseMessage,
4
+ SonareEngineClipPageRequestMessage,
4
5
  SonareEngineSyncMessage,
5
6
  SonareEngineTransportRequestMessage,
6
7
  SonareEngineTransportResponseMessage,
@@ -39,7 +40,9 @@ export function isEngineSyncMessage(value: unknown): value is SonareEngineSyncMe
39
40
  value.type === 'syncClipsDelta' ||
40
41
  value.type === 'syncClipPageProvider' ||
41
42
  value.type === 'syncClipPage' ||
43
+ value.type === 'syncClipPageClear' ||
42
44
  value.type === 'syncClipPageCommit' ||
45
+ value.type === 'syncClipPageDestroy' ||
43
46
  value.type === 'syncMidiClips' ||
44
47
  value.type === 'syncMarkers' ||
45
48
  value.type === 'syncMetronome' ||
@@ -68,10 +71,12 @@ export function isEngineSyncMessage(value: unknown): value is SonareEngineSyncMe
68
71
  value.type === 'syncMidiNoteOn' ||
69
72
  value.type === 'syncMidiNoteOff' ||
70
73
  value.type === 'syncMidiCc' ||
74
+ value.type === 'syncMidiUmp' ||
71
75
  value.type === 'syncMidiSysex' ||
72
76
  value.type === 'syncMidiPanic' ||
73
77
  value.type === 'syncMidiDestinationExternal' ||
74
- value.type === 'syncExternalMidiClock'
78
+ value.type === 'syncExternalMidiClock' ||
79
+ value.type === 'destroy'
75
80
  );
76
81
  }
77
82
 
@@ -147,6 +152,22 @@ export function isExternalMidiBatchMessage(
147
152
  return isRecord(value) && value.type === 'externalMidi' && Array.isArray(value.events);
148
153
  }
149
154
 
155
+ export function isClipPageRequestMessage(
156
+ value: unknown,
157
+ ): value is SonareEngineClipPageRequestMessage {
158
+ return (
159
+ isRecord(value) &&
160
+ value.type === 'clipPageRequest' &&
161
+ Array.isArray(value.requests) &&
162
+ value.requests.every(
163
+ (request) =>
164
+ isRecord(request) &&
165
+ typeof request.clipId === 'number' &&
166
+ typeof request.pageIndex === 'number',
167
+ )
168
+ );
169
+ }
170
+
150
171
  export function isMeterSnapshot(value: unknown): value is SonareWorkletMeterSnapshot {
151
172
  return (
152
173
  isRecord(value) &&