@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,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 {
@@ -6,7 +6,7 @@ import {
6
6
  type ResolvedMetronomeConfig,
7
7
  resolveMetronomeConfig,
8
8
  type SonareEngineCaptureRequestMessage,
9
- type SonareEngineCaptureResponseMessage,
9
+ type SonareEngineCaptureResponseMessageInternal,
10
10
  type SonareEngineSyncMessage,
11
11
  type SonareEngineTransportRequestMessage,
12
12
  type SonareEngineTransportResponseMessage,
@@ -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,9 +42,26 @@ import {
36
42
  type SonareWorkletMeterSnapshot,
37
43
  scopeRingFromSharedBuffer,
38
44
  telemetryFromEngine,
45
+ writeInt64Words,
39
46
  writeSonareEngineTelemetryRingBuffer,
40
47
  } from './protocol';
41
48
 
49
+ function captureTransferList(channels: readonly Float32Array[]): Transferable[] {
50
+ const transfers: ArrayBuffer[] = [];
51
+ const seen = new Set<ArrayBuffer>();
52
+ for (const channel of channels) {
53
+ const buffer = channel.buffer;
54
+ if (!(buffer instanceof ArrayBuffer)) {
55
+ throw new TypeError('capture response channels must use plain ArrayBuffers');
56
+ }
57
+ if (!seen.has(buffer)) {
58
+ seen.add(buffer);
59
+ transfers.push(buffer);
60
+ }
61
+ }
62
+ return transfers;
63
+ }
64
+
42
65
  /**
43
66
  * AudioWorklet-style bridge for the DAW realtime engine facade.
44
67
  *
@@ -55,6 +78,8 @@ export class SonareRealtimeEngineWorkletProcessor {
55
78
  private telemetryRing?: SonareEngineTelemetryRingBuffer;
56
79
  private meterRing?: SharedMeterRingWriter;
57
80
  private scopeRing?: SharedScopeRingWriter;
81
+ private clipPageRequestRing?: SharedClipPageRequestRingWriter;
82
+ private externalMidiRing?: SharedExternalMidiRingWriter;
58
83
  private transport?: WorkletTransport;
59
84
  private meterIntervalFrames: number;
60
85
  private lastMeterFrame = Number.NEGATIVE_INFINITY;
@@ -71,7 +96,15 @@ export class SonareRealtimeEngineWorkletProcessor {
71
96
  private channelBuffers: Float32Array[];
72
97
  private readonly liveClips = new Map<number, EngineClip>();
73
98
  private readonly pagedClipProviders = new Map<number, number>();
99
+ private readonly pagedClipPageFrames = new Map<number, number>();
74
100
  private readonly pendingPagedClips = new Map<number, EngineClip>();
101
+ // The worklet drains at most this many distinct page misses per render
102
+ // quantum. The fixed scalar buffers avoid retaining an unbounded queue on
103
+ // the audio thread; any remaining native requests stay queued for the next
104
+ // quantum and render silence meanwhile.
105
+ private readonly clipPageRequestClipIds = new Float64Array(64);
106
+ private readonly clipPageRequestPageIndices = new Float64Array(64);
107
+ private clipPageRequestOverflowReported = 0;
75
108
 
76
109
  constructor(
77
110
  options: SonareRealtimeEngineWorkletProcessorOptions = {},
@@ -101,7 +134,25 @@ export class SonareRealtimeEngineWorkletProcessor {
101
134
  options.scopeBands,
102
135
  )
103
136
  : undefined;
104
- this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
137
+ this.clipPageRequestRing = options.clipPageRequestSharedBuffer
138
+ ? clipPageRequestRingFromSharedBuffer(
139
+ options.clipPageRequestSharedBuffer,
140
+ options.clipPageRequestRingCapacity,
141
+ )
142
+ : undefined;
143
+ this.externalMidiRing = options.externalMidiSharedBuffer
144
+ ? externalMidiRingFromSharedBuffer(
145
+ options.externalMidiSharedBuffer,
146
+ options.externalMidiRingCapacity,
147
+ )
148
+ : undefined;
149
+ this.engine = new RealtimeEngine(
150
+ this.sampleRate,
151
+ this.blockSize,
152
+ 1024,
153
+ 1024,
154
+ this.channelCount,
155
+ );
105
156
  // Allocate persistent WASM-heap scratch (worst case: channelCount channels x
106
157
  // blockSize frames) and acquire the per-channel heap views once.
107
158
  this.engine.prepareChannels(this.channelCount, this.blockSize);
@@ -191,6 +242,7 @@ export class SonareRealtimeEngineWorkletProcessor {
191
242
  target.fill(0);
192
243
  }
193
244
  }
245
+ this.publishClipPageRequests();
194
246
  this.publishTelemetry();
195
247
  this.publishMeters();
196
248
  this.publishScope();
@@ -206,7 +258,7 @@ export class SonareRealtimeEngineWorkletProcessor {
206
258
 
207
259
  receiveCommand(command: SonareEngineCommandRecord): void {
208
260
  if (!this.closed) {
209
- this.applyCommand(command);
261
+ this.safeApplyCommand(command);
210
262
  }
211
263
  }
212
264
 
@@ -218,35 +270,68 @@ export class SonareRealtimeEngineWorkletProcessor {
218
270
  if (this.closed) {
219
271
  return;
220
272
  }
273
+ try {
274
+ this.applySync(message);
275
+ } catch (error) {
276
+ this.transport?.postMessage?.({
277
+ type: 'syncError',
278
+ syncType: message.type,
279
+ message: error instanceof Error ? error.message : String(error),
280
+ });
281
+ }
282
+ }
283
+
284
+ private applySync(message: SonareEngineSyncMessage): void {
221
285
  switch (message.type) {
222
- case 'syncClips':
286
+ case 'destroy':
287
+ this.destroy();
288
+ return;
289
+ case 'syncClips': {
290
+ // Commit native state before touching the worklet-side mirrors. A
291
+ // rejected embind call must leave a later delta based on the previous
292
+ // clip set, not on a half-applied replacement.
293
+ this.engine.setClips(message.clips);
294
+ this.clearPagedClipProviders();
223
295
  this.liveClips.clear();
224
296
  for (const clip of message.clips) {
225
297
  if (clip.id !== undefined) {
226
298
  this.liveClips.set(clip.id, clip);
227
299
  }
228
300
  }
229
- this.engine.setClips(message.clips);
230
301
  break;
231
- case 'syncClipsDelta':
302
+ }
303
+ case 'syncClipsDelta': {
304
+ const nextClips = new Map(this.liveClips);
232
305
  for (const clipId of message.removeIds) {
233
- this.liveClips.delete(clipId);
306
+ nextClips.delete(clipId);
234
307
  }
235
308
  for (const clip of message.upserts) {
236
309
  if (clip.id !== undefined) {
237
- this.liveClips.set(clip.id, clip);
310
+ nextClips.set(clip.id, clip);
238
311
  }
239
312
  }
240
- this.engine.setClips(Array.from(this.liveClips.values()));
313
+ this.engine.setClips(Array.from(nextClips.values()));
314
+ for (const clipId of message.removeIds) {
315
+ this.removePagedClipProvider(clipId);
316
+ }
317
+ this.liveClips.clear();
318
+ for (const [clipId, clip] of nextClips) {
319
+ this.liveClips.set(clipId, clip);
320
+ }
241
321
  break;
322
+ }
242
323
  case 'syncClipPageProvider': {
243
324
  const provider = this.engine.createClipPageProvider(
244
325
  message.numChannels,
245
326
  message.numSamples,
246
327
  message.pageFrames,
247
328
  );
329
+ this.removePagedClipProvider(message.clipId);
248
330
  this.pagedClipProviders.set(message.clipId, provider.id);
249
- this.pendingPagedClips.set(message.clipId, message.clip);
331
+ this.pagedClipPageFrames.set(message.clipId, message.pageFrames);
332
+ if (message.clip) {
333
+ this.pendingPagedClips.set(message.clipId, message.clip);
334
+ }
250
335
  break;
251
336
  }
252
337
  case 'syncClipPage': {
@@ -256,16 +341,34 @@ export class SonareRealtimeEngineWorkletProcessor {
256
341
  }
257
342
  break;
258
343
  }
344
+ case 'syncClipPageClear': {
345
+ const providerId = this.pagedClipProviders.get(message.clipId);
346
+ if (providerId !== undefined) {
347
+ this.engine.clearClipPage(providerId, message.pageIndex);
348
+ }
349
+ break;
350
+ }
259
351
  case 'syncClipPageCommit': {
260
352
  const providerId = this.pagedClipProviders.get(message.clipId);
261
- const clip = this.pendingPagedClips.get(message.clipId);
353
+ const clip = message.clip ?? this.pendingPagedClips.get(message.clipId);
262
354
  if (providerId !== undefined && clip) {
263
- this.liveClips.set(message.clipId, { ...clip, pageProvider: providerId });
355
+ const nextClip = { ...clip, pageProvider: providerId };
356
+ const nextClips = new Map(this.liveClips);
357
+ nextClips.set(message.clipId, nextClip);
358
+ this.engine.setClips(Array.from(nextClips.values()));
359
+ this.liveClips.set(message.clipId, nextClip);
264
360
  this.pendingPagedClips.delete(message.clipId);
265
- this.engine.setClips(Array.from(this.liveClips.values()));
266
361
  }
267
362
  break;
268
363
  }
364
+ case 'syncClipPageDestroy': {
365
+ const nextClips = new Map(this.liveClips);
366
+ nextClips.delete(message.clipId);
367
+ this.engine.setClips(Array.from(nextClips.values()));
368
+ this.liveClips.delete(message.clipId);
369
+ this.removePagedClipProvider(message.clipId);
370
+ break;
371
+ }
269
372
  case 'syncMidiClips':
270
373
  this.engine.setMidiClips(message.clips);
271
374
  break;
@@ -273,12 +376,23 @@ export class SonareRealtimeEngineWorkletProcessor {
273
376
  this.engine.setMarkers(message.markers);
274
377
  break;
275
378
  case 'syncMetronome':
276
- this.metronomeConfig = resolveMetronomeConfig(message.config);
277
- this.engine.setMetronome(message.config);
379
+ // Do not publish the cached config until the native engine accepted it.
380
+ // Otherwise a rejected sync would corrupt the command-side fallback.
381
+ {
382
+ const config = resolveMetronomeConfig(message.config);
383
+ this.engine.setMetronome(message.config);
384
+ this.metronomeConfig = config;
385
+ }
278
386
  break;
279
387
  case 'syncAutomation':
280
388
  this.engine.setAutomationLane(message.paramId, message.points);
281
389
  break;
390
+ case 'syncParameters':
391
+ this.engine.clearParameters();
392
+ for (const info of message.parameters) {
393
+ this.engine.addParameter(info);
394
+ }
395
+ break;
282
396
  case 'syncTempo':
283
397
  if (message.tempoSegments) {
284
398
  this.engine.setTempoSegments(message.tempoSegments);
@@ -425,6 +539,9 @@ export class SonareRealtimeEngineWorkletProcessor {
425
539
  message.renderFrame,
426
540
  );
427
541
  break;
542
+ case 'syncMidiUmp':
543
+ this.engine.pushMidiUmp(message.destinationId, message.word0, message.renderFrame);
544
+ break;
428
545
  case 'syncMidiSysex':
429
546
  this.engine.pushMidiSysex(message.destinationId, message.data, message.renderFrame);
430
547
  break;
@@ -437,6 +554,45 @@ export class SonareRealtimeEngineWorkletProcessor {
437
554
  case 'syncExternalMidiClock':
438
555
  this.engine.setExternalMidiClockEnabled(message.enabled);
439
556
  break;
557
+ case 'syncMidiInputSource':
558
+ this.engine.setMidiInputSource(message.destinationId ?? 0);
559
+ break;
560
+ case 'syncClearMidiInputSource':
561
+ this.engine.clearMidiInputSource();
562
+ break;
563
+ case 'syncMidiCcBinding':
564
+ this.engine.bindMidiCc(message.channel, message.controller, message.paramId, {
565
+ minValue: message.minValue,
566
+ maxValue: message.maxValue,
567
+ });
568
+ break;
569
+ case 'syncMidiInputNoteOn':
570
+ this.engine.pushMidiInputNoteOn(
571
+ message.group,
572
+ message.channel,
573
+ message.data0,
574
+ message.data1,
575
+ message.portTimeSamples,
576
+ );
577
+ break;
578
+ case 'syncMidiInputNoteOff':
579
+ this.engine.pushMidiInputNoteOff(
580
+ message.group,
581
+ message.channel,
582
+ message.data0,
583
+ message.data1,
584
+ message.portTimeSamples,
585
+ );
586
+ break;
587
+ case 'syncMidiInputCc':
588
+ this.engine.pushMidiInputCc(
589
+ message.group,
590
+ message.channel,
591
+ message.data0,
592
+ message.data1,
593
+ message.portTimeSamples,
594
+ );
595
+ break;
440
596
  }
441
597
  }
442
598
 
@@ -459,26 +615,24 @@ export class SonareRealtimeEngineWorkletProcessor {
459
615
  source: status.source,
460
616
  recordOffsetSamples: status.recordOffsetSamples,
461
617
  },
462
- } satisfies SonareEngineCaptureResponseMessage);
618
+ } satisfies SonareEngineCaptureResponseMessageInternal);
463
619
  return;
464
620
  }
465
621
  if (message.op === 'read') {
466
- const captured = this.engine.capturedAudio();
467
- const channels: number[][] = [];
468
- for (let ch = 0; ch < captured.length; ch++) {
469
- const source = captured[ch];
470
- const copy: number[] = [];
471
- for (let i = 0; i < source.length; i++) {
472
- copy.push(Number(source[i]));
473
- }
474
- channels.push(copy);
475
- }
476
- this.transport?.postMessage?.({
477
- type: 'captureResponse',
478
- requestId: message.requestId,
479
- ok: true,
480
- channels,
481
- } satisfies SonareEngineCaptureResponseMessage);
622
+ // Embind's outer val::array is array-like but not structured-cloneable
623
+ // in every host. Copy only that tiny container; keep each native
624
+ // Float32Array intact so the channel payload remains transferable.
625
+ const channels = Array.from(this.engine.capturedAudio());
626
+ const transfer = captureTransferList(channels);
627
+ this.transport?.postMessage?.(
628
+ {
629
+ type: 'captureResponse',
630
+ requestId: message.requestId,
631
+ ok: true,
632
+ channels,
633
+ } satisfies SonareEngineCaptureResponseMessageInternal,
634
+ transfer,
635
+ );
482
636
  return;
483
637
  }
484
638
  this.engine.resetCapture();
@@ -486,14 +640,14 @@ export class SonareRealtimeEngineWorkletProcessor {
486
640
  type: 'captureResponse',
487
641
  requestId: message.requestId,
488
642
  ok: true,
489
- } satisfies SonareEngineCaptureResponseMessage);
643
+ } satisfies SonareEngineCaptureResponseMessageInternal);
490
644
  } catch (error) {
491
645
  this.transport?.postMessage?.({
492
646
  type: 'captureResponse',
493
647
  requestId: message.requestId,
494
648
  ok: false,
495
649
  error: error instanceof Error ? error.message : String(error),
496
- } satisfies SonareEngineCaptureResponseMessage);
650
+ } satisfies SonareEngineCaptureResponseMessageInternal);
497
651
  }
498
652
  }
499
653
 
@@ -520,6 +674,7 @@ export class SonareRealtimeEngineWorkletProcessor {
520
674
 
521
675
  destroy(): void {
522
676
  if (!this.closed) {
677
+ this.clearPagedClipProviders();
523
678
  this.engine.destroy();
524
679
  this.closed = true;
525
680
  }
@@ -534,7 +689,25 @@ export class SonareRealtimeEngineWorkletProcessor {
534
689
  if (!command) {
535
690
  return;
536
691
  }
692
+ this.safeApplyCommand(command);
693
+ }
694
+ }
695
+
696
+ private safeApplyCommand(command: SonareEngineCommandRecord): void {
697
+ try {
537
698
  this.applyCommand(command);
699
+ } catch {
700
+ // A malformed control command must never escape AudioWorklet.process():
701
+ // Web Audio permanently stops invoking a processor that throws there.
702
+ this.publishTelemetryRecord({
703
+ type: SonareEngineTelemetryType.Error,
704
+ error: SonareEngineTelemetryError.InvalidCommand,
705
+ renderFrame: 0,
706
+ timelineSample: 0,
707
+ audibleTimelineSample: 0,
708
+ graphLatencySamplesQ8: 0,
709
+ value: Number(command.type),
710
+ });
538
711
  }
539
712
  }
540
713
 
@@ -569,9 +742,6 @@ export class SonareRealtimeEngineWorkletProcessor {
569
742
  case SonareEngineCommandType.TransportSeekPpq:
570
743
  this.engine.seekPpq(Number(command.argFloat ?? 0), sampleTime);
571
744
  break;
572
- case SonareEngineCommandType.SetTempoMap:
573
- this.engine.setTempo(Number(command.argFloat ?? 120));
574
- break;
575
745
  case SonareEngineCommandType.SetLoop:
576
746
  this.engine.setLoop(
577
747
  Number(command.argFloat ?? 0),
@@ -601,6 +771,7 @@ export class SonareRealtimeEngineWorkletProcessor {
601
771
  beatGain: this.metronomeConfig.beatGain,
602
772
  accentGain: this.metronomeConfig.accentGain,
603
773
  clickSamples: this.metronomeConfig.clickSamples,
774
+ clickSeconds: this.metronomeConfig.clickSeconds,
604
775
  });
605
776
  break;
606
777
  case SonareEngineCommandType.SeekMarker:
@@ -616,6 +787,24 @@ export class SonareRealtimeEngineWorkletProcessor {
616
787
  sampleTime,
617
788
  );
618
789
  break;
790
+ case SonareEngineCommandType.SetTrackMonitorMode: {
791
+ const rawMode = command.argInt;
792
+ const mode = typeof rawMode === 'bigint' ? Number(rawMode) : rawMode;
793
+ if (typeof mode !== 'number' || !Number.isSafeInteger(mode) || mode < 0 || mode > 2) {
794
+ throw new RangeError(`Invalid track monitor mode: ${String(rawMode)}`);
795
+ }
796
+ const laneIndex = command.targetId;
797
+ if (
798
+ typeof laneIndex !== 'number' ||
799
+ !Number.isSafeInteger(laneIndex) ||
800
+ laneIndex < 0 ||
801
+ laneIndex > 0xffff_ffff
802
+ ) {
803
+ throw new RangeError(`Invalid track monitor lane index: ${String(laneIndex)}`);
804
+ }
805
+ this.engine.setTrackMonitorMode(laneIndex, mode as 0 | 1 | 2, sampleTime);
806
+ break;
807
+ }
619
808
  default:
620
809
  this.publishTelemetryRecord({
621
810
  type: SonareEngineTelemetryType.Error,
@@ -631,11 +820,36 @@ export class SonareRealtimeEngineWorkletProcessor {
631
820
  }
632
821
 
633
822
  private publishTelemetry(): void {
823
+ const ring = this.telemetryRing;
824
+ if (ring) {
825
+ for (let count = 0; count < 64 && this.engine.popTelemetryToScratch(); count++) {
826
+ this.writeTelemetryScratch(ring);
827
+ }
828
+ return;
829
+ }
634
830
  for (const item of this.engine.drainTelemetry(64)) {
635
831
  this.publishTelemetryRecord(telemetryFromEngine(item));
636
832
  }
637
833
  }
638
834
 
835
+ private writeTelemetryScratch(ring: SonareEngineTelemetryRingBuffer): void {
836
+ const writeIndex = Atomics.load(ring.header, 0);
837
+ const offset = (writeIndex % ring.capacity) * SONARE_ENGINE_TELEMETRY_RECORD_BYTES;
838
+ ring.view.setUint32(offset, this.engine.telemetryScratchType(), true);
839
+ ring.view.setUint32(offset + 4, this.engine.telemetryScratchError(), true);
840
+ writeInt64Words(ring.view, offset + 8, this.engine.telemetryScratchRenderFrame());
841
+ writeInt64Words(ring.view, offset + 16, this.engine.telemetryScratchTimelineSample());
842
+ writeInt64Words(ring.view, offset + 24, this.engine.telemetryScratchAudibleTimelineSample());
843
+ ring.view.setInt32(offset + 32, this.engine.telemetryScratchGraphLatencySamplesQ8(), true);
844
+ ring.view.setUint32(offset + 36, this.engine.telemetryScratchValue(), true);
845
+ ring.view.setUint32(offset + 40, 0, true);
846
+ ring.view.setUint32(offset + 44, 0, true);
847
+ Atomics.store(ring.header, 0, writeIndex + 1);
848
+ if (writeIndex + 1 > ring.capacity) {
849
+ Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
850
+ }
851
+ }
852
+
639
853
  private publishTelemetryRecord(record: SonareEngineTelemetryRecord): void {
640
854
  if (this.telemetryRing) {
641
855
  writeSonareEngineTelemetryRingBuffer(this.telemetryRing, record);
@@ -659,6 +873,22 @@ export class SonareRealtimeEngineWorkletProcessor {
659
873
  if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
660
874
  return;
661
875
  }
876
+ if (this.meterRing) {
877
+ for (let count = 0; count < 64 && this.engine.popMeterTelemetryToScratch(); count++) {
878
+ const frame = this.engine.meterScratchRenderFrame();
879
+ if (
880
+ frame !== this.lastMeterFrame &&
881
+ frame - this.lastMeterFrame < this.meterIntervalFrames
882
+ ) {
883
+ continue;
884
+ }
885
+ if (frame !== this.lastMeterFrame) {
886
+ this.lastMeterFrame = frame;
887
+ }
888
+ this.writeMeterScratch(this.meterRing);
889
+ }
890
+ return;
891
+ }
662
892
  for (const item of this.engine.drainMeterTelemetry(64)) {
663
893
  const meter = meterFromEngine(item);
664
894
  if (
@@ -683,6 +913,19 @@ export class SonareRealtimeEngineWorkletProcessor {
683
913
  }
684
914
  }
685
915
 
916
+ private writeMeterScratch(ring: SharedMeterRingWriter): void {
917
+ const writeIndex = Atomics.load(ring.header, 0);
918
+ const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
919
+ const frame = Number(this.engine.meterScratchRenderFrame());
920
+ ring.records[offset] = encodeFrameLo(frame);
921
+ ring.records[offset + 1] = encodeFrameHi(frame);
922
+ ring.records[offset + 2] = this.engine.meterScratchTargetId();
923
+ for (let field = 0; field < 11; field++) {
924
+ ring.records[offset + 3 + field] = this.engine.meterScratchValue(field);
925
+ }
926
+ Atomics.store(ring.header, 0, writeIndex + 1);
927
+ }
928
+
686
929
  private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
687
930
  const ring = this.meterRing;
688
931
  if (!ring) {
@@ -720,19 +963,59 @@ export class SonareRealtimeEngineWorkletProcessor {
720
963
  if (!ring) {
721
964
  return;
722
965
  }
723
- for (const item of this.engine.drainScopeTelemetry(64)) {
724
- this.writeScopeRing(ring, item);
966
+ for (let count = 0; count < 64 && this.engine.popScopeTelemetryToScratch(); count++) {
967
+ this.writeScopeScratch(ring);
725
968
  }
726
969
  }
727
970
 
971
+ private writeScopeScratch(ring: SharedScopeRingWriter): void {
972
+ const writeIndex = Atomics.load(ring.header, 0);
973
+ const base = (writeIndex % ring.capacity) * ring.recordFloats;
974
+ const frame = Number(this.engine.scopeScratchRenderFrame());
975
+ ring.records[base] = encodeFrameLo(frame);
976
+ ring.records[base + 1] = encodeFrameHi(frame);
977
+ ring.records[base + 2] = this.engine.scopeScratchTargetId();
978
+ const bandCount = Math.min(ring.bands, this.engine.scopeScratchBandCount());
979
+ ring.records[base + 3] = bandCount;
980
+ const pointCount = Math.min(ring.maxPoints, this.engine.scopeScratchPointCount());
981
+ ring.records[base + 4] = pointCount;
982
+ const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
983
+ for (let i = 0; i < bandCount; i++) {
984
+ ring.records[bandsBase + i] = this.engine.scopeScratchBand(i);
985
+ }
986
+ const pointsBase = bandsBase + ring.bands;
987
+ for (let i = 0; i < pointCount; i++) {
988
+ ring.records[pointsBase + 2 * i] = this.engine.scopeScratchPointLeft(i);
989
+ ring.records[pointsBase + 2 * i + 1] = this.engine.scopeScratchPointRight(i);
990
+ }
991
+ Atomics.store(ring.header, 0, writeIndex + 1);
992
+ }
993
+
728
994
  // Drains queued external-MIDI events (already lowered to MIDI 1.0 bytes) and
729
995
  // forwards them to the main thread for delivery to Web MIDI output ports.
730
996
  // One batch per render block; skipped entirely when nothing is queued, so an
731
997
  // all-internal project never allocates or posts here.
732
998
  private publishExternalMidi(): void {
999
+ const ring = this.externalMidiRing;
1000
+ if (ring) {
1001
+ for (let count = 0; count < 256 && this.engine.popExternalMidiToScratch(); count++) {
1002
+ pushSonareExternalMidiRingBuffer(
1003
+ ring,
1004
+ this.engine.externalMidiScratchDestinationId(),
1005
+ this.engine.externalMidiScratchRenderFrame(),
1006
+ this.engine.externalMidiScratchByteWord(),
1007
+ this.engine.externalMidiScratchByteCount(),
1008
+ );
1009
+ this.engine.consumeExternalMidiScratch();
1010
+ }
1011
+ return;
1012
+ }
733
1013
  if (!this.transport?.postMessage) {
734
1014
  return;
735
1015
  }
1016
+ if (this.engine.externalMidiPendingCount() === 0) {
1017
+ return;
1018
+ }
736
1019
  const events = this.engine.drainExternalMidi(256);
737
1020
  if (events.length === 0) {
738
1021
  return;
@@ -740,30 +1023,120 @@ export class SonareRealtimeEngineWorkletProcessor {
740
1023
  this.transport.postMessage({ type: 'externalMidi', events });
741
1024
  }
742
1025
 
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];
1026
+ /**
1027
+ * Forward a bounded batch of native page misses to the main thread. This
1028
+ * deliberately runs after audio rendering: a cache miss is silence for this
1029
+ * block, and OPFS I/O must never delay `process()`.
1030
+ */
1031
+ private publishClipPageRequests(): void {
1032
+ const ring = this.clipPageRequestRing;
1033
+ const transport = this.transport;
1034
+ if (!ring && !transport?.postMessage) {
1035
+ return;
756
1036
  }
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;
1037
+ let count = 0;
1038
+ let drained = 0;
1039
+ while (drained < this.clipPageRequestClipIds.length) {
1040
+ drained += 1;
1041
+ let clipId: number;
1042
+ let sample: number;
1043
+ if (ring) {
1044
+ // This scalar scratch API is intentionally used only on the SAB path:
1045
+ // it avoids embind materialising one JS object for every page miss in
1046
+ // AudioWorklet process().
1047
+ if (!this.engine.popClipPageRequestToScratch()) {
1048
+ break;
1049
+ }
1050
+ clipId = this.engine.clipPageRequestScratchClipId();
1051
+ sample = this.engine.clipPageRequestScratchSample();
1052
+ } else {
1053
+ // postMessage fallback is explicitly non-RT-safe; retain the public
1054
+ // object-returning API only for that degraded control-plane path.
1055
+ const request = this.engine.popClipPageRequest();
1056
+ if (!request) {
1057
+ break;
1058
+ }
1059
+ clipId = request.clipId;
1060
+ sample = request.sample;
1061
+ }
1062
+ const pageFrames = this.pagedClipPageFrames.get(clipId);
1063
+ if (!pageFrames) {
1064
+ continue;
1065
+ }
1066
+ const pageIndex = Math.floor(sample / pageFrames);
1067
+ let duplicate = false;
1068
+ for (let i = 0; i < count; ++i) {
1069
+ if (
1070
+ this.clipPageRequestClipIds[i] === clipId &&
1071
+ this.clipPageRequestPageIndices[i] === pageIndex
1072
+ ) {
1073
+ duplicate = true;
1074
+ break;
1075
+ }
1076
+ }
1077
+ if (!duplicate) {
1078
+ this.clipPageRequestClipIds[count] = clipId;
1079
+ this.clipPageRequestPageIndices[count] = pageIndex;
1080
+ count += 1;
1081
+ }
1082
+ }
1083
+ const overflowCount = this.engine.clipPageRequestOverflowCount();
1084
+ // The native counter is uint32_t; preserve a correct delta across its
1085
+ // defined unsigned wraparound instead of emitting a negative count.
1086
+ const dropped = (overflowCount - this.clipPageRequestOverflowReported) >>> 0;
1087
+ this.clipPageRequestOverflowReported = overflowCount;
1088
+ if (ring) {
1089
+ // Preserve native-queue loss in the same cumulative counter used for a
1090
+ // full SAB request ring. No object, structured clone, or postMessage is
1091
+ // created on this path.
1092
+ if (dropped > 0) {
1093
+ Atomics.add(ring.header, 4, dropped);
1094
+ }
1095
+ for (let i = 0; i < count; ++i) {
1096
+ pushSonareClipPageRequestRingBuffer(
1097
+ ring,
1098
+ this.clipPageRequestClipIds[i],
1099
+ this.clipPageRequestPageIndices[i],
1100
+ );
1101
+ }
1102
+ return;
1103
+ }
1104
+ if (count === 0 && dropped === 0) {
1105
+ return;
1106
+ }
1107
+ if (!transport?.postMessage) {
1108
+ return;
1109
+ }
1110
+ // A message is necessarily allocated by postMessage; the hot path's
1111
+ // resident queue remains fixed-size and no message is built without a miss.
1112
+ const requests = new Array<{ clipId: number; pageIndex: number }>(count);
1113
+ for (let i = 0; i < count; ++i) {
1114
+ requests[i] = {
1115
+ clipId: this.clipPageRequestClipIds[i],
1116
+ pageIndex: this.clipPageRequestPageIndices[i],
1117
+ };
1118
+ }
1119
+ transport.postMessage({
1120
+ type: 'clipPageRequest',
1121
+ requests,
1122
+ ...(dropped > 0 ? { dropped } : {}),
1123
+ });
1124
+ }
1125
+
1126
+ private removePagedClipProvider(clipId: number): void {
1127
+ const providerId = this.pagedClipProviders.get(clipId);
1128
+ if (providerId !== undefined) {
1129
+ this.engine.destroyClipPageProvider(providerId);
1130
+ }
1131
+ this.pagedClipProviders.delete(clipId);
1132
+ this.pagedClipPageFrames.delete(clipId);
1133
+ this.pendingPagedClips.delete(clipId);
1134
+ }
1135
+
1136
+ private clearPagedClipProviders(): void {
1137
+ for (const clipId of this.pagedClipProviders.keys()) {
1138
+ this.removePagedClipProvider(clipId);
762
1139
  }
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
1140
  }
768
1141
 
769
1142
  private commandRingFromSharedBuffer(