@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
@@ -1,4 +1,4 @@
1
- import type { EngineClip, EngineScopeTelemetry } from '../index';
1
+ import type { EngineClip } from '../index';
2
2
  import { RealtimeEngine } from '../index';
3
3
  import type { WorkletInput, WorkletOutput } from './audio_types';
4
4
  import {
@@ -14,12 +14,18 @@ import {
14
14
  type WorkletTransport,
15
15
  } from './messages';
16
16
  import {
17
+ clipPageRequestRingFromSharedBuffer,
17
18
  encodeFrameHi,
18
19
  encodeFrameLo,
19
20
  engineRingFromSharedBuffer,
21
+ externalMidiRingFromSharedBuffer,
20
22
  meterFromEngine,
21
23
  meterRingFromSharedBuffer,
22
24
  popSonareEngineCommandRingBuffer,
25
+ pushSonareClipPageRequestRingBuffer,
26
+ pushSonareExternalMidiRingBuffer,
27
+ type SharedClipPageRequestRingWriter,
28
+ type SharedExternalMidiRingWriter,
23
29
  type SharedMeterRingWriter,
24
30
  type SharedScopeRingWriter,
25
31
  SONARE_ENGINE_COMMAND_RECORD_BYTES,
@@ -36,6 +42,7 @@ import {
36
42
  type SonareWorkletMeterSnapshot,
37
43
  scopeRingFromSharedBuffer,
38
44
  telemetryFromEngine,
45
+ writeInt64Words,
39
46
  writeSonareEngineTelemetryRingBuffer,
40
47
  } from './protocol';
41
48
 
@@ -55,6 +62,8 @@ export class SonareRealtimeEngineWorkletProcessor {
55
62
  private telemetryRing?: SonareEngineTelemetryRingBuffer;
56
63
  private meterRing?: SharedMeterRingWriter;
57
64
  private scopeRing?: SharedScopeRingWriter;
65
+ private clipPageRequestRing?: SharedClipPageRequestRingWriter;
66
+ private externalMidiRing?: SharedExternalMidiRingWriter;
58
67
  private transport?: WorkletTransport;
59
68
  private meterIntervalFrames: number;
60
69
  private lastMeterFrame = Number.NEGATIVE_INFINITY;
@@ -71,7 +80,15 @@ export class SonareRealtimeEngineWorkletProcessor {
71
80
  private channelBuffers: Float32Array[];
72
81
  private readonly liveClips = new Map<number, EngineClip>();
73
82
  private readonly pagedClipProviders = new Map<number, number>();
83
+ private readonly pagedClipPageFrames = new Map<number, number>();
74
84
  private readonly pendingPagedClips = new Map<number, EngineClip>();
85
+ // The worklet drains at most this many distinct page misses per render
86
+ // quantum. The fixed scalar buffers avoid retaining an unbounded queue on
87
+ // the audio thread; any remaining native requests stay queued for the next
88
+ // quantum and render silence meanwhile.
89
+ private readonly clipPageRequestClipIds = new Float64Array(64);
90
+ private readonly clipPageRequestPageIndices = new Float64Array(64);
91
+ private clipPageRequestOverflowReported = 0;
75
92
 
76
93
  constructor(
77
94
  options: SonareRealtimeEngineWorkletProcessorOptions = {},
@@ -101,7 +118,25 @@ export class SonareRealtimeEngineWorkletProcessor {
101
118
  options.scopeBands,
102
119
  )
103
120
  : undefined;
104
- this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
121
+ this.clipPageRequestRing = options.clipPageRequestSharedBuffer
122
+ ? clipPageRequestRingFromSharedBuffer(
123
+ options.clipPageRequestSharedBuffer,
124
+ options.clipPageRequestRingCapacity,
125
+ )
126
+ : undefined;
127
+ this.externalMidiRing = options.externalMidiSharedBuffer
128
+ ? externalMidiRingFromSharedBuffer(
129
+ options.externalMidiSharedBuffer,
130
+ options.externalMidiRingCapacity,
131
+ )
132
+ : undefined;
133
+ this.engine = new RealtimeEngine(
134
+ this.sampleRate,
135
+ this.blockSize,
136
+ 1024,
137
+ 1024,
138
+ this.channelCount,
139
+ );
105
140
  // Allocate persistent WASM-heap scratch (worst case: channelCount channels x
106
141
  // blockSize frames) and acquire the per-channel heap views once.
107
142
  this.engine.prepareChannels(this.channelCount, this.blockSize);
@@ -191,6 +226,7 @@ export class SonareRealtimeEngineWorkletProcessor {
191
226
  target.fill(0);
192
227
  }
193
228
  }
229
+ this.publishClipPageRequests();
194
230
  this.publishTelemetry();
195
231
  this.publishMeters();
196
232
  this.publishScope();
@@ -206,7 +242,7 @@ export class SonareRealtimeEngineWorkletProcessor {
206
242
 
207
243
  receiveCommand(command: SonareEngineCommandRecord): void {
208
244
  if (!this.closed) {
209
- this.applyCommand(command);
245
+ this.safeApplyCommand(command);
210
246
  }
211
247
  }
212
248
 
@@ -218,35 +254,68 @@ export class SonareRealtimeEngineWorkletProcessor {
218
254
  if (this.closed) {
219
255
  return;
220
256
  }
257
+ try {
258
+ this.applySync(message);
259
+ } catch (error) {
260
+ this.transport?.postMessage?.({
261
+ type: 'syncError',
262
+ syncType: message.type,
263
+ message: error instanceof Error ? error.message : String(error),
264
+ });
265
+ }
266
+ }
267
+
268
+ private applySync(message: SonareEngineSyncMessage): void {
221
269
  switch (message.type) {
222
- case 'syncClips':
270
+ case 'destroy':
271
+ this.destroy();
272
+ return;
273
+ case 'syncClips': {
274
+ // Commit native state before touching the worklet-side mirrors. A
275
+ // rejected embind call must leave a later delta based on the previous
276
+ // clip set, not on a half-applied replacement.
277
+ this.engine.setClips(message.clips);
278
+ this.clearPagedClipProviders();
223
279
  this.liveClips.clear();
224
280
  for (const clip of message.clips) {
225
281
  if (clip.id !== undefined) {
226
282
  this.liveClips.set(clip.id, clip);
227
283
  }
228
284
  }
229
- this.engine.setClips(message.clips);
230
285
  break;
231
- case 'syncClipsDelta':
286
+ }
287
+ case 'syncClipsDelta': {
288
+ const nextClips = new Map(this.liveClips);
232
289
  for (const clipId of message.removeIds) {
233
- this.liveClips.delete(clipId);
290
+ nextClips.delete(clipId);
234
291
  }
235
292
  for (const clip of message.upserts) {
236
293
  if (clip.id !== undefined) {
237
- this.liveClips.set(clip.id, clip);
294
+ nextClips.set(clip.id, clip);
238
295
  }
239
296
  }
240
- this.engine.setClips(Array.from(this.liveClips.values()));
297
+ this.engine.setClips(Array.from(nextClips.values()));
298
+ for (const clipId of message.removeIds) {
299
+ this.removePagedClipProvider(clipId);
300
+ }
301
+ this.liveClips.clear();
302
+ for (const [clipId, clip] of nextClips) {
303
+ this.liveClips.set(clipId, clip);
304
+ }
241
305
  break;
306
+ }
242
307
  case 'syncClipPageProvider': {
243
308
  const provider = this.engine.createClipPageProvider(
244
309
  message.numChannels,
245
310
  message.numSamples,
246
311
  message.pageFrames,
247
312
  );
313
+ this.removePagedClipProvider(message.clipId);
248
314
  this.pagedClipProviders.set(message.clipId, provider.id);
249
- this.pendingPagedClips.set(message.clipId, message.clip);
315
+ this.pagedClipPageFrames.set(message.clipId, message.pageFrames);
316
+ if (message.clip) {
317
+ this.pendingPagedClips.set(message.clipId, message.clip);
318
+ }
250
319
  break;
251
320
  }
252
321
  case 'syncClipPage': {
@@ -256,16 +325,34 @@ export class SonareRealtimeEngineWorkletProcessor {
256
325
  }
257
326
  break;
258
327
  }
328
+ case 'syncClipPageClear': {
329
+ const providerId = this.pagedClipProviders.get(message.clipId);
330
+ if (providerId !== undefined) {
331
+ this.engine.clearClipPage(providerId, message.pageIndex);
332
+ }
333
+ break;
334
+ }
259
335
  case 'syncClipPageCommit': {
260
336
  const providerId = this.pagedClipProviders.get(message.clipId);
261
- const clip = this.pendingPagedClips.get(message.clipId);
337
+ const clip = message.clip ?? this.pendingPagedClips.get(message.clipId);
262
338
  if (providerId !== undefined && clip) {
263
- this.liveClips.set(message.clipId, { ...clip, pageProvider: providerId });
339
+ const nextClip = { ...clip, pageProvider: providerId };
340
+ const nextClips = new Map(this.liveClips);
341
+ nextClips.set(message.clipId, nextClip);
342
+ this.engine.setClips(Array.from(nextClips.values()));
343
+ this.liveClips.set(message.clipId, nextClip);
264
344
  this.pendingPagedClips.delete(message.clipId);
265
- this.engine.setClips(Array.from(this.liveClips.values()));
266
345
  }
267
346
  break;
268
347
  }
348
+ case 'syncClipPageDestroy': {
349
+ const nextClips = new Map(this.liveClips);
350
+ nextClips.delete(message.clipId);
351
+ this.engine.setClips(Array.from(nextClips.values()));
352
+ this.liveClips.delete(message.clipId);
353
+ this.removePagedClipProvider(message.clipId);
354
+ break;
355
+ }
269
356
  case 'syncMidiClips':
270
357
  this.engine.setMidiClips(message.clips);
271
358
  break;
@@ -273,12 +360,23 @@ export class SonareRealtimeEngineWorkletProcessor {
273
360
  this.engine.setMarkers(message.markers);
274
361
  break;
275
362
  case 'syncMetronome':
276
- this.metronomeConfig = resolveMetronomeConfig(message.config);
277
- this.engine.setMetronome(message.config);
363
+ // Do not publish the cached config until the native engine accepted it.
364
+ // Otherwise a rejected sync would corrupt the command-side fallback.
365
+ {
366
+ const config = resolveMetronomeConfig(message.config);
367
+ this.engine.setMetronome(message.config);
368
+ this.metronomeConfig = config;
369
+ }
278
370
  break;
279
371
  case 'syncAutomation':
280
372
  this.engine.setAutomationLane(message.paramId, message.points);
281
373
  break;
374
+ case 'syncParameters':
375
+ this.engine.clearParameters();
376
+ for (const info of message.parameters) {
377
+ this.engine.addParameter(info);
378
+ }
379
+ break;
282
380
  case 'syncTempo':
283
381
  if (message.tempoSegments) {
284
382
  this.engine.setTempoSegments(message.tempoSegments);
@@ -425,6 +523,9 @@ export class SonareRealtimeEngineWorkletProcessor {
425
523
  message.renderFrame,
426
524
  );
427
525
  break;
526
+ case 'syncMidiUmp':
527
+ this.engine.pushMidiUmp(message.destinationId, message.word0, message.renderFrame);
528
+ break;
428
529
  case 'syncMidiSysex':
429
530
  this.engine.pushMidiSysex(message.destinationId, message.data, message.renderFrame);
430
531
  break;
@@ -437,6 +538,45 @@ export class SonareRealtimeEngineWorkletProcessor {
437
538
  case 'syncExternalMidiClock':
438
539
  this.engine.setExternalMidiClockEnabled(message.enabled);
439
540
  break;
541
+ case 'syncMidiInputSource':
542
+ this.engine.setMidiInputSource(message.destinationId ?? 0);
543
+ break;
544
+ case 'syncClearMidiInputSource':
545
+ this.engine.clearMidiInputSource();
546
+ break;
547
+ case 'syncMidiCcBinding':
548
+ this.engine.bindMidiCc(message.channel, message.controller, message.paramId, {
549
+ minValue: message.minValue,
550
+ maxValue: message.maxValue,
551
+ });
552
+ break;
553
+ case 'syncMidiInputNoteOn':
554
+ this.engine.pushMidiInputNoteOn(
555
+ message.group,
556
+ message.channel,
557
+ message.data0,
558
+ message.data1,
559
+ message.portTimeSamples,
560
+ );
561
+ break;
562
+ case 'syncMidiInputNoteOff':
563
+ this.engine.pushMidiInputNoteOff(
564
+ message.group,
565
+ message.channel,
566
+ message.data0,
567
+ message.data1,
568
+ message.portTimeSamples,
569
+ );
570
+ break;
571
+ case 'syncMidiInputCc':
572
+ this.engine.pushMidiInputCc(
573
+ message.group,
574
+ message.channel,
575
+ message.data0,
576
+ message.data1,
577
+ message.portTimeSamples,
578
+ );
579
+ break;
440
580
  }
441
581
  }
442
582
 
@@ -520,6 +660,7 @@ export class SonareRealtimeEngineWorkletProcessor {
520
660
 
521
661
  destroy(): void {
522
662
  if (!this.closed) {
663
+ this.clearPagedClipProviders();
523
664
  this.engine.destroy();
524
665
  this.closed = true;
525
666
  }
@@ -534,7 +675,25 @@ export class SonareRealtimeEngineWorkletProcessor {
534
675
  if (!command) {
535
676
  return;
536
677
  }
678
+ this.safeApplyCommand(command);
679
+ }
680
+ }
681
+
682
+ private safeApplyCommand(command: SonareEngineCommandRecord): void {
683
+ try {
537
684
  this.applyCommand(command);
685
+ } catch {
686
+ // A malformed control command must never escape AudioWorklet.process():
687
+ // Web Audio permanently stops invoking a processor that throws there.
688
+ this.publishTelemetryRecord({
689
+ type: SonareEngineTelemetryType.Error,
690
+ error: SonareEngineTelemetryError.InvalidCommand,
691
+ renderFrame: 0,
692
+ timelineSample: 0,
693
+ audibleTimelineSample: 0,
694
+ graphLatencySamplesQ8: 0,
695
+ value: Number(command.type),
696
+ });
538
697
  }
539
698
  }
540
699
 
@@ -569,9 +728,6 @@ export class SonareRealtimeEngineWorkletProcessor {
569
728
  case SonareEngineCommandType.TransportSeekPpq:
570
729
  this.engine.seekPpq(Number(command.argFloat ?? 0), sampleTime);
571
730
  break;
572
- case SonareEngineCommandType.SetTempoMap:
573
- this.engine.setTempo(Number(command.argFloat ?? 120));
574
- break;
575
731
  case SonareEngineCommandType.SetLoop:
576
732
  this.engine.setLoop(
577
733
  Number(command.argFloat ?? 0),
@@ -601,6 +757,7 @@ export class SonareRealtimeEngineWorkletProcessor {
601
757
  beatGain: this.metronomeConfig.beatGain,
602
758
  accentGain: this.metronomeConfig.accentGain,
603
759
  clickSamples: this.metronomeConfig.clickSamples,
760
+ clickSeconds: this.metronomeConfig.clickSeconds,
604
761
  });
605
762
  break;
606
763
  case SonareEngineCommandType.SeekMarker:
@@ -631,11 +788,36 @@ export class SonareRealtimeEngineWorkletProcessor {
631
788
  }
632
789
 
633
790
  private publishTelemetry(): void {
791
+ const ring = this.telemetryRing;
792
+ if (ring) {
793
+ for (let count = 0; count < 64 && this.engine.popTelemetryToScratch(); count++) {
794
+ this.writeTelemetryScratch(ring);
795
+ }
796
+ return;
797
+ }
634
798
  for (const item of this.engine.drainTelemetry(64)) {
635
799
  this.publishTelemetryRecord(telemetryFromEngine(item));
636
800
  }
637
801
  }
638
802
 
803
+ private writeTelemetryScratch(ring: SonareEngineTelemetryRingBuffer): void {
804
+ const writeIndex = Atomics.load(ring.header, 0);
805
+ const offset = (writeIndex % ring.capacity) * SONARE_ENGINE_TELEMETRY_RECORD_BYTES;
806
+ ring.view.setUint32(offset, this.engine.telemetryScratchType(), true);
807
+ ring.view.setUint32(offset + 4, this.engine.telemetryScratchError(), true);
808
+ writeInt64Words(ring.view, offset + 8, this.engine.telemetryScratchRenderFrame());
809
+ writeInt64Words(ring.view, offset + 16, this.engine.telemetryScratchTimelineSample());
810
+ writeInt64Words(ring.view, offset + 24, this.engine.telemetryScratchAudibleTimelineSample());
811
+ ring.view.setInt32(offset + 32, this.engine.telemetryScratchGraphLatencySamplesQ8(), true);
812
+ ring.view.setUint32(offset + 36, this.engine.telemetryScratchValue(), true);
813
+ ring.view.setUint32(offset + 40, 0, true);
814
+ ring.view.setUint32(offset + 44, 0, true);
815
+ Atomics.store(ring.header, 0, writeIndex + 1);
816
+ if (writeIndex + 1 > ring.capacity) {
817
+ Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
818
+ }
819
+ }
820
+
639
821
  private publishTelemetryRecord(record: SonareEngineTelemetryRecord): void {
640
822
  if (this.telemetryRing) {
641
823
  writeSonareEngineTelemetryRingBuffer(this.telemetryRing, record);
@@ -659,6 +841,22 @@ export class SonareRealtimeEngineWorkletProcessor {
659
841
  if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
660
842
  return;
661
843
  }
844
+ if (this.meterRing) {
845
+ for (let count = 0; count < 64 && this.engine.popMeterTelemetryToScratch(); count++) {
846
+ const frame = this.engine.meterScratchRenderFrame();
847
+ if (
848
+ frame !== this.lastMeterFrame &&
849
+ frame - this.lastMeterFrame < this.meterIntervalFrames
850
+ ) {
851
+ continue;
852
+ }
853
+ if (frame !== this.lastMeterFrame) {
854
+ this.lastMeterFrame = frame;
855
+ }
856
+ this.writeMeterScratch(this.meterRing);
857
+ }
858
+ return;
859
+ }
662
860
  for (const item of this.engine.drainMeterTelemetry(64)) {
663
861
  const meter = meterFromEngine(item);
664
862
  if (
@@ -683,6 +881,19 @@ export class SonareRealtimeEngineWorkletProcessor {
683
881
  }
684
882
  }
685
883
 
884
+ private writeMeterScratch(ring: SharedMeterRingWriter): void {
885
+ const writeIndex = Atomics.load(ring.header, 0);
886
+ const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
887
+ const frame = Number(this.engine.meterScratchRenderFrame());
888
+ ring.records[offset] = encodeFrameLo(frame);
889
+ ring.records[offset + 1] = encodeFrameHi(frame);
890
+ ring.records[offset + 2] = this.engine.meterScratchTargetId();
891
+ for (let field = 0; field < 11; field++) {
892
+ ring.records[offset + 3 + field] = this.engine.meterScratchValue(field);
893
+ }
894
+ Atomics.store(ring.header, 0, writeIndex + 1);
895
+ }
896
+
686
897
  private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
687
898
  const ring = this.meterRing;
688
899
  if (!ring) {
@@ -720,9 +931,32 @@ export class SonareRealtimeEngineWorkletProcessor {
720
931
  if (!ring) {
721
932
  return;
722
933
  }
723
- for (const item of this.engine.drainScopeTelemetry(64)) {
724
- this.writeScopeRing(ring, item);
934
+ for (let count = 0; count < 64 && this.engine.popScopeTelemetryToScratch(); count++) {
935
+ this.writeScopeScratch(ring);
936
+ }
937
+ }
938
+
939
+ private writeScopeScratch(ring: SharedScopeRingWriter): void {
940
+ const writeIndex = Atomics.load(ring.header, 0);
941
+ const base = (writeIndex % ring.capacity) * ring.recordFloats;
942
+ const frame = Number(this.engine.scopeScratchRenderFrame());
943
+ ring.records[base] = encodeFrameLo(frame);
944
+ ring.records[base + 1] = encodeFrameHi(frame);
945
+ ring.records[base + 2] = this.engine.scopeScratchTargetId();
946
+ const bandCount = Math.min(ring.bands, this.engine.scopeScratchBandCount());
947
+ ring.records[base + 3] = bandCount;
948
+ const pointCount = Math.min(ring.maxPoints, this.engine.scopeScratchPointCount());
949
+ ring.records[base + 4] = pointCount;
950
+ const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
951
+ for (let i = 0; i < bandCount; i++) {
952
+ ring.records[bandsBase + i] = this.engine.scopeScratchBand(i);
953
+ }
954
+ const pointsBase = bandsBase + ring.bands;
955
+ for (let i = 0; i < pointCount; i++) {
956
+ ring.records[pointsBase + 2 * i] = this.engine.scopeScratchPointLeft(i);
957
+ ring.records[pointsBase + 2 * i + 1] = this.engine.scopeScratchPointRight(i);
725
958
  }
959
+ Atomics.store(ring.header, 0, writeIndex + 1);
726
960
  }
727
961
 
728
962
  // Drains queued external-MIDI events (already lowered to MIDI 1.0 bytes) and
@@ -730,9 +964,26 @@ export class SonareRealtimeEngineWorkletProcessor {
730
964
  // One batch per render block; skipped entirely when nothing is queued, so an
731
965
  // all-internal project never allocates or posts here.
732
966
  private publishExternalMidi(): void {
967
+ const ring = this.externalMidiRing;
968
+ if (ring) {
969
+ for (let count = 0; count < 256 && this.engine.popExternalMidiToScratch(); count++) {
970
+ pushSonareExternalMidiRingBuffer(
971
+ ring,
972
+ this.engine.externalMidiScratchDestinationId(),
973
+ this.engine.externalMidiScratchRenderFrame(),
974
+ this.engine.externalMidiScratchByteWord(),
975
+ this.engine.externalMidiScratchByteCount(),
976
+ );
977
+ this.engine.consumeExternalMidiScratch();
978
+ }
979
+ return;
980
+ }
733
981
  if (!this.transport?.postMessage) {
734
982
  return;
735
983
  }
984
+ if (this.engine.externalMidiPendingCount() === 0) {
985
+ return;
986
+ }
736
987
  const events = this.engine.drainExternalMidi(256);
737
988
  if (events.length === 0) {
738
989
  return;
@@ -740,30 +991,120 @@ export class SonareRealtimeEngineWorkletProcessor {
740
991
  this.transport.postMessage({ type: 'externalMidi', events });
741
992
  }
742
993
 
743
- private writeScopeRing(ring: SharedScopeRingWriter, record: EngineScopeTelemetry): void {
744
- const writeIndex = Atomics.load(ring.header, 0);
745
- const base = (writeIndex % ring.capacity) * ring.recordFloats;
746
- ring.records[base] = encodeFrameLo(record.renderFrame);
747
- ring.records[base + 1] = encodeFrameHi(record.renderFrame);
748
- ring.records[base + 2] = record.targetId;
749
- const bandCount = Math.min(ring.bands, record.bands.length);
750
- ring.records[base + 3] = bandCount;
751
- const pointCount = Math.min(ring.maxPoints, record.points.length);
752
- ring.records[base + 4] = pointCount;
753
- const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
754
- for (let i = 0; i < bandCount; i++) {
755
- ring.records[bandsBase + i] = record.bands[i];
994
+ /**
995
+ * Forward a bounded batch of native page misses to the main thread. This
996
+ * deliberately runs after audio rendering: a cache miss is silence for this
997
+ * block, and OPFS I/O must never delay `process()`.
998
+ */
999
+ private publishClipPageRequests(): void {
1000
+ const ring = this.clipPageRequestRing;
1001
+ const transport = this.transport;
1002
+ if (!ring && !transport?.postMessage) {
1003
+ return;
756
1004
  }
757
- const pointsBase = bandsBase + ring.bands;
758
- for (let i = 0; i < pointCount; i++) {
759
- const point = record.points[i];
760
- ring.records[pointsBase + 2 * i] = point.left;
761
- ring.records[pointsBase + 2 * i + 1] = point.right;
1005
+ let count = 0;
1006
+ let drained = 0;
1007
+ while (drained < this.clipPageRequestClipIds.length) {
1008
+ drained += 1;
1009
+ let clipId: number;
1010
+ let sample: number;
1011
+ if (ring) {
1012
+ // This scalar scratch API is intentionally used only on the SAB path:
1013
+ // it avoids embind materialising one JS object for every page miss in
1014
+ // AudioWorklet process().
1015
+ if (!this.engine.popClipPageRequestToScratch()) {
1016
+ break;
1017
+ }
1018
+ clipId = this.engine.clipPageRequestScratchClipId();
1019
+ sample = this.engine.clipPageRequestScratchSample();
1020
+ } else {
1021
+ // postMessage fallback is explicitly non-RT-safe; retain the public
1022
+ // object-returning API only for that degraded control-plane path.
1023
+ const request = this.engine.popClipPageRequest();
1024
+ if (!request) {
1025
+ break;
1026
+ }
1027
+ clipId = request.clipId;
1028
+ sample = request.sample;
1029
+ }
1030
+ const pageFrames = this.pagedClipPageFrames.get(clipId);
1031
+ if (!pageFrames) {
1032
+ continue;
1033
+ }
1034
+ const pageIndex = Math.floor(sample / pageFrames);
1035
+ let duplicate = false;
1036
+ for (let i = 0; i < count; ++i) {
1037
+ if (
1038
+ this.clipPageRequestClipIds[i] === clipId &&
1039
+ this.clipPageRequestPageIndices[i] === pageIndex
1040
+ ) {
1041
+ duplicate = true;
1042
+ break;
1043
+ }
1044
+ }
1045
+ if (!duplicate) {
1046
+ this.clipPageRequestClipIds[count] = clipId;
1047
+ this.clipPageRequestPageIndices[count] = pageIndex;
1048
+ count += 1;
1049
+ }
1050
+ }
1051
+ const overflowCount = this.engine.clipPageRequestOverflowCount();
1052
+ // The native counter is uint32_t; preserve a correct delta across its
1053
+ // defined unsigned wraparound instead of emitting a negative count.
1054
+ const dropped = (overflowCount - this.clipPageRequestOverflowReported) >>> 0;
1055
+ this.clipPageRequestOverflowReported = overflowCount;
1056
+ if (ring) {
1057
+ // Preserve native-queue loss in the same cumulative counter used for a
1058
+ // full SAB request ring. No object, structured clone, or postMessage is
1059
+ // created on this path.
1060
+ if (dropped > 0) {
1061
+ Atomics.add(ring.header, 4, dropped);
1062
+ }
1063
+ for (let i = 0; i < count; ++i) {
1064
+ pushSonareClipPageRequestRingBuffer(
1065
+ ring,
1066
+ this.clipPageRequestClipIds[i],
1067
+ this.clipPageRequestPageIndices[i],
1068
+ );
1069
+ }
1070
+ return;
1071
+ }
1072
+ if (count === 0 && dropped === 0) {
1073
+ return;
1074
+ }
1075
+ if (!transport?.postMessage) {
1076
+ return;
1077
+ }
1078
+ // A message is necessarily allocated by postMessage; the hot path's
1079
+ // resident queue remains fixed-size and no message is built without a miss.
1080
+ const requests = new Array<{ clipId: number; pageIndex: number }>(count);
1081
+ for (let i = 0; i < count; ++i) {
1082
+ requests[i] = {
1083
+ clipId: this.clipPageRequestClipIds[i],
1084
+ pageIndex: this.clipPageRequestPageIndices[i],
1085
+ };
1086
+ }
1087
+ transport.postMessage({
1088
+ type: 'clipPageRequest',
1089
+ requests,
1090
+ ...(dropped > 0 ? { dropped } : {}),
1091
+ });
1092
+ }
1093
+
1094
+ private removePagedClipProvider(clipId: number): void {
1095
+ const providerId = this.pagedClipProviders.get(clipId);
1096
+ if (providerId !== undefined) {
1097
+ this.engine.destroyClipPageProvider(providerId);
1098
+ }
1099
+ this.pagedClipProviders.delete(clipId);
1100
+ this.pagedClipPageFrames.delete(clipId);
1101
+ this.pendingPagedClips.delete(clipId);
1102
+ }
1103
+
1104
+ private clearPagedClipProviders(): void {
1105
+ for (const clipId of this.pagedClipProviders.keys()) {
1106
+ this.removePagedClipProvider(clipId);
762
1107
  }
763
- Atomics.store(ring.header, 0, writeIndex + 1);
764
- // Like writeMeterRing, writeIndex is a free-running monotonic counter; the
765
- // reader detects silent overrun via firstReadable, so the overflow slot
766
- // (header[5]) stays at its initial 0.
767
1108
  }
768
1109
 
769
1110
  private commandRingFromSharedBuffer(
@@ -24,6 +24,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
24
24
  class RegisteredSonareRealtimeEngineWorkletProcessor extends Base {
25
25
  private bridge?: SonareRealtimeEngineWorkletProcessor;
26
26
  private readonly pendingMessages: unknown[] = [];
27
+ private pendingMessagesOverflowed = false;
27
28
  readonly port?: WorkletPort;
28
29
 
29
30
  constructor(options?: { processorOptions?: SonareRealtimeEngineWorkletProcessorOptions }) {
@@ -35,6 +36,10 @@ export function registerSonareRealtimeEngineWorkletProcessor(
35
36
  if (!this.bridge) {
36
37
  if (this.pendingMessages.length < 1024) {
37
38
  this.pendingMessages.push(event.data);
39
+ } else {
40
+ // A long sync before embind initialization must fail visibly rather
41
+ // than drop a provider/page/commit message and leave silent audio.
42
+ this.pendingMessagesOverflowed = true;
38
43
  }
39
44
  return;
40
45
  }
@@ -112,6 +117,9 @@ export function registerSonareRealtimeEngineWorkletProcessor(
112
117
  moduleFactory,
113
118
  });
114
119
  }
120
+ if (this.pendingMessagesOverflowed) {
121
+ throw new Error('AudioWorklet initialization message buffer overflowed.');
122
+ }
115
123
  this.bridge = new SonareRealtimeEngineWorkletProcessor(options, {
116
124
  postMessage: (message) => port?.postMessage?.(message),
117
125
  onMeter: (meter) => port?.postMessage?.(meter),
@@ -232,6 +232,17 @@ export function pushMidiCc(
232
232
  });
233
233
  }
234
234
 
235
+ export function pushMidiUmp(
236
+ ctx: EngineStripContext,
237
+ trackId: string | number,
238
+ word0: number,
239
+ renderFrame: number,
240
+ ): void {
241
+ const destinationId = ctx.resolveTargetId(trackId);
242
+ ctx.offlineEngine.pushMidiUmp(destinationId, word0, renderFrame);
243
+ ctx.postSync({ type: 'syncMidiUmp', destinationId, word0, renderFrame });
244
+ }
245
+
235
246
  export function setBuiltinInstrument(
236
247
  ctx: EngineStripContext,
237
248
  trackId: string | number,
@@ -260,7 +271,12 @@ export function loadSoundFont(ctx: EngineStripContext, data: Uint8Array): void {
260
271
  export function setSf2Instrument(
261
272
  ctx: EngineStripContext,
262
273
  trackId: string | number,
263
- config: { destinationId?: number; gain?: number; polyphony?: number },
274
+ config: {
275
+ destinationId?: number;
276
+ gain?: number;
277
+ polyphony?: number;
278
+ preferModelForModeledFamilies?: boolean;
279
+ },
264
280
  ): void {
265
281
  const destinationId = ctx.resolveTargetId(trackId);
266
282
  ctx.offlineEngine.setSf2Instrument(config, destinationId);