@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
@@ -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
@@ -191,23 +218,42 @@ export interface ResolvedMetronomeConfig {
191
218
  export const DEFAULT_METRONOME_CONFIG: ResolvedMetronomeConfig = {
192
219
  beatGain: 0.35,
193
220
  accentGain: 0.7,
194
- clickSamples: 96,
221
+ clickSamples: 0,
222
+ clickSeconds: 0,
195
223
  };
196
224
 
197
225
  export function resolveMetronomeConfig(config: EngineMetronomeConfig): ResolvedMetronomeConfig {
198
- return {
226
+ const resolved = {
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
  };
232
+ if (
233
+ !Number.isFinite(resolved.beatGain) ||
234
+ resolved.beatGain < 0 ||
235
+ !Number.isFinite(resolved.accentGain) ||
236
+ resolved.accentGain < 0 ||
237
+ !Number.isInteger(resolved.clickSamples) ||
238
+ resolved.clickSamples < 0 ||
239
+ !Number.isFinite(resolved.clickSeconds) ||
240
+ resolved.clickSeconds < 0 ||
241
+ resolved.clickSamples > 384000 ||
242
+ (config.clickSeconds !== undefined &&
243
+ (!Number.isFinite(config.clickSeconds) || config.clickSeconds < 0 || config.clickSeconds > 1))
244
+ ) {
245
+ throw new RangeError('invalid metronome gains or click length');
246
+ }
247
+ return resolved;
203
248
  }
204
249
 
205
250
  // Out-of-band control messages posted from the main-thread SonareEngine facade
206
251
  // to the worklet engine processor over node.port. Unlike SonareEngineCommandRecord
207
252
  // (a small POD POSTed/ringed every block) these carry bulk/structured payloads
208
253
  // (clip audio buffers, marker lists, metronome config) that cannot fit the
209
- // fixed-size SAB command record, so they are applied OUTSIDE process() — the
210
- // 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.
211
257
  export interface SonareEngineSyncClipsMessage {
212
258
  type: 'syncClips';
213
259
  clips: EngineClip[];
@@ -223,7 +269,8 @@ export interface SonareEngineSyncClipsDeltaMessage {
223
269
  export interface SonareEngineSyncClipPageProviderMessage {
224
270
  type: 'syncClipPageProvider';
225
271
  clipId: number;
226
- clip: EngineClip;
272
+ /** Omitted when an OPFS stream is primed before its clip is scheduled. */
273
+ clip?: EngineClip;
227
274
  numChannels: number;
228
275
  numSamples: number;
229
276
  pageFrames: number;
@@ -237,10 +284,41 @@ export interface SonareEngineSyncClipPageMessage {
237
284
  channels: Float32Array[];
238
285
  }
239
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
+
240
300
  /** Makes a fully supplied paged clip visible to the audio engine. */
241
301
  export interface SonareEngineSyncClipPageCommitMessage {
242
302
  type: 'syncClipPageCommit';
243
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;
244
322
  }
245
323
 
246
324
  export interface SonareEngineSyncMidiClipsMessage {
@@ -264,6 +342,12 @@ export interface SonareEngineSyncAutomationMessage {
264
342
  points: EngineAutomationPoint[];
265
343
  }
266
344
 
345
+ /** Replaces the live engine's registered custom-parameter set. */
346
+ export interface SonareEngineSyncParametersMessage {
347
+ type: 'syncParameters';
348
+ parameters: EngineParameterInfo[];
349
+ }
350
+
267
351
  export interface SonareEngineSyncTempoMessage {
268
352
  type: 'syncTempo';
269
353
  bpm: number;
@@ -389,7 +473,12 @@ export interface SonareEngineSyncSynthInstrumentMessage {
389
473
  export interface SonareEngineSyncSf2InstrumentMessage {
390
474
  type: 'syncSf2Instrument';
391
475
  destinationId: number;
392
- config: { destinationId?: number; gain?: number; polyphony?: number };
476
+ config: {
477
+ destinationId?: number;
478
+ gain?: number;
479
+ polyphony?: number;
480
+ preferModelForModeledFamilies?: boolean;
481
+ };
393
482
  }
394
483
 
395
484
  export interface SonareEngineSyncLoadSoundFontMessage {
@@ -424,6 +513,13 @@ export interface SonareEngineSyncMidiCcMessage {
424
513
  renderFrame: number;
425
514
  }
426
515
 
516
+ export interface SonareEngineSyncMidiUmpMessage {
517
+ type: 'syncMidiUmp';
518
+ destinationId: number;
519
+ word0: number;
520
+ renderFrame: number;
521
+ }
522
+
427
523
  export interface SonareEngineSyncMidiSysexMessage {
428
524
  type: 'syncMidiSysex';
429
525
  destinationId: number;
@@ -447,6 +543,34 @@ export interface SonareEngineSyncExternalMidiClockMessage {
447
543
  enabled: boolean;
448
544
  }
449
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
+
450
574
  export type SonareEngineInstrumentSyncMessage =
451
575
  | SonareEngineSyncBuiltinInstrumentMessage
452
576
  | SonareEngineSyncSynthInstrumentMessage
@@ -459,11 +583,14 @@ export type SonareEngineSyncMessage =
459
583
  | SonareEngineSyncClipsDeltaMessage
460
584
  | SonareEngineSyncClipPageProviderMessage
461
585
  | SonareEngineSyncClipPageMessage
586
+ | SonareEngineSyncClipPageClearMessage
462
587
  | SonareEngineSyncClipPageCommitMessage
588
+ | SonareEngineSyncClipPageDestroyMessage
463
589
  | SonareEngineSyncMidiClipsMessage
464
590
  | SonareEngineSyncMarkersMessage
465
591
  | SonareEngineSyncMetronomeMessage
466
592
  | SonareEngineSyncAutomationMessage
593
+ | SonareEngineSyncParametersMessage
467
594
  | SonareEngineSyncTempoMessage
468
595
  | SonareEngineSyncMixerMessage
469
596
  | SonareEngineSyncCaptureMessage
@@ -486,10 +613,15 @@ export type SonareEngineSyncMessage =
486
613
  | SonareEngineSyncMidiFxMessage
487
614
  | SonareEngineSyncMidiNoteMessage
488
615
  | SonareEngineSyncMidiCcMessage
616
+ | SonareEngineSyncMidiUmpMessage
489
617
  | SonareEngineSyncMidiSysexMessage
490
618
  | SonareEngineSyncMidiPanicMessage
491
619
  | SonareEngineSyncMidiDestinationExternalMessage
492
- | SonareEngineSyncExternalMidiClockMessage;
620
+ | SonareEngineSyncExternalMidiClockMessage
621
+ | SonareEngineSyncMidiInputSourceMessage
622
+ | SonareEngineSyncMidiCcBindingMessage
623
+ | SonareEngineSyncMidiInputEventMessage
624
+ | SonareEngineDestroyMessage;
493
625
 
494
626
  export interface WorkletPort {
495
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,
@@ -122,6 +133,9 @@ export enum SonareEngineTelemetryError {
122
133
  CommandBacklogDeferred = 14,
123
134
  ClipPageUnderrun = 15,
124
135
  InsertAutomationOverflow = 16,
136
+ MidiClockOverflow = 17,
137
+ MetronomeOverflow = 18,
138
+ InvalidCommand = 19,
125
139
  }
126
140
 
127
141
  export interface SonareMeterRingBuffer {
@@ -180,6 +194,31 @@ export interface SonareScopeRingReadResult {
180
194
  scopes: SonareWorkletScopeSnapshot[];
181
195
  }
182
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
+
183
222
  export interface SharedScopeRingWriter {
184
223
  header: Int32Array;
185
224
  records: Float32Array;
@@ -226,6 +265,30 @@ export interface SonareEngineTelemetryRingReadResult {
226
265
  telemetry: SonareEngineTelemetryRecord[];
227
266
  }
228
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
+
229
292
  export interface SharedMeterRingWriter {
230
293
  header: Int32Array;
231
294
  records: Float32Array;
@@ -495,6 +558,190 @@ export function sonareEngineTelemetryRingBufferByteLength(capacity: number): num
495
558
  );
496
559
  }
497
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
+
498
745
  export function createSonareEngineCommandRingBuffer(capacity = 128): SonareEngineCommandRingBuffer {
499
746
  const clampedCapacity = Math.max(1, Math.floor(capacity));
500
747
  const sharedBuffer = new SharedArrayBuffer(
@@ -668,18 +915,56 @@ export function engineRingFromSharedBuffer(
668
915
  };
669
916
  }
670
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
+
671
943
  function recordOffset(index: number, capacity: number, recordBytes: number): number {
672
944
  return (index % capacity) * recordBytes;
673
945
  }
674
946
 
675
- export function toBigInt64(value: number | bigint | undefined, fallback: bigint): bigint {
676
- if (typeof value === 'bigint') {
677
- 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;
678
951
  }
679
- if (typeof value === 'number') {
680
- return BigInt(Math.trunc(value));
952
+ if (!Number.isSafeInteger(resolved)) {
953
+ throw new RangeError('64-bit ring values must be safe integers');
681
954
  }
682
- 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);
683
968
  }
684
969
 
685
970
  function writeEngineCommandRecord(
@@ -689,21 +974,21 @@ function writeEngineCommandRecord(
689
974
  ): void {
690
975
  view.setUint32(offset, command.type, true);
691
976
  view.setUint32(offset + 4, command.targetId ?? 0, true);
692
- view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
977
+ writeInt64Words(view, offset + 8, toSafeInteger(command.sampleTime, -1));
693
978
  // argFloat occupies a full 8-byte Float64 slot (replacing the old Float32 +
694
979
  // 4-byte pad) so PPQ scalars carried here keep full double precision over the
695
980
  // SAB transport, matching the engine's double-precision seek/loop contract.
696
981
  view.setFloat64(offset + 16, command.argFloat ?? 0, true);
697
- view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
982
+ writeInt64Words(view, offset + 24, toSafeInteger(command.argInt, 0));
698
983
  }
699
984
 
700
985
  function readEngineCommandRecord(view: DataView, offset: number): SonareEngineCommandRecord {
701
986
  return {
702
987
  type: view.getUint32(offset, true),
703
988
  targetId: view.getUint32(offset + 4, true),
704
- sampleTime: Number(view.getBigInt64(offset + 8, true)),
989
+ sampleTime: readInt64Words(view, offset + 8),
705
990
  argFloat: view.getFloat64(offset + 16, true),
706
- argInt: Number(view.getBigInt64(offset + 24, true)),
991
+ argInt: readInt64Words(view, offset + 24),
707
992
  };
708
993
  }
709
994
 
@@ -714,21 +999,22 @@ function writeEngineTelemetryRecord(
714
999
  ): void {
715
1000
  view.setUint32(offset, telemetry.type, true);
716
1001
  view.setUint32(offset + 4, telemetry.error, true);
717
- view.setBigInt64(offset + 8, BigInt(Math.trunc(telemetry.renderFrame)), true);
718
- view.setBigInt64(offset + 16, BigInt(Math.trunc(telemetry.timelineSample)), true);
719
- 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);
720
1005
  view.setInt32(offset + 32, telemetry.graphLatencySamplesQ8, true);
721
1006
  view.setUint32(offset + 36, telemetry.value, true);
722
- view.setBigInt64(offset + 40, 0n, true);
1007
+ view.setUint32(offset + 40, 0, true);
1008
+ view.setUint32(offset + 44, 0, true);
723
1009
  }
724
1010
 
725
1011
  function readEngineTelemetryRecord(view: DataView, offset: number): SonareEngineTelemetryRecord {
726
1012
  return {
727
1013
  type: view.getUint32(offset, true),
728
1014
  error: view.getUint32(offset + 4, true),
729
- renderFrame: Number(view.getBigInt64(offset + 8, true)),
730
- timelineSample: Number(view.getBigInt64(offset + 16, true)),
731
- 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),
732
1018
  graphLatencySamplesQ8: view.getInt32(offset + 32, true),
733
1019
  value: view.getUint32(offset + 36, true),
734
1020
  };