@libraz/libsonare 1.5.5 → 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 (75) 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 +759 -35
  6. package/dist/index.js +904 -209
  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 +574 -86
  18. package/dist/worklet.js +1634 -235
  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 +6 -0
  24. package/src/clip_page_streamer.ts +62 -8
  25. package/src/codes.ts +51 -64
  26. package/src/effects_mastering.ts +1 -0
  27. package/src/errors.ts +1 -0
  28. package/src/feature_core.ts +140 -2
  29. package/src/feature_music.ts +17 -2
  30. package/src/feature_pitch.ts +70 -1
  31. package/src/feature_spectral.ts +231 -5
  32. package/src/feature_spectrogram.ts +159 -0
  33. package/src/features.ts +16 -1
  34. package/src/index.ts +101 -7
  35. package/src/mastering_chain.ts +20 -8
  36. package/src/mastering_core.ts +5 -0
  37. package/src/mastering_dynamics.ts +7 -1
  38. package/src/metering.ts +6 -1
  39. package/src/opfs_clip_pages.ts +26 -1
  40. package/src/project.ts +7 -0
  41. package/src/project_class.ts +62 -8
  42. package/src/project_internal.ts +24 -29
  43. package/src/project_types.ts +69 -10
  44. package/src/public_types.ts +68 -0
  45. package/src/public_types_mastering.ts +22 -0
  46. package/src/public_types_mixing.ts +11 -2
  47. package/src/public_types_music.ts +9 -0
  48. package/src/public_types_spectral.ts +33 -0
  49. package/src/quick_analysis.ts +25 -7
  50. package/src/realtime_engine.ts +153 -3
  51. package/src/realtime_voice_changer.ts +51 -77
  52. package/src/sonare-analysis.js.d.ts +8 -0
  53. package/src/sonare.js.d.ts +276 -6
  54. package/src/streaming_processors.ts +15 -0
  55. package/src/web_midi.ts +36 -4
  56. package/src/worker.ts +196 -0
  57. package/src/worker_client.ts +352 -0
  58. package/src/worker_protocol.ts +59 -0
  59. package/src/worklet/engine-automation.ts +2 -1
  60. package/src/worklet/engine-capture-facade.ts +13 -4
  61. package/src/worklet/engine-clips.ts +12 -3
  62. package/src/worklet/engine-node.ts +212 -4
  63. package/src/worklet/engine-offline.ts +7 -2
  64. package/src/worklet/engine-parameter-facade.ts +26 -5
  65. package/src/worklet/engine-processor.ts +383 -42
  66. package/src/worklet/engine-register.ts +8 -0
  67. package/src/worklet/engine-strips.ts +17 -1
  68. package/src/worklet/engine-sync.ts +2 -6
  69. package/src/worklet/engine-tempo-facade.ts +8 -6
  70. package/src/worklet/engine.ts +380 -13
  71. package/src/worklet/guards.ts +22 -1
  72. package/src/worklet/messages.ts +125 -7
  73. package/src/worklet/protocol.ts +301 -17
  74. package/src/worklet/voice-changer-processor.ts +12 -15
  75. package/src/worklet.ts +20 -1
@@ -6,11 +6,13 @@ import type {
6
6
  EngineMarker,
7
7
  EngineMetronomeConfig,
8
8
  EngineMidiClipSchedule,
9
+ EngineParameterInfo,
9
10
  EngineTempoSegment,
10
11
  EngineTimeSignatureSegment,
11
12
  EngineTrackLane,
12
13
  EngineTransportState,
13
14
  RealtimeVoiceChangerConfigInput,
15
+ RealtimeVoiceChangerPodConfig,
14
16
  } from '../index';
15
17
  import type { AutomationCurve } from '../public_types';
16
18
  import type {
@@ -55,6 +57,16 @@ export interface SonareRealtimeEngineWorkletProcessorOptions {
55
57
  scopeBands?: number;
56
58
  scopeSharedBuffer?: SharedArrayBuffer;
57
59
  scopeRingCapacity?: number;
60
+ /**
61
+ * Lock-free SPSC queue for clip-page cache misses. Supplying this is required
62
+ * for realtime-safe OPFS streaming: it avoids both embind object creation and
63
+ * postMessage structured cloning from AudioWorklet process().
64
+ */
65
+ clipPageRequestSharedBuffer?: SharedArrayBuffer;
66
+ clipPageRequestRingCapacity?: number;
67
+ /** Lock-free worklet-to-main-thread MIDI-1 output ring. */
68
+ externalMidiSharedBuffer?: SharedArrayBuffer;
69
+ externalMidiRingCapacity?: number;
58
70
  }
59
71
 
60
72
  export interface SonareRealtimeVoiceChangerWorkletProcessorOptions {
@@ -66,7 +78,8 @@ export interface SonareRealtimeVoiceChangerWorkletProcessorOptions {
66
78
 
67
79
  export interface SonareRealtimeVoiceChangerSetConfigMessage {
68
80
  type: 'setConfig';
69
- preset: RealtimeVoiceChangerConfigInput;
81
+ /** Pre-normalized by the main thread; never JSON parsed in the worklet. */
82
+ config: RealtimeVoiceChangerPodConfig;
70
83
  }
71
84
 
72
85
  export interface SonareRealtimeVoiceChangerResetMessage {
@@ -88,6 +101,10 @@ export interface SonareRealtimeEngineNodeCapabilities {
88
101
  sharedArrayBuffer: boolean;
89
102
  atomics: boolean;
90
103
  audioWorklet: boolean;
104
+ /** True only when clip-page misses use the bounded SAB ring. */
105
+ clipPageRequestsRealtimeSafe: boolean;
106
+ /** True when external MIDI uses the SAB output ring rather than postMessage. */
107
+ externalMidiRealtimeSafe: boolean;
91
108
  engineAbiVersion?: number;
92
109
  expectedEngineAbiVersion?: number;
93
110
  abiCompatible?: boolean;
@@ -161,10 +178,18 @@ export interface SonareWorkletExternalMidiMessage {
161
178
  events: SonareWorkletExternalMidiEvent[];
162
179
  }
163
180
 
181
+ /** A control-plane sync message that the worklet rejected without crashing. */
182
+ export interface SonareEngineSyncErrorMessage {
183
+ type: 'syncError';
184
+ syncType: SonareEngineSyncMessage['type'];
185
+ message: string;
186
+ }
187
+
164
188
  export type SonareWorkletTransportMessage =
165
189
  | SonareWorkletMeterSnapshot
166
190
  | SonareWorkletSpectrumSnapshot
167
191
  | SonareWorkletExternalMidiMessage
192
+ | SonareEngineClipPageRequestMessage
168
193
  | SonareEngineTelemetryRecord;
169
194
 
170
195
  export interface WorkletTransport {
@@ -172,7 +197,8 @@ export interface WorkletTransport {
172
197
  message:
173
198
  | SonareWorkletTransportMessage
174
199
  | SonareEngineCaptureResponseMessage
175
- | SonareEngineTransportResponseMessage,
200
+ | SonareEngineTransportResponseMessage
201
+ | SonareEngineSyncErrorMessage,
176
202
  transfer?: Transferable[],
177
203
  ) => void;
178
204
  onMeter?: (meter: SonareWorkletMeterSnapshot) => void;
@@ -183,6 +209,7 @@ export interface ResolvedMetronomeConfig {
183
209
  beatGain: number;
184
210
  accentGain: number;
185
211
  clickSamples: number;
212
+ clickSeconds: number;
186
213
  }
187
214
 
188
215
  // Fallback metronome gains/click length used by the worklet consumer until the
@@ -192,6 +219,7 @@ export const DEFAULT_METRONOME_CONFIG: ResolvedMetronomeConfig = {
192
219
  beatGain: 0.35,
193
220
  accentGain: 0.7,
194
221
  clickSamples: 0,
222
+ clickSeconds: 0,
195
223
  };
196
224
 
197
225
  export function resolveMetronomeConfig(config: EngineMetronomeConfig): ResolvedMetronomeConfig {
@@ -199,6 +227,7 @@ export function resolveMetronomeConfig(config: EngineMetronomeConfig): ResolvedM
199
227
  beatGain: config.beatGain ?? DEFAULT_METRONOME_CONFIG.beatGain,
200
228
  accentGain: config.accentGain ?? DEFAULT_METRONOME_CONFIG.accentGain,
201
229
  clickSamples: config.clickSamples ?? DEFAULT_METRONOME_CONFIG.clickSamples,
230
+ clickSeconds: config.clickSeconds ?? DEFAULT_METRONOME_CONFIG.clickSeconds,
202
231
  };
203
232
  if (
204
233
  !Number.isFinite(resolved.beatGain) ||
@@ -207,6 +236,8 @@ export function resolveMetronomeConfig(config: EngineMetronomeConfig): ResolvedM
207
236
  resolved.accentGain < 0 ||
208
237
  !Number.isInteger(resolved.clickSamples) ||
209
238
  resolved.clickSamples < 0 ||
239
+ !Number.isFinite(resolved.clickSeconds) ||
240
+ resolved.clickSeconds < 0 ||
210
241
  resolved.clickSamples > 384000 ||
211
242
  (config.clickSeconds !== undefined &&
212
243
  (!Number.isFinite(config.clickSeconds) || config.clickSeconds < 0 || config.clickSeconds > 1))
@@ -220,8 +251,9 @@ export function resolveMetronomeConfig(config: EngineMetronomeConfig): ResolvedM
220
251
  // to the worklet engine processor over node.port. Unlike SonareEngineCommandRecord
221
252
  // (a small POD POSTed/ringed every block) these carry bulk/structured payloads
222
253
  // (clip audio buffers, marker lists, metronome config) that cannot fit the
223
- // fixed-size SAB command record, so they are applied OUTSIDE process() — the
224
- // audio-thread equivalent of the engine's control-thread RtPublisher setters.
254
+ // fixed-size SAB command record. Their port handlers still execute on the
255
+ // AudioWorklet rendering thread, so consumers must validate them without
256
+ // allocation and publish bounded snapshots for process() to consume.
225
257
  export interface SonareEngineSyncClipsMessage {
226
258
  type: 'syncClips';
227
259
  clips: EngineClip[];
@@ -237,7 +269,8 @@ export interface SonareEngineSyncClipsDeltaMessage {
237
269
  export interface SonareEngineSyncClipPageProviderMessage {
238
270
  type: 'syncClipPageProvider';
239
271
  clipId: number;
240
- clip: EngineClip;
272
+ /** Omitted when an OPFS stream is primed before its clip is scheduled. */
273
+ clip?: EngineClip;
241
274
  numChannels: number;
242
275
  numSamples: number;
243
276
  pageFrames: number;
@@ -251,10 +284,41 @@ export interface SonareEngineSyncClipPageMessage {
251
284
  channels: Float32Array[];
252
285
  }
253
286
 
287
+ /** Evicts one page after the main-thread sliding window advances. */
288
+ export interface SonareEngineSyncClipPageClearMessage {
289
+ type: 'syncClipPageClear';
290
+ clipId: number;
291
+ pageIndex: number;
292
+ }
293
+
294
+ /** Releases a provider whose clip was removed or whose stream was closed. */
295
+ export interface SonareEngineSyncClipPageDestroyMessage {
296
+ type: 'syncClipPageDestroy';
297
+ clipId: number;
298
+ }
299
+
254
300
  /** Makes a fully supplied paged clip visible to the audio engine. */
255
301
  export interface SonareEngineSyncClipPageCommitMessage {
256
302
  type: 'syncClipPageCommit';
257
303
  clipId: number;
304
+ /**
305
+ * Clip schedule to publish with a provider that was primed before its
306
+ * schedule was known. The original pre-baked push path supplies it on the
307
+ * provider message instead.
308
+ */
309
+ clip?: EngineClip;
310
+ }
311
+
312
+ /**
313
+ * A bounded batch of page misses emitted by the AudioWorklet. The main thread
314
+ * resolves these from OPFS and responds with `syncClipPage`; a missing page is
315
+ * silent until that response arrives.
316
+ */
317
+ export interface SonareEngineClipPageRequestMessage {
318
+ type: 'clipPageRequest';
319
+ requests: Array<{ clipId: number; pageIndex: number }>;
320
+ /** Number of misses dropped because a bounded native or SAB request queue was full. */
321
+ dropped?: number;
258
322
  }
259
323
 
260
324
  export interface SonareEngineSyncMidiClipsMessage {
@@ -278,6 +342,12 @@ export interface SonareEngineSyncAutomationMessage {
278
342
  points: EngineAutomationPoint[];
279
343
  }
280
344
 
345
+ /** Replaces the live engine's registered custom-parameter set. */
346
+ export interface SonareEngineSyncParametersMessage {
347
+ type: 'syncParameters';
348
+ parameters: EngineParameterInfo[];
349
+ }
350
+
281
351
  export interface SonareEngineSyncTempoMessage {
282
352
  type: 'syncTempo';
283
353
  bpm: number;
@@ -403,7 +473,12 @@ export interface SonareEngineSyncSynthInstrumentMessage {
403
473
  export interface SonareEngineSyncSf2InstrumentMessage {
404
474
  type: 'syncSf2Instrument';
405
475
  destinationId: number;
406
- config: { destinationId?: number; gain?: number; polyphony?: number };
476
+ config: {
477
+ destinationId?: number;
478
+ gain?: number;
479
+ polyphony?: number;
480
+ preferModelForModeledFamilies?: boolean;
481
+ };
407
482
  }
408
483
 
409
484
  export interface SonareEngineSyncLoadSoundFontMessage {
@@ -438,6 +513,13 @@ export interface SonareEngineSyncMidiCcMessage {
438
513
  renderFrame: number;
439
514
  }
440
515
 
516
+ export interface SonareEngineSyncMidiUmpMessage {
517
+ type: 'syncMidiUmp';
518
+ destinationId: number;
519
+ word0: number;
520
+ renderFrame: number;
521
+ }
522
+
441
523
  export interface SonareEngineSyncMidiSysexMessage {
442
524
  type: 'syncMidiSysex';
443
525
  destinationId: number;
@@ -461,6 +543,34 @@ export interface SonareEngineSyncExternalMidiClockMessage {
461
543
  enabled: boolean;
462
544
  }
463
545
 
546
+ export interface SonareEngineSyncMidiInputSourceMessage {
547
+ type: 'syncMidiInputSource' | 'syncClearMidiInputSource';
548
+ destinationId?: number;
549
+ }
550
+
551
+ export interface SonareEngineSyncMidiCcBindingMessage {
552
+ type: 'syncMidiCcBinding';
553
+ channel: number;
554
+ controller: number;
555
+ paramId: number;
556
+ minValue: number;
557
+ maxValue: number;
558
+ }
559
+
560
+ export interface SonareEngineSyncMidiInputEventMessage {
561
+ type: 'syncMidiInputNoteOn' | 'syncMidiInputNoteOff' | 'syncMidiInputCc';
562
+ group: number;
563
+ channel: number;
564
+ data0: number;
565
+ data1: number;
566
+ portTimeSamples: number;
567
+ }
568
+
569
+ /** Releases the realtime engine and all worklet-owned clip buffers. */
570
+ export interface SonareEngineDestroyMessage {
571
+ type: 'destroy';
572
+ }
573
+
464
574
  export type SonareEngineInstrumentSyncMessage =
465
575
  | SonareEngineSyncBuiltinInstrumentMessage
466
576
  | SonareEngineSyncSynthInstrumentMessage
@@ -473,11 +583,14 @@ export type SonareEngineSyncMessage =
473
583
  | SonareEngineSyncClipsDeltaMessage
474
584
  | SonareEngineSyncClipPageProviderMessage
475
585
  | SonareEngineSyncClipPageMessage
586
+ | SonareEngineSyncClipPageClearMessage
476
587
  | SonareEngineSyncClipPageCommitMessage
588
+ | SonareEngineSyncClipPageDestroyMessage
477
589
  | SonareEngineSyncMidiClipsMessage
478
590
  | SonareEngineSyncMarkersMessage
479
591
  | SonareEngineSyncMetronomeMessage
480
592
  | SonareEngineSyncAutomationMessage
593
+ | SonareEngineSyncParametersMessage
481
594
  | SonareEngineSyncTempoMessage
482
595
  | SonareEngineSyncMixerMessage
483
596
  | SonareEngineSyncCaptureMessage
@@ -500,10 +613,15 @@ export type SonareEngineSyncMessage =
500
613
  | SonareEngineSyncMidiFxMessage
501
614
  | SonareEngineSyncMidiNoteMessage
502
615
  | SonareEngineSyncMidiCcMessage
616
+ | SonareEngineSyncMidiUmpMessage
503
617
  | SonareEngineSyncMidiSysexMessage
504
618
  | SonareEngineSyncMidiPanicMessage
505
619
  | SonareEngineSyncMidiDestinationExternalMessage
506
- | SonareEngineSyncExternalMidiClockMessage;
620
+ | SonareEngineSyncExternalMidiClockMessage
621
+ | SonareEngineSyncMidiInputSourceMessage
622
+ | SonareEngineSyncMidiCcBindingMessage
623
+ | SonareEngineSyncMidiInputEventMessage
624
+ | SonareEngineDestroyMessage;
507
625
 
508
626
  export interface WorkletPort {
509
627
  postMessage?: (message: unknown, transfer?: Transferable[]) => void;
@@ -77,6 +77,17 @@ export function decodeFrame(lo: number, hi: number): number {
77
77
  export const SONARE_ENGINE_RING_HEADER_INTS = 5;
78
78
  export const SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
79
79
  export const SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
80
+ // Clip-page request ring header: [writeIndex, readIndex, capacity,
81
+ // recordUint32s, dropped]. Records are [clipId, pageIndex]. This is an SPSC
82
+ // queue: the AudioWorklet is the sole writer and the main thread is the sole
83
+ // reader. Unlike the telemetry rings, it must not overwrite unread requests:
84
+ // the producer increments `dropped` when the fixed queue is full.
85
+ export const SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS = 5;
86
+ export const SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S = 2;
87
+ // External MIDI records: destination ID, render-frame offset, packed MIDI-1
88
+ // bytes (little-endian), and byte count. SPSC worklet -> main-thread ring.
89
+ export const SONARE_EXTERNAL_MIDI_RING_HEADER_INTS = 5;
90
+ export const SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S = 4;
80
91
 
81
92
  export enum SonareEngineCommandType {
82
93
  SetParam = 0,
@@ -124,6 +135,7 @@ export enum SonareEngineTelemetryError {
124
135
  InsertAutomationOverflow = 16,
125
136
  MidiClockOverflow = 17,
126
137
  MetronomeOverflow = 18,
138
+ InvalidCommand = 19,
127
139
  }
128
140
 
129
141
  export interface SonareMeterRingBuffer {
@@ -182,6 +194,31 @@ export interface SonareScopeRingReadResult {
182
194
  scopes: SonareWorkletScopeSnapshot[];
183
195
  }
184
196
 
197
+ export interface SonareExternalMidiRingEvent {
198
+ destinationId: number;
199
+ renderFrame: number;
200
+ byteWord: number;
201
+ byteCount: number;
202
+ }
203
+
204
+ export interface SonareExternalMidiRingBuffer {
205
+ sharedBuffer: SharedArrayBuffer;
206
+ header: Int32Array;
207
+ records: Uint32Array;
208
+ capacity: number;
209
+ }
210
+
211
+ export interface SharedExternalMidiRingWriter {
212
+ header: Int32Array;
213
+ records: Uint32Array;
214
+ capacity: number;
215
+ }
216
+
217
+ export interface SonareExternalMidiRingReadResult {
218
+ events: SonareExternalMidiRingEvent[];
219
+ dropped: number;
220
+ }
221
+
185
222
  export interface SharedScopeRingWriter {
186
223
  header: Int32Array;
187
224
  records: Float32Array;
@@ -228,6 +265,30 @@ export interface SonareEngineTelemetryRingReadResult {
228
265
  telemetry: SonareEngineTelemetryRecord[];
229
266
  }
230
267
 
268
+ export interface SonareClipPageRequest {
269
+ clipId: number;
270
+ pageIndex: number;
271
+ }
272
+
273
+ export interface SonareClipPageRequestRingBuffer {
274
+ sharedBuffer: SharedArrayBuffer;
275
+ header: Int32Array;
276
+ records: Uint32Array;
277
+ capacity: number;
278
+ }
279
+
280
+ export interface SharedClipPageRequestRingWriter {
281
+ header: Int32Array;
282
+ records: Uint32Array;
283
+ capacity: number;
284
+ }
285
+
286
+ export interface SonareClipPageRequestRingReadResult {
287
+ requests: SonareClipPageRequest[];
288
+ /** Cumulative uint32 drop count for native and SAB-ring overflows. */
289
+ dropped: number;
290
+ }
291
+
231
292
  export interface SharedMeterRingWriter {
232
293
  header: Int32Array;
233
294
  records: Float32Array;
@@ -497,6 +558,190 @@ export function sonareEngineTelemetryRingBufferByteLength(capacity: number): num
497
558
  );
498
559
  }
499
560
 
561
+ export function sonareClipPageRequestRingBufferByteLength(capacity: number): number {
562
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
563
+ return (
564
+ SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
565
+ clampedCapacity * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S * Uint32Array.BYTES_PER_ELEMENT
566
+ );
567
+ }
568
+
569
+ export function sonareExternalMidiRingBufferByteLength(capacity: number): number {
570
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
571
+ return (
572
+ SONARE_EXTERNAL_MIDI_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
573
+ clampedCapacity * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S * Uint32Array.BYTES_PER_ELEMENT
574
+ );
575
+ }
576
+
577
+ export function externalMidiRingFromSharedBuffer(
578
+ sharedBuffer: SharedArrayBuffer,
579
+ fallbackCapacity?: number,
580
+ ): SharedExternalMidiRingWriter {
581
+ const headerBytes = SONARE_EXTERNAL_MIDI_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
582
+ const header = new Int32Array(sharedBuffer, 0, SONARE_EXTERNAL_MIDI_RING_HEADER_INTS);
583
+ const existingCapacity = Atomics.load(header, 2);
584
+ const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
585
+ if (sharedBuffer.byteLength < sonareExternalMidiRingBufferByteLength(capacity)) {
586
+ throw new Error('externalMidiSharedBuffer is too small for the requested ring capacity.');
587
+ }
588
+ Atomics.store(header, 2, capacity);
589
+ Atomics.store(header, 3, SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S);
590
+ return {
591
+ header,
592
+ records: new Uint32Array(
593
+ sharedBuffer,
594
+ headerBytes,
595
+ capacity * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S,
596
+ ),
597
+ capacity,
598
+ };
599
+ }
600
+
601
+ export function createSonareExternalMidiRingBuffer(capacity = 256): SonareExternalMidiRingBuffer {
602
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
603
+ const sharedBuffer = new SharedArrayBuffer(
604
+ sonareExternalMidiRingBufferByteLength(clampedCapacity),
605
+ );
606
+ const ring = externalMidiRingFromSharedBuffer(sharedBuffer, clampedCapacity);
607
+ Atomics.store(ring.header, 0, 0);
608
+ Atomics.store(ring.header, 1, 0);
609
+ Atomics.store(ring.header, 4, 0);
610
+ return { sharedBuffer, header: ring.header, records: ring.records, capacity: ring.capacity };
611
+ }
612
+
613
+ /** Write one lowered MIDI-1 event without allocations from the audio worklet. */
614
+ export function pushSonareExternalMidiRingBuffer(
615
+ ring: SharedExternalMidiRingWriter,
616
+ destinationId: number,
617
+ renderFrame: number,
618
+ byteWord: number,
619
+ byteCount: number,
620
+ ): boolean {
621
+ // Emscripten can expose an int64 render-frame scalar as bigint when the
622
+ // host enables WASM_BIGINT. Normalize at this fixed-record boundary.
623
+ const destination = Number(destinationId);
624
+ const frame = Number(renderFrame);
625
+ const packedBytes = Number(byteWord);
626
+ const count = Number(byteCount);
627
+ if (
628
+ !Number.isSafeInteger(destination) ||
629
+ !Number.isSafeInteger(frame) ||
630
+ frame < 0 ||
631
+ !Number.isSafeInteger(packedBytes) ||
632
+ packedBytes < 0 ||
633
+ packedBytes > 0xff_ffff ||
634
+ !Number.isSafeInteger(count) ||
635
+ count < 1 ||
636
+ count > 3
637
+ ) {
638
+ Atomics.add(ring.header, 4, 1);
639
+ return false;
640
+ }
641
+ const writeIndex = Atomics.load(ring.header, 0);
642
+ const readIndex = Atomics.load(ring.header, 1);
643
+ if (writeIndex - readIndex >= ring.capacity) {
644
+ Atomics.add(ring.header, 4, 1);
645
+ return false;
646
+ }
647
+ const offset = (writeIndex % ring.capacity) * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S;
648
+ Atomics.store(ring.records, offset, destination);
649
+ Atomics.store(ring.records, offset + 1, frame);
650
+ Atomics.store(ring.records, offset + 2, packedBytes);
651
+ Atomics.store(ring.records, offset + 3, count);
652
+ Atomics.store(ring.header, 0, writeIndex + 1);
653
+ return true;
654
+ }
655
+
656
+ /** Main-thread drain. It is intentionally the only place MIDI byte arrays are built. */
657
+ export function readSonareExternalMidiRingBuffer(
658
+ ring: SonareExternalMidiRingBuffer,
659
+ ): SonareExternalMidiRingReadResult {
660
+ const readIndex = Atomics.load(ring.header, 1);
661
+ const writeIndex = Atomics.load(ring.header, 0);
662
+ const events: SonareExternalMidiRingEvent[] = [];
663
+ for (let index = readIndex; index < writeIndex; index++) {
664
+ const offset = (index % ring.capacity) * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S;
665
+ events.push({
666
+ destinationId: Atomics.load(ring.records, offset) >>> 0,
667
+ renderFrame: Atomics.load(ring.records, offset + 1) >>> 0,
668
+ byteWord: Atomics.load(ring.records, offset + 2) >>> 0,
669
+ byteCount: Atomics.load(ring.records, offset + 3) >>> 0,
670
+ });
671
+ }
672
+ Atomics.store(ring.header, 1, writeIndex);
673
+ return { events, dropped: Atomics.load(ring.header, 4) >>> 0 };
674
+ }
675
+
676
+ export function createSonareClipPageRequestRingBuffer(
677
+ capacity = 128,
678
+ ): SonareClipPageRequestRingBuffer {
679
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
680
+ const sharedBuffer = new SharedArrayBuffer(
681
+ sonareClipPageRequestRingBufferByteLength(clampedCapacity),
682
+ );
683
+ const ring = clipPageRequestRingFromSharedBuffer(sharedBuffer, clampedCapacity);
684
+ Atomics.store(ring.header, 0, 0);
685
+ Atomics.store(ring.header, 1, 0);
686
+ Atomics.store(ring.header, 2, ring.capacity);
687
+ Atomics.store(ring.header, 3, SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S);
688
+ Atomics.store(ring.header, 4, 0);
689
+ return { sharedBuffer, header: ring.header, records: ring.records, capacity: ring.capacity };
690
+ }
691
+
692
+ /**
693
+ * Writes one request without allocating. Invalid uint32 coordinates and a full
694
+ * ring are counted as drops so the main thread can surface a bounded-loss
695
+ * diagnostic without receiving a worklet postMessage.
696
+ */
697
+ export function pushSonareClipPageRequestRingBuffer(
698
+ ring: SharedClipPageRequestRingWriter,
699
+ clipId: number,
700
+ pageIndex: number,
701
+ ): boolean {
702
+ if (
703
+ !Number.isSafeInteger(clipId) ||
704
+ clipId < 0 ||
705
+ clipId > 0xffff_ffff ||
706
+ !Number.isSafeInteger(pageIndex) ||
707
+ pageIndex < 0 ||
708
+ pageIndex > 0xffff_ffff
709
+ ) {
710
+ Atomics.add(ring.header, 4, 1);
711
+ return false;
712
+ }
713
+ const writeIndex = Atomics.load(ring.header, 0);
714
+ const readIndex = Atomics.load(ring.header, 1);
715
+ if (writeIndex - readIndex >= ring.capacity) {
716
+ Atomics.add(ring.header, 4, 1);
717
+ return false;
718
+ }
719
+ const offset = (writeIndex % ring.capacity) * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S;
720
+ Atomics.store(ring.records, offset, clipId);
721
+ Atomics.store(ring.records, offset + 1, pageIndex);
722
+ // Publish only after both scalar fields have been written.
723
+ Atomics.store(ring.header, 0, writeIndex + 1);
724
+ return true;
725
+ }
726
+
727
+ /** Drains the SPSC request ring on the main thread. Object creation is here, never in process(). */
728
+ export function readSonareClipPageRequestRingBuffer(
729
+ ring: SonareClipPageRequestRingBuffer,
730
+ ): SonareClipPageRequestRingReadResult {
731
+ const readIndex = Atomics.load(ring.header, 1);
732
+ const writeIndex = Atomics.load(ring.header, 0);
733
+ const requests: SonareClipPageRequest[] = [];
734
+ for (let index = readIndex; index < writeIndex; index++) {
735
+ const offset = (index % ring.capacity) * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S;
736
+ requests.push({
737
+ clipId: Atomics.load(ring.records, offset),
738
+ pageIndex: Atomics.load(ring.records, offset + 1),
739
+ });
740
+ }
741
+ Atomics.store(ring.header, 1, writeIndex);
742
+ return { requests, dropped: Atomics.load(ring.header, 4) >>> 0 };
743
+ }
744
+
500
745
  export function createSonareEngineCommandRingBuffer(capacity = 128): SonareEngineCommandRingBuffer {
501
746
  const clampedCapacity = Math.max(1, Math.floor(capacity));
502
747
  const sharedBuffer = new SharedArrayBuffer(
@@ -670,18 +915,56 @@ export function engineRingFromSharedBuffer(
670
915
  };
671
916
  }
672
917
 
918
+ export function clipPageRequestRingFromSharedBuffer(
919
+ sharedBuffer: SharedArrayBuffer,
920
+ fallbackCapacity?: number,
921
+ ): SharedClipPageRequestRingWriter {
922
+ const headerBytes = SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
923
+ const header = new Int32Array(sharedBuffer, 0, SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS);
924
+ const existingCapacity = Atomics.load(header, 2);
925
+ const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
926
+ const minBytes = sonareClipPageRequestRingBufferByteLength(capacity);
927
+ if (sharedBuffer.byteLength < minBytes) {
928
+ throw new Error('clipPageRequestSharedBuffer is too small for the requested ring capacity.');
929
+ }
930
+ Atomics.store(header, 2, capacity);
931
+ Atomics.store(header, 3, SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S);
932
+ return {
933
+ header,
934
+ records: new Uint32Array(
935
+ sharedBuffer,
936
+ headerBytes,
937
+ capacity * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S,
938
+ ),
939
+ capacity,
940
+ };
941
+ }
942
+
673
943
  function recordOffset(index: number, capacity: number, recordBytes: number): number {
674
944
  return (index % capacity) * recordBytes;
675
945
  }
676
946
 
677
- export function toBigInt64(value: number | bigint | undefined, fallback: bigint): bigint {
678
- if (typeof value === 'bigint') {
679
- return value;
947
+ function toSafeInteger(value: number | bigint | undefined, fallback: number): number {
948
+ const resolved = typeof value === 'bigint' ? Number(value) : value;
949
+ if (resolved === undefined) {
950
+ return fallback;
680
951
  }
681
- if (typeof value === 'number') {
682
- return BigInt(Math.trunc(value));
952
+ if (!Number.isSafeInteger(resolved)) {
953
+ throw new RangeError('64-bit ring values must be safe integers');
683
954
  }
684
- return fallback;
955
+ return resolved;
956
+ }
957
+
958
+ /** Store a signed 64-bit safe integer as two 32-bit words without BigInt. */
959
+ export function writeInt64Words(view: DataView, offset: number, value: number): void {
960
+ const integer = toSafeInteger(value, 0);
961
+ view.setUint32(offset, integer >>> 0, true);
962
+ view.setInt32(offset + 4, Math.floor(integer / 0x100000000), true);
963
+ }
964
+
965
+ /** Reconstruct a signed 64-bit safe integer stored by {@link writeInt64Words}. */
966
+ export function readInt64Words(view: DataView, offset: number): number {
967
+ return view.getInt32(offset + 4, true) * 0x100000000 + view.getUint32(offset, true);
685
968
  }
686
969
 
687
970
  function writeEngineCommandRecord(
@@ -691,21 +974,21 @@ function writeEngineCommandRecord(
691
974
  ): void {
692
975
  view.setUint32(offset, command.type, true);
693
976
  view.setUint32(offset + 4, command.targetId ?? 0, true);
694
- view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
977
+ writeInt64Words(view, offset + 8, toSafeInteger(command.sampleTime, -1));
695
978
  // argFloat occupies a full 8-byte Float64 slot (replacing the old Float32 +
696
979
  // 4-byte pad) so PPQ scalars carried here keep full double precision over the
697
980
  // SAB transport, matching the engine's double-precision seek/loop contract.
698
981
  view.setFloat64(offset + 16, command.argFloat ?? 0, true);
699
- view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
982
+ writeInt64Words(view, offset + 24, toSafeInteger(command.argInt, 0));
700
983
  }
701
984
 
702
985
  function readEngineCommandRecord(view: DataView, offset: number): SonareEngineCommandRecord {
703
986
  return {
704
987
  type: view.getUint32(offset, true),
705
988
  targetId: view.getUint32(offset + 4, true),
706
- sampleTime: Number(view.getBigInt64(offset + 8, true)),
989
+ sampleTime: readInt64Words(view, offset + 8),
707
990
  argFloat: view.getFloat64(offset + 16, true),
708
- argInt: Number(view.getBigInt64(offset + 24, true)),
991
+ argInt: readInt64Words(view, offset + 24),
709
992
  };
710
993
  }
711
994
 
@@ -716,21 +999,22 @@ function writeEngineTelemetryRecord(
716
999
  ): void {
717
1000
  view.setUint32(offset, telemetry.type, true);
718
1001
  view.setUint32(offset + 4, telemetry.error, true);
719
- view.setBigInt64(offset + 8, BigInt(Math.trunc(telemetry.renderFrame)), true);
720
- view.setBigInt64(offset + 16, BigInt(Math.trunc(telemetry.timelineSample)), true);
721
- view.setBigInt64(offset + 24, BigInt(Math.trunc(telemetry.audibleTimelineSample)), true);
1002
+ writeInt64Words(view, offset + 8, telemetry.renderFrame);
1003
+ writeInt64Words(view, offset + 16, telemetry.timelineSample);
1004
+ writeInt64Words(view, offset + 24, telemetry.audibleTimelineSample);
722
1005
  view.setInt32(offset + 32, telemetry.graphLatencySamplesQ8, true);
723
1006
  view.setUint32(offset + 36, telemetry.value, true);
724
- view.setBigInt64(offset + 40, 0n, true);
1007
+ view.setUint32(offset + 40, 0, true);
1008
+ view.setUint32(offset + 44, 0, true);
725
1009
  }
726
1010
 
727
1011
  function readEngineTelemetryRecord(view: DataView, offset: number): SonareEngineTelemetryRecord {
728
1012
  return {
729
1013
  type: view.getUint32(offset, true),
730
1014
  error: view.getUint32(offset + 4, true),
731
- renderFrame: Number(view.getBigInt64(offset + 8, true)),
732
- timelineSample: Number(view.getBigInt64(offset + 16, true)),
733
- audibleTimelineSample: Number(view.getBigInt64(offset + 24, true)),
1015
+ renderFrame: readInt64Words(view, offset + 8),
1016
+ timelineSample: readInt64Words(view, offset + 16),
1017
+ audibleTimelineSample: readInt64Words(view, offset + 24),
734
1018
  graphLatencySamplesQ8: view.getInt32(offset + 32, true),
735
1019
  value: view.getUint32(offset + 36, true),
736
1020
  };