@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
package/dist/worklet.js CHANGED
@@ -1,3 +1,397 @@
1
+ // src/opfs_clip_pages.ts
2
+ var opfsClipPageWorkerSource = `
3
+ const sonareClipPageReadQueues = new Map();
4
+
5
+ function sonareEnqueueClipPageRead(key, task) {
6
+ const previous = sonareClipPageReadQueues.get(key) || Promise.resolve();
7
+ const next = previous.catch(() => undefined).then(task);
8
+ const queued = next.finally(() => {
9
+ if (sonareClipPageReadQueues.get(key) === queued) {
10
+ sonareClipPageReadQueues.delete(key);
11
+ }
12
+ });
13
+ sonareClipPageReadQueues.set(key, queued);
14
+ return next;
15
+ }
16
+
17
+ self.onmessage = async (event) => {
18
+ const message = event.data;
19
+ if (!message || message.type !== 'sonare:read-clip-page') return;
20
+ const { requestId, path, pageIndex, numChannels, numSamples, pageFrames, dataOffsetBytes = 0 } = message;
21
+ await sonareEnqueueClipPageRead(String(path), async () => {
22
+ try {
23
+ if (pageIndex < 0) {
24
+ self.postMessage({ type: 'sonare:clip-page', requestId, pageIndex, ok: false });
25
+ return;
26
+ }
27
+ const startFrame = pageIndex * pageFrames;
28
+ if (startFrame >= numSamples) {
29
+ self.postMessage({ type: 'sonare:clip-page', requestId, pageIndex, ok: false });
30
+ return;
31
+ }
32
+ const root = await self.navigator.storage.getDirectory();
33
+ let dir = root;
34
+ const parts = String(path).split('/').filter(Boolean);
35
+ for (let i = 0; i < parts.length - 1; ++i) {
36
+ dir = await dir.getDirectoryHandle(parts[i]);
37
+ }
38
+ const fileHandle = await dir.getFileHandle(parts[parts.length - 1]);
39
+ const access = await fileHandle.createSyncAccessHandle();
40
+ try {
41
+ const frames = Math.min(pageFrames, numSamples - startFrame);
42
+ const frameBytes = numChannels * 4;
43
+ const bytes = new Uint8Array(frames * frameBytes);
44
+ let bytesReadTotal = 0;
45
+ const readOffset = dataOffsetBytes + startFrame * frameBytes;
46
+ while (bytesReadTotal < bytes.byteLength) {
47
+ const bytesRead = access.read(bytes.subarray(bytesReadTotal), {
48
+ at: readOffset + bytesReadTotal,
49
+ });
50
+ if (bytesRead <= 0) {
51
+ break;
52
+ }
53
+ bytesReadTotal += bytesRead;
54
+ }
55
+ if (bytesReadTotal !== bytes.byteLength || bytesReadTotal % frameBytes !== 0) {
56
+ self.postMessage({ type: 'sonare:clip-page', requestId, pageIndex, ok: false });
57
+ return;
58
+ }
59
+ const framesRead = bytesReadTotal / frameBytes;
60
+ const view = new DataView(bytes.buffer, 0, framesRead * frameBytes);
61
+ const channelBuffers = Array.from({ length: numChannels }, () => new ArrayBuffer(framesRead * 4));
62
+ for (let ch = 0; ch < numChannels; ++ch) {
63
+ const channel = new Float32Array(channelBuffers[ch]);
64
+ for (let frame = 0; frame < framesRead; ++frame) {
65
+ channel[frame] = view.getFloat32((frame * numChannels + ch) * 4, true);
66
+ }
67
+ }
68
+ self.postMessage(
69
+ { type: 'sonare:clip-page', requestId, pageIndex, ok: true, frames: framesRead, channelBuffers },
70
+ channelBuffers,
71
+ );
72
+ } finally {
73
+ access.close();
74
+ }
75
+ } catch (error) {
76
+ self.postMessage({
77
+ type: 'sonare:clip-page',
78
+ requestId,
79
+ pageIndex,
80
+ ok: false,
81
+ error: error instanceof Error ? error.message : String(error),
82
+ });
83
+ }
84
+ });
85
+ };
86
+ `;
87
+ function createOpfsClipPageWorker() {
88
+ const blob = new Blob([opfsClipPageWorkerSource], { type: "text/javascript" });
89
+ const url = URL.createObjectURL(blob);
90
+ try {
91
+ return new Worker(url);
92
+ } finally {
93
+ URL.revokeObjectURL(url);
94
+ }
95
+ }
96
+ function createOpfsClipPageProvider(engine, options) {
97
+ if (options.numChannels <= 0 || options.numSamples <= 0 || options.pageFrames <= 0) {
98
+ throw new Error("numChannels, numSamples, and pageFrames must be positive");
99
+ }
100
+ const provider = engine.createClipPageProvider(
101
+ options.numChannels,
102
+ options.numSamples,
103
+ options.pageFrames
104
+ );
105
+ const worker = options.worker ?? createOpfsClipPageWorker();
106
+ const ownsWorker = options.worker === void 0 || options.terminateWorkerOnClose === true;
107
+ let nextRequestId = 1;
108
+ let closed = false;
109
+ let readQueue = Promise.resolve();
110
+ const pending = /* @__PURE__ */ new Map();
111
+ const onMessage = (event) => {
112
+ const response = event.data;
113
+ if (response?.type !== "sonare:clip-page") {
114
+ return;
115
+ }
116
+ const entry = pending.get(response.requestId);
117
+ if (!entry) {
118
+ return;
119
+ }
120
+ pending.delete(response.requestId);
121
+ if (!response.ok) {
122
+ entry.resolve(false);
123
+ return;
124
+ }
125
+ const channels = response.channels ?? response.channelBuffers?.map(
126
+ (buffer) => new Float32Array(buffer, 0, response.frames ?? buffer.byteLength / 4)
127
+ );
128
+ if (!channels || channels.length === 0) {
129
+ entry.resolve(false);
130
+ return;
131
+ }
132
+ try {
133
+ provider.supply(response.pageIndex, channels);
134
+ options.onPageSupplied?.(response.pageIndex, channels);
135
+ } catch {
136
+ entry.resolve(false);
137
+ return;
138
+ }
139
+ entry.resolve(true);
140
+ };
141
+ worker.addEventListener("message", onMessage);
142
+ const supplyPage = (pageIndex) => {
143
+ if (closed) {
144
+ return Promise.reject(new Error("OpfsClipPageProvider is closed"));
145
+ }
146
+ const requestId = nextRequestId++;
147
+ const promise = new Promise((resolve, reject) => {
148
+ pending.set(requestId, { resolve, reject });
149
+ });
150
+ readQueue = readQueue.catch(() => void 0).then(() => {
151
+ if (closed) {
152
+ const entry = pending.get(requestId);
153
+ pending.delete(requestId);
154
+ entry?.reject(new Error("OpfsClipPageProvider is closed"));
155
+ return;
156
+ }
157
+ worker.postMessage({
158
+ type: "sonare:read-clip-page",
159
+ requestId,
160
+ path: options.path,
161
+ pageIndex,
162
+ numChannels: options.numChannels,
163
+ numSamples: options.numSamples,
164
+ pageFrames: options.pageFrames,
165
+ dataOffsetBytes: options.dataOffsetBytes ?? 0
166
+ });
167
+ return promise.then(
168
+ () => void 0,
169
+ () => void 0
170
+ );
171
+ });
172
+ readQueue.catch(() => {
173
+ });
174
+ return promise;
175
+ };
176
+ return {
177
+ provider,
178
+ supplyPage,
179
+ supplyRequest(request) {
180
+ return supplyPage(Math.floor(request.sample / options.pageFrames));
181
+ },
182
+ clearPage(pageIndex) {
183
+ if (closed) {
184
+ return;
185
+ }
186
+ provider.clear(pageIndex);
187
+ options.onPageCleared?.(pageIndex);
188
+ },
189
+ close() {
190
+ if (closed) {
191
+ return;
192
+ }
193
+ closed = true;
194
+ worker.removeEventListener("message", onMessage);
195
+ for (const entry of pending.values()) {
196
+ entry.reject(new Error("OpfsClipPageProvider is closed"));
197
+ }
198
+ pending.clear();
199
+ provider.destroy();
200
+ options.onClose?.();
201
+ if (ownsWorker) {
202
+ worker.terminate();
203
+ }
204
+ }
205
+ };
206
+ }
207
+
208
+ // src/clip_page_streamer.ts
209
+ var ClipPageStreamer = class {
210
+ constructor(engine, options = {}) {
211
+ this.sources = /* @__PURE__ */ new Map();
212
+ this.closed = false;
213
+ this.engine = engine;
214
+ this.readAheadPages = Math.max(0, Math.floor(options.readAheadPages ?? 2));
215
+ this.retainBehindPages = Math.max(0, Math.floor(options.retainBehindPages ?? 1));
216
+ this.maxRequestsPerPump = Math.max(1, Math.floor(options.maxRequestsPerPump ?? 256));
217
+ }
218
+ /**
219
+ * Register a paged clip. Pages already supplied to the provider before
220
+ * registration (for example a primed first page) should be passed in
221
+ * `initialResidentPages` so they participate in eviction.
222
+ */
223
+ addSource(source, initialResidentPages = []) {
224
+ if (source.pageFrames <= 0 || source.numSamples <= 0) {
225
+ throw new Error("pageFrames and numSamples must be positive");
226
+ }
227
+ const lastPage = Math.ceil(source.numSamples / source.pageFrames) - 1;
228
+ const previous = this.sources.get(source.clipId);
229
+ if (previous) {
230
+ this.resetState(previous);
231
+ }
232
+ this.sources.set(source.clipId, {
233
+ source,
234
+ lastPage,
235
+ generation: 0,
236
+ lastFrontier: null,
237
+ resident: new Map(Array.from(initialResidentPages, (page) => [page, 0]))
238
+ });
239
+ }
240
+ /** Stop tracking a clip. Does not close its binding (the caller owns that). */
241
+ removeSource(clipId) {
242
+ const state = this.sources.get(clipId);
243
+ if (state) {
244
+ this.resetState(state);
245
+ }
246
+ this.sources.delete(clipId);
247
+ }
248
+ /**
249
+ * Explicitly start a new playback generation after a host seek/loop. Resident
250
+ * pages are evicted and any older in-flight fetch is cleared when it settles.
251
+ * The next miss establishes the new bounded window.
252
+ */
253
+ resetSource(clipId) {
254
+ const state = this.sources.get(clipId);
255
+ if (state) {
256
+ this.resetState(state);
257
+ }
258
+ }
259
+ /**
260
+ * Drain pending page-miss requests, fetch the missing pages plus their
261
+ * read-ahead window, and evict out-of-window pages. Resolves once this round's
262
+ * fetches settle. Concurrent fetches are serialized inside each binding.
263
+ */
264
+ async pump() {
265
+ if (this.closed) {
266
+ return;
267
+ }
268
+ const frontiers = /* @__PURE__ */ new Map();
269
+ for (let drained = 0; drained < this.maxRequestsPerPump; ++drained) {
270
+ const request = this.engine.popClipPageRequest();
271
+ if (!request) {
272
+ break;
273
+ }
274
+ const state = this.sources.get(request.clipId);
275
+ if (!state) {
276
+ continue;
277
+ }
278
+ const page = request.pageIndex !== void 0 ? request.pageIndex : Math.floor((request.sample ?? Number.NaN) / state.source.pageFrames);
279
+ if (!Number.isInteger(page) || page < 0 || page > state.lastPage) {
280
+ continue;
281
+ }
282
+ frontiers.set(request.clipId, page);
283
+ }
284
+ const fetches = [];
285
+ for (const [clipId, frontier] of frontiers) {
286
+ const state = this.sources.get(clipId);
287
+ if (!state) {
288
+ continue;
289
+ }
290
+ fetches.push(...this.serviceFrontier(state, frontier));
291
+ }
292
+ await Promise.all(fetches);
293
+ }
294
+ /** Close every registered clip's binding and stop tracking. */
295
+ close() {
296
+ if (this.closed) {
297
+ return;
298
+ }
299
+ this.closed = true;
300
+ for (const state of this.sources.values()) {
301
+ this.resetState(state);
302
+ state.source.binding.close();
303
+ }
304
+ this.sources.clear();
305
+ }
306
+ serviceFrontier(state, frontier) {
307
+ if (state.lastFrontier !== null && frontier < state.lastFrontier) {
308
+ this.resetState(state);
309
+ }
310
+ state.lastFrontier = frontier;
311
+ const generation = state.generation;
312
+ const low = Math.max(0, frontier - this.retainBehindPages);
313
+ const high = Math.min(state.lastPage, frontier + this.readAheadPages);
314
+ for (const page of state.resident.keys()) {
315
+ if (page < low || page > high) {
316
+ this.clearPage(state, page);
317
+ state.resident.delete(page);
318
+ }
319
+ }
320
+ const fetches = [];
321
+ for (let page = low; page <= high; ++page) {
322
+ if (state.resident.get(page) === generation) {
323
+ continue;
324
+ }
325
+ state.resident.set(page, generation);
326
+ const pageIndex = page;
327
+ fetches.push(
328
+ state.source.binding.supplyPage(pageIndex).then(
329
+ (ok) => {
330
+ if (state.generation !== generation) {
331
+ this.clearPage(state, pageIndex);
332
+ } else if (!ok && state.resident.get(pageIndex) === generation) {
333
+ state.resident.delete(pageIndex);
334
+ }
335
+ return ok;
336
+ },
337
+ (error) => {
338
+ if (state.resident.get(pageIndex) === generation) {
339
+ state.resident.delete(pageIndex);
340
+ }
341
+ throw error;
342
+ }
343
+ )
344
+ );
345
+ }
346
+ return fetches;
347
+ }
348
+ resetState(state) {
349
+ state.generation += 1;
350
+ state.lastFrontier = null;
351
+ for (const page of state.resident.keys()) {
352
+ this.clearPage(state, page);
353
+ }
354
+ state.resident.clear();
355
+ }
356
+ clearPage(state, pageIndex) {
357
+ if (state.source.binding.clearPage) {
358
+ state.source.binding.clearPage(pageIndex);
359
+ } else {
360
+ state.source.binding.provider.clear(pageIndex);
361
+ }
362
+ }
363
+ };
364
+ async function attachOpfsClipStream(streamerOrEngine, engineOrOptions, maybeOptions) {
365
+ if (!(streamerOrEngine instanceof ClipPageStreamer)) {
366
+ return streamerOrEngine.attachOpfsClipStream(engineOrOptions);
367
+ }
368
+ const streamer = streamerOrEngine;
369
+ const engine = engineOrOptions;
370
+ const options = maybeOptions;
371
+ if (!options) {
372
+ throw new Error("attachOpfsClipStream requires options.");
373
+ }
374
+ const { clipId, primePages = 1, ...providerOptions } = options;
375
+ const binding = createOpfsClipPageProvider(engine, providerOptions);
376
+ const lastPage = Math.ceil(providerOptions.numSamples / providerOptions.pageFrames) - 1;
377
+ const primed = [];
378
+ for (let page = 0; page < primePages && page <= lastPage; ++page) {
379
+ if (await binding.supplyPage(page)) {
380
+ primed.push(page);
381
+ }
382
+ }
383
+ streamer.addSource(
384
+ {
385
+ clipId,
386
+ binding,
387
+ pageFrames: providerOptions.pageFrames,
388
+ numSamples: providerOptions.numSamples
389
+ },
390
+ primed
391
+ );
392
+ return { binding, provider: binding.provider };
393
+ }
394
+
1
395
  // src/errors.ts
2
396
  var SonareError = class extends Error {
3
397
  constructor(code, codeName, message) {
@@ -151,79 +545,58 @@ function getSonareModule() {
151
545
  }
152
546
 
153
547
  // src/codes.ts
154
- function automationCurveCode(curve) {
155
- switch (curve) {
156
- case "linear":
157
- return 0;
158
- case "exponential":
159
- return 1;
160
- case "hold":
161
- return 2;
162
- case "s-curve":
163
- return 3;
164
- default:
165
- throw new Error(`Invalid automation curve: ${curve}`);
548
+ function resolveEnumOrdinal(value, values, enumName) {
549
+ if (typeof value === "number") {
550
+ if (!Number.isSafeInteger(value) || !Object.values(values).includes(value)) {
551
+ throw new RangeError(`Invalid ${enumName}: ${String(value)}`);
552
+ }
553
+ return value;
166
554
  }
555
+ if (typeof value === "string") {
556
+ const ordinal = values[value];
557
+ if (ordinal !== void 0) {
558
+ return ordinal;
559
+ }
560
+ }
561
+ throw new RangeError(`Invalid ${enumName}: ${String(value)}`);
562
+ }
563
+ var AUTOMATION_CURVE_VALUES = {
564
+ linear: 0,
565
+ exponential: 1,
566
+ hold: 2,
567
+ "s-curve": 3
568
+ };
569
+ var PAN_LAW_VALUES = {
570
+ const3dB: 0,
571
+ "const4.5dB": 1,
572
+ const6dB: 2,
573
+ linear0dB: 3
574
+ };
575
+ var PAN_MODE_VALUES = {
576
+ balance: 0,
577
+ pan: 0,
578
+ stereopan: 1,
579
+ "stereo-pan": 1,
580
+ dualpan: 2,
581
+ "dual-pan": 2
582
+ };
583
+ var METER_TAP_VALUES = { preFader: 0, postFader: 1 };
584
+ var SEND_TIMING_VALUES = { postFader: 0, preFader: 1 };
585
+ function automationCurveCode(curve) {
586
+ return resolveEnumOrdinal(curve, AUTOMATION_CURVE_VALUES, "automation curve");
167
587
  }
168
588
  function panLawCode(panLaw) {
169
- if (typeof panLaw === "number") {
170
- return panLaw;
171
- }
172
- switch (panLaw) {
173
- case "const3dB":
174
- return 0;
175
- case "const4.5dB":
176
- return 1;
177
- case "const6dB":
178
- return 2;
179
- case "linear0dB":
180
- return 3;
181
- default:
182
- throw new Error(`Invalid pan law: ${panLaw}`);
183
- }
589
+ return resolveEnumOrdinal(panLaw, PAN_LAW_VALUES, "pan law");
184
590
  }
185
591
  function panModeCode(panMode) {
186
- if (typeof panMode === "number") {
187
- return panMode;
188
- }
189
- switch (panMode) {
190
- case "balance":
191
- return 0;
192
- case "stereoPan":
193
- case "stereo-pan":
194
- return 1;
195
- case "dualPan":
196
- case "dual-pan":
197
- return 2;
198
- default:
199
- throw new Error(`Invalid pan mode: ${panMode}`);
200
- }
592
+ const normalized = typeof panMode === "string" ? panMode.replace(/_/g, "-").toLowerCase() : panMode;
593
+ return resolveEnumOrdinal(normalized, PAN_MODE_VALUES, "pan mode");
201
594
  }
202
595
  function meterTapCode(tap) {
203
- if (typeof tap === "number") {
204
- return tap;
205
- }
206
- switch (tap) {
207
- case "preFader":
208
- return 0;
209
- case "postFader":
210
- return 1;
211
- default:
212
- throw new Error(`Invalid meter tap: ${tap}`);
213
- }
596
+ return resolveEnumOrdinal(tap, METER_TAP_VALUES, "meter tap");
214
597
  }
215
598
  function sendTimingCode(timing) {
216
- if (typeof timing === "number") {
217
- return timing;
218
- }
219
- switch (timing) {
220
- case "postFader":
221
- return 0;
222
- case "preFader":
223
- return 1;
224
- default:
225
- throw new Error(`Invalid send timing: ${timing}`);
226
- }
599
+ return resolveEnumOrdinal(timing, SEND_TIMING_VALUES, "send timing");
227
600
  }
228
601
 
229
602
  // src/realtime_engine.ts
@@ -244,7 +617,7 @@ function engineCapabilities() {
244
617
  };
245
618
  }
246
619
  var RealtimeEngine = class {
247
- constructor(sampleRate = 48e3, maxBlockSize = 128, commandCapacity = 1024, telemetryCapacity = 1024) {
620
+ constructor(sampleRate = 48e3, maxBlockSize = 128, commandCapacity = 1024, telemetryCapacity = 1024, maxChannels = 64) {
248
621
  const module2 = getSonareModule();
249
622
  const capabilities = engineCapabilities();
250
623
  if (!capabilities.abiCompatible) {
@@ -256,11 +629,18 @@ var RealtimeEngine = class {
256
629
  sampleRate,
257
630
  maxBlockSize,
258
631
  commandCapacity,
259
- telemetryCapacity
632
+ telemetryCapacity,
633
+ maxChannels
260
634
  );
261
635
  }
262
- prepare(sampleRate, maxBlockSize, commandCapacity = 1024, telemetryCapacity = 1024) {
263
- this.native.prepare(sampleRate, maxBlockSize, commandCapacity, telemetryCapacity);
636
+ prepare(sampleRate, maxBlockSize, commandCapacity = 1024, telemetryCapacity = 1024, maxChannels = 64) {
637
+ this.native.prepareWithChannels(
638
+ sampleRate,
639
+ maxBlockSize,
640
+ commandCapacity,
641
+ telemetryCapacity,
642
+ maxChannels
643
+ );
264
644
  }
265
645
  /** Queue a sample-accurate parameter change (engine kSetParam). */
266
646
  setParameter(paramId, value, renderFrame = -1) {
@@ -386,6 +766,9 @@ var RealtimeEngine = class {
386
766
  externalMidiDroppedCount() {
387
767
  return this.native.externalMidiDroppedCount();
388
768
  }
769
+ externalMidiPendingCount() {
770
+ return this.native.externalMidiPendingCount();
771
+ }
389
772
  /**
390
773
  * Drain queued external-MIDI events, already lowered to MIDI 1.0 byte
391
774
  * messages ready to write to a Web MIDI output port. Call once per audio
@@ -396,6 +779,25 @@ var RealtimeEngine = class {
396
779
  drainExternalMidi(maxRecords = 1024) {
397
780
  return this.native.drainExternalMidi(maxRecords);
398
781
  }
782
+ /** Scalar, allocation-free external-MIDI drain for AudioWorklet SAB output. */
783
+ popExternalMidiToScratch() {
784
+ return this.native.popExternalMidiToScratch();
785
+ }
786
+ externalMidiScratchDestinationId() {
787
+ return this.native.externalMidiScratchDestinationId();
788
+ }
789
+ externalMidiScratchRenderFrame() {
790
+ return this.native.externalMidiScratchRenderFrame();
791
+ }
792
+ externalMidiScratchByteWord() {
793
+ return this.native.externalMidiScratchByteWord();
794
+ }
795
+ externalMidiScratchByteCount() {
796
+ return this.native.externalMidiScratchByteCount();
797
+ }
798
+ consumeExternalMidiScratch() {
799
+ this.native.consumeExternalMidiScratch();
800
+ }
399
801
  pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples = 0) {
400
802
  this.native.pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples);
401
803
  }
@@ -420,6 +822,10 @@ var RealtimeEngine = class {
420
822
  pushMidiCc(destinationId, group, channel, controller, value, renderFrame = -1) {
421
823
  this.native.pushMidiCc(destinationId, group, channel, controller, value, renderFrame);
422
824
  }
825
+ /** Queue one immediate MIDI 1.0 channel-voice UMP word for a destination. */
826
+ pushMidiUmp(destinationId, word0, renderFrame = -1) {
827
+ this.native.pushMidiUmp(destinationId, word0, renderFrame);
828
+ }
423
829
  /**
424
830
  * Queue an immediate (live) MIDI SysEx frame to a MIDI destination. `data` is
425
831
  * the full message including the leading 0xF0 and trailing 0xF7 (1..512
@@ -465,6 +871,10 @@ var RealtimeEngine = class {
465
871
  settleParameters() {
466
872
  this.native.settleParameters();
467
873
  }
874
+ /** Drains queued commands on an offline/control-only engine immediately. */
875
+ flushControlCommands() {
876
+ this.native.flushControlCommands();
877
+ }
468
878
  seekPpq(ppq, renderFrame = -1) {
469
879
  this.native.seekPpq(ppq, renderFrame);
470
880
  }
@@ -719,6 +1129,23 @@ var RealtimeEngine = class {
719
1129
  popClipPageRequest() {
720
1130
  return this.native.popClipPageRequest();
721
1131
  }
1132
+ /**
1133
+ * Moves one native request into the binding's persistent scalar scratch.
1134
+ * This avoids creating an embind JS object in AudioWorklet process().
1135
+ */
1136
+ popClipPageRequestToScratch() {
1137
+ return this.native.popClipPageRequestToScratch();
1138
+ }
1139
+ clipPageRequestScratchClipId() {
1140
+ return this.native.clipPageRequestScratchClipId();
1141
+ }
1142
+ clipPageRequestScratchSample() {
1143
+ return this.native.clipPageRequestScratchSample();
1144
+ }
1145
+ /** Cumulative page misses dropped because the native bounded request queue was full. */
1146
+ clipPageRequestOverflowCount() {
1147
+ return this.native.clipPageRequestOverflowCount();
1148
+ }
722
1149
  setCaptureBuffer(numChannels, capacityFrames) {
723
1150
  this.native.setCaptureBuffer(numChannels, capacityFrames);
724
1151
  }
@@ -747,6 +1174,10 @@ var RealtimeEngine = class {
747
1174
  capturedAudio() {
748
1175
  return this.native.capturedAudio();
749
1176
  }
1177
+ /**
1178
+ * Renders in place, adding engine output to `channels`. Zero each plane first
1179
+ * when it contains no upstream input.
1180
+ */
750
1181
  process(channels) {
751
1182
  return this.native.process(channels);
752
1183
  }
@@ -769,6 +1200,7 @@ var RealtimeEngine = class {
769
1200
  }
770
1201
  /**
771
1202
  * Runs the engine in place over the prepared per-channel scratch buffers.
1203
+ * Zero each active span first when it contains no upstream input.
772
1204
  * Allocation-free: safe to call on the AudioWorklet render thread after
773
1205
  * `prepareChannels`.
774
1206
  */
@@ -790,6 +1222,42 @@ var RealtimeEngine = class {
790
1222
  drainTelemetry(maxRecords = 1024) {
791
1223
  return this.native.drainTelemetry(maxRecords);
792
1224
  }
1225
+ popTelemetryToScratch() {
1226
+ return this.native.popTelemetryToScratch();
1227
+ }
1228
+ telemetryScratchType() {
1229
+ return this.native.telemetryScratchType();
1230
+ }
1231
+ telemetryScratchError() {
1232
+ return this.native.telemetryScratchError();
1233
+ }
1234
+ telemetryScratchRenderFrame() {
1235
+ return Number(this.native.telemetryScratchRenderFrame());
1236
+ }
1237
+ telemetryScratchTimelineSample() {
1238
+ return Number(this.native.telemetryScratchTimelineSample());
1239
+ }
1240
+ telemetryScratchAudibleTimelineSample() {
1241
+ return Number(this.native.telemetryScratchAudibleTimelineSample());
1242
+ }
1243
+ telemetryScratchGraphLatencySamplesQ8() {
1244
+ return this.native.telemetryScratchGraphLatencySamplesQ8();
1245
+ }
1246
+ telemetryScratchValue() {
1247
+ return this.native.telemetryScratchValue();
1248
+ }
1249
+ popMeterTelemetryToScratch() {
1250
+ return this.native.popMeterTelemetryToScratch();
1251
+ }
1252
+ meterScratchTargetId() {
1253
+ return this.native.meterScratchTargetId();
1254
+ }
1255
+ meterScratchRenderFrame() {
1256
+ return Number(this.native.meterScratchRenderFrame());
1257
+ }
1258
+ meterScratchValue(field) {
1259
+ return this.native.meterScratchValue(field);
1260
+ }
793
1261
  drainMeterTelemetry(maxRecords = 1024) {
794
1262
  return this.native.drainMeterTelemetry(maxRecords);
795
1263
  }
@@ -817,6 +1285,30 @@ var RealtimeEngine = class {
817
1285
  drainScopeTelemetry(maxRecords = 1024) {
818
1286
  return this.native.drainScopeTelemetry(maxRecords);
819
1287
  }
1288
+ popScopeTelemetryToScratch() {
1289
+ return this.native.popScopeTelemetryToScratch();
1290
+ }
1291
+ scopeScratchTargetId() {
1292
+ return this.native.scopeScratchTargetId();
1293
+ }
1294
+ scopeScratchRenderFrame() {
1295
+ return Number(this.native.scopeScratchRenderFrame());
1296
+ }
1297
+ scopeScratchBandCount() {
1298
+ return this.native.scopeScratchBandCount();
1299
+ }
1300
+ scopeScratchBand(index) {
1301
+ return this.native.scopeScratchBand(index);
1302
+ }
1303
+ scopeScratchPointCount() {
1304
+ return this.native.scopeScratchPointCount();
1305
+ }
1306
+ scopeScratchPointLeft(index) {
1307
+ return this.native.scopeScratchPointLeft(index);
1308
+ }
1309
+ scopeScratchPointRight(index) {
1310
+ return this.native.scopeScratchPointRight(index);
1311
+ }
820
1312
  destroy() {
821
1313
  this.native.delete();
822
1314
  }
@@ -1274,65 +1766,18 @@ var Mixer = class _Mixer {
1274
1766
  };
1275
1767
 
1276
1768
  // src/realtime_voice_changer.ts
1277
- function isFlatVoiceChangerPod(config) {
1278
- return typeof config === "object" && config !== null && "retuneSemitones" in config;
1279
- }
1280
- function flatVoiceChangerPodToNested(pod) {
1281
- return {
1282
- inputGainDb: pod.inputGainDb,
1283
- outputGainDb: pod.outputGainDb,
1284
- wetMix: pod.wetMix,
1285
- retune: { semitones: pod.retuneSemitones, mix: pod.retuneMix, grainSize: pod.retuneGrainSize },
1286
- formant: {
1287
- factor: pod.formantFactor,
1288
- amount: pod.formantAmount,
1289
- body: pod.formantBody,
1290
- brightness: pod.formantBrightness,
1291
- nasal: pod.formantNasal
1292
- },
1293
- eq: {
1294
- highpassHz: pod.eqHighpassHz,
1295
- bodyDb: pod.eqBodyDb,
1296
- presenceDb: pod.eqPresenceDb,
1297
- airDb: pod.eqAirDb
1298
- },
1299
- gate: {
1300
- thresholdDb: pod.gateThresholdDb,
1301
- attackMs: pod.gateAttackMs,
1302
- releaseMs: pod.gateReleaseMs,
1303
- rangeDb: pod.gateRangeDb
1304
- },
1305
- compressor: {
1306
- thresholdDb: pod.compressorThresholdDb,
1307
- ratio: pod.compressorRatio,
1308
- attackMs: pod.compressorAttackMs,
1309
- releaseMs: pod.compressorReleaseMs,
1310
- makeupGainDb: pod.compressorMakeupGainDb
1311
- },
1312
- deesser: {
1313
- frequencyHz: pod.deesserFrequencyHz,
1314
- thresholdDb: pod.deesserThresholdDb,
1315
- ratio: pod.deesserRatio,
1316
- rangeDb: pod.deesserRangeDb
1317
- },
1318
- reverb: {
1319
- mix: pod.reverbMix,
1320
- timeMs: pod.reverbTimeMs,
1321
- damping: pod.reverbDamping,
1322
- seed: pod.reverbSeed
1323
- },
1324
- limiter: {
1325
- ceilingDb: pod.limiterCeilingDb,
1326
- releaseMs: pod.limiterReleaseMs,
1327
- enableIspLimiter: pod.limiterEnableIspLimiter,
1328
- ispCeilingDbtp: pod.limiterIspCeilingDbtp
1329
- }
1330
- };
1331
- }
1332
1769
  var RealtimeVoiceChanger = class {
1333
- constructor(config = "neutral-monitor") {
1770
+ /**
1771
+ * Creates a voice changer. Supplying `sampleRate` prepares it immediately,
1772
+ * matching the Node and Python constructors; omitting it preserves the
1773
+ * explicit {@link prepare} lifecycle for callers that configure later.
1774
+ */
1775
+ constructor(config = "neutral-monitor", sampleRate, maxBlockSize = 128, channels = 1) {
1334
1776
  const module2 = getSonareModule();
1335
1777
  this.changer = module2.createRealtimeVoiceChanger(config);
1778
+ if (sampleRate !== void 0) {
1779
+ this.changer.prepare(sampleRate, maxBlockSize, channels);
1780
+ }
1336
1781
  }
1337
1782
  prepare(sampleRate, maxBlockSize = 128, channels = 1) {
1338
1783
  this.changer.prepare(sampleRate, maxBlockSize, channels);
@@ -1341,8 +1786,15 @@ var RealtimeVoiceChanger = class {
1341
1786
  this.changer.reset();
1342
1787
  }
1343
1788
  setConfig(config) {
1344
- const resolved = isFlatVoiceChangerPod(config) ? flatVoiceChangerPodToNested(config) : config;
1345
- this.changer.setConfig(resolved);
1789
+ this.changer.setConfig(config);
1790
+ }
1791
+ /**
1792
+ * Apply a flat, pre-normalized config without JSON serialization. Intended
1793
+ * for AudioWorklet control messages whose sender prepared the POD on the
1794
+ * main thread.
1795
+ */
1796
+ setPodConfig(config) {
1797
+ this.changer.setPodConfig(config);
1346
1798
  }
1347
1799
  configJson() {
1348
1800
  return this.changer.configJson();
@@ -1350,6 +1802,13 @@ var RealtimeVoiceChanger = class {
1350
1802
  latencySamples() {
1351
1803
  return this.changer.latencySamples();
1352
1804
  }
1805
+ /**
1806
+ * Monotonically increases whenever {@link prepare} can replace the native
1807
+ * scratch buffers. Cached WASM heap views must be reacquired after it changes.
1808
+ */
1809
+ bufferGeneration() {
1810
+ return this.changer.bufferGeneration();
1811
+ }
1353
1812
  processMono(samples) {
1354
1813
  return this.changer.processMono(samples);
1355
1814
  }
@@ -1426,10 +1885,12 @@ var RealtimeVoiceChanger = class {
1426
1885
  createRealtimeMonoBuffer(numSamples) {
1427
1886
  let input = this.getMonoInputBuffer(numSamples);
1428
1887
  let output = this.getMonoOutputBuffer(numSamples);
1888
+ let generation = this.bufferGeneration();
1429
1889
  const reacquireIfDetached = () => {
1430
- if (input.byteLength === 0 || output.byteLength === 0) {
1890
+ if (generation !== this.bufferGeneration() || input.byteLength === 0 || output.byteLength === 0) {
1431
1891
  input = this.getMonoInputBuffer(numSamples);
1432
1892
  output = this.getMonoOutputBuffer(numSamples);
1893
+ generation = this.bufferGeneration();
1433
1894
  }
1434
1895
  };
1435
1896
  return {
@@ -1451,10 +1912,12 @@ var RealtimeVoiceChanger = class {
1451
1912
  createRealtimeInterleavedBuffer(numFrames, numChannels) {
1452
1913
  let input = this.getInterleavedInputBuffer(numFrames, numChannels);
1453
1914
  let output = this.getInterleavedOutputBuffer(numFrames, numChannels);
1915
+ let generation = this.bufferGeneration();
1454
1916
  const reacquireIfDetached = () => {
1455
- if (input.byteLength === 0 || output.byteLength === 0) {
1917
+ if (generation !== this.bufferGeneration() || input.byteLength === 0 || output.byteLength === 0) {
1456
1918
  input = this.getInterleavedInputBuffer(numFrames, numChannels);
1457
1919
  output = this.getInterleavedOutputBuffer(numFrames, numChannels);
1920
+ generation = this.bufferGeneration();
1458
1921
  }
1459
1922
  };
1460
1923
  return {
@@ -1481,15 +1944,17 @@ var RealtimeVoiceChanger = class {
1481
1944
  */
1482
1945
  createRealtimePlanarBuffer(numFrames, numChannels) {
1483
1946
  let channels = [];
1947
+ let generation = this.bufferGeneration();
1484
1948
  const acquire = () => {
1485
1949
  channels = [];
1486
1950
  for (let ch = 0; ch < numChannels; ch++) {
1487
1951
  channels.push(this.getPlanarChannelBuffer(ch, numFrames));
1488
1952
  }
1953
+ generation = this.bufferGeneration();
1489
1954
  };
1490
1955
  acquire();
1491
1956
  const reacquireIfDetached = () => {
1492
- if ((channels[0]?.byteLength ?? 0) === 0) {
1957
+ if (generation !== this.bufferGeneration() || (channels[0]?.byteLength ?? 0) === 0) {
1493
1958
  acquire();
1494
1959
  }
1495
1960
  };
@@ -1571,7 +2036,7 @@ function resolveParamId(parameters, nodeId, param) {
1571
2036
  if (byName) {
1572
2037
  return byName.id;
1573
2038
  }
1574
- return resolveTargetId(param || nodeId);
2039
+ throw new RangeError(`Unknown engine parameter ${JSON.stringify(param)} for node ${nodeId}`);
1575
2040
  }
1576
2041
  function curveCode(curve) {
1577
2042
  if (typeof curve === "number") {
@@ -1636,12 +2101,16 @@ function buildTransportFacade(ctx) {
1636
2101
  },
1637
2102
  seekPpq: (ppq, sampleTime = -1) => {
1638
2103
  ctx.offlineEngine.seekPpq(ppq, sampleTime);
1639
- return ctx.realtimeNode.seekPpq(ppq, sampleTime);
2104
+ const ok = ctx.realtimeNode.seekPpq(ppq, sampleTime);
2105
+ ctx.flushOfflineMirror();
2106
+ return ok;
1640
2107
  },
1641
2108
  seekSeconds: (seconds, sampleTime = -1) => {
1642
2109
  const timelineSample = Math.max(0, Math.round(seconds * ctx.sampleRate));
1643
2110
  ctx.offlineEngine.seekSample(timelineSample, sampleTime);
1644
- return ctx.realtimeNode.seekSample(timelineSample, sampleTime);
2111
+ const ok = ctx.realtimeNode.seekSample(timelineSample, sampleTime);
2112
+ ctx.flushOfflineMirror();
2113
+ return ok;
1645
2114
  },
1646
2115
  setTempo: (bpm) => ctx.setTempo(bpm),
1647
2116
  setTempoSegments: (segments) => ctx.setTempoSegments(segments),
@@ -1728,6 +2197,10 @@ function decodeFrame(lo, hi) {
1728
2197
  var SONARE_ENGINE_RING_HEADER_INTS = 5;
1729
2198
  var SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
1730
2199
  var SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
2200
+ var SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS = 5;
2201
+ var SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S = 2;
2202
+ var SONARE_EXTERNAL_MIDI_RING_HEADER_INTS = 5;
2203
+ var SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S = 4;
1731
2204
  var SonareEngineCommandType = /* @__PURE__ */ ((SonareEngineCommandType2) => {
1732
2205
  SonareEngineCommandType2[SonareEngineCommandType2["SetParam"] = 0] = "SetParam";
1733
2206
  SonareEngineCommandType2[SonareEngineCommandType2["SetParamSmoothed"] = 1] = "SetParamSmoothed";
@@ -1774,6 +2247,7 @@ var SonareEngineTelemetryError = /* @__PURE__ */ ((SonareEngineTelemetryError2)
1774
2247
  SonareEngineTelemetryError2[SonareEngineTelemetryError2["InsertAutomationOverflow"] = 16] = "InsertAutomationOverflow";
1775
2248
  SonareEngineTelemetryError2[SonareEngineTelemetryError2["MidiClockOverflow"] = 17] = "MidiClockOverflow";
1776
2249
  SonareEngineTelemetryError2[SonareEngineTelemetryError2["MetronomeOverflow"] = 18] = "MetronomeOverflow";
2250
+ SonareEngineTelemetryError2[SonareEngineTelemetryError2["InvalidCommand"] = 19] = "InvalidCommand";
1777
2251
  return SonareEngineTelemetryError2;
1778
2252
  })(SonareEngineTelemetryError || {});
1779
2253
  function toDb(value) {
@@ -1971,6 +2445,128 @@ function sonareEngineTelemetryRingBufferByteLength(capacity) {
1971
2445
  const clampedCapacity = Math.max(1, Math.floor(capacity));
1972
2446
  return SONARE_ENGINE_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT + clampedCapacity * SONARE_ENGINE_TELEMETRY_RECORD_BYTES;
1973
2447
  }
2448
+ function sonareClipPageRequestRingBufferByteLength(capacity) {
2449
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
2450
+ return SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT + clampedCapacity * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S * Uint32Array.BYTES_PER_ELEMENT;
2451
+ }
2452
+ function sonareExternalMidiRingBufferByteLength(capacity) {
2453
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
2454
+ return SONARE_EXTERNAL_MIDI_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT + clampedCapacity * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S * Uint32Array.BYTES_PER_ELEMENT;
2455
+ }
2456
+ function externalMidiRingFromSharedBuffer(sharedBuffer, fallbackCapacity) {
2457
+ const headerBytes = SONARE_EXTERNAL_MIDI_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
2458
+ const header = new Int32Array(sharedBuffer, 0, SONARE_EXTERNAL_MIDI_RING_HEADER_INTS);
2459
+ const existingCapacity = Atomics.load(header, 2);
2460
+ const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
2461
+ if (sharedBuffer.byteLength < sonareExternalMidiRingBufferByteLength(capacity)) {
2462
+ throw new Error("externalMidiSharedBuffer is too small for the requested ring capacity.");
2463
+ }
2464
+ Atomics.store(header, 2, capacity);
2465
+ Atomics.store(header, 3, SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S);
2466
+ return {
2467
+ header,
2468
+ records: new Uint32Array(
2469
+ sharedBuffer,
2470
+ headerBytes,
2471
+ capacity * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S
2472
+ ),
2473
+ capacity
2474
+ };
2475
+ }
2476
+ function createSonareExternalMidiRingBuffer(capacity = 256) {
2477
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
2478
+ const sharedBuffer = new SharedArrayBuffer(
2479
+ sonareExternalMidiRingBufferByteLength(clampedCapacity)
2480
+ );
2481
+ const ring = externalMidiRingFromSharedBuffer(sharedBuffer, clampedCapacity);
2482
+ Atomics.store(ring.header, 0, 0);
2483
+ Atomics.store(ring.header, 1, 0);
2484
+ Atomics.store(ring.header, 4, 0);
2485
+ return { sharedBuffer, header: ring.header, records: ring.records, capacity: ring.capacity };
2486
+ }
2487
+ function pushSonareExternalMidiRingBuffer(ring, destinationId, renderFrame, byteWord, byteCount) {
2488
+ const destination = Number(destinationId);
2489
+ const frame = Number(renderFrame);
2490
+ const packedBytes = Number(byteWord);
2491
+ const count = Number(byteCount);
2492
+ if (!Number.isSafeInteger(destination) || !Number.isSafeInteger(frame) || frame < 0 || !Number.isSafeInteger(packedBytes) || packedBytes < 0 || packedBytes > 16777215 || !Number.isSafeInteger(count) || count < 1 || count > 3) {
2493
+ Atomics.add(ring.header, 4, 1);
2494
+ return false;
2495
+ }
2496
+ const writeIndex = Atomics.load(ring.header, 0);
2497
+ const readIndex = Atomics.load(ring.header, 1);
2498
+ if (writeIndex - readIndex >= ring.capacity) {
2499
+ Atomics.add(ring.header, 4, 1);
2500
+ return false;
2501
+ }
2502
+ const offset = writeIndex % ring.capacity * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S;
2503
+ Atomics.store(ring.records, offset, destination);
2504
+ Atomics.store(ring.records, offset + 1, frame);
2505
+ Atomics.store(ring.records, offset + 2, packedBytes);
2506
+ Atomics.store(ring.records, offset + 3, count);
2507
+ Atomics.store(ring.header, 0, writeIndex + 1);
2508
+ return true;
2509
+ }
2510
+ function readSonareExternalMidiRingBuffer(ring) {
2511
+ const readIndex = Atomics.load(ring.header, 1);
2512
+ const writeIndex = Atomics.load(ring.header, 0);
2513
+ const events = [];
2514
+ for (let index = readIndex; index < writeIndex; index++) {
2515
+ const offset = index % ring.capacity * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S;
2516
+ events.push({
2517
+ destinationId: Atomics.load(ring.records, offset) >>> 0,
2518
+ renderFrame: Atomics.load(ring.records, offset + 1) >>> 0,
2519
+ byteWord: Atomics.load(ring.records, offset + 2) >>> 0,
2520
+ byteCount: Atomics.load(ring.records, offset + 3) >>> 0
2521
+ });
2522
+ }
2523
+ Atomics.store(ring.header, 1, writeIndex);
2524
+ return { events, dropped: Atomics.load(ring.header, 4) >>> 0 };
2525
+ }
2526
+ function createSonareClipPageRequestRingBuffer(capacity = 128) {
2527
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
2528
+ const sharedBuffer = new SharedArrayBuffer(
2529
+ sonareClipPageRequestRingBufferByteLength(clampedCapacity)
2530
+ );
2531
+ const ring = clipPageRequestRingFromSharedBuffer(sharedBuffer, clampedCapacity);
2532
+ Atomics.store(ring.header, 0, 0);
2533
+ Atomics.store(ring.header, 1, 0);
2534
+ Atomics.store(ring.header, 2, ring.capacity);
2535
+ Atomics.store(ring.header, 3, SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S);
2536
+ Atomics.store(ring.header, 4, 0);
2537
+ return { sharedBuffer, header: ring.header, records: ring.records, capacity: ring.capacity };
2538
+ }
2539
+ function pushSonareClipPageRequestRingBuffer(ring, clipId, pageIndex) {
2540
+ if (!Number.isSafeInteger(clipId) || clipId < 0 || clipId > 4294967295 || !Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex > 4294967295) {
2541
+ Atomics.add(ring.header, 4, 1);
2542
+ return false;
2543
+ }
2544
+ const writeIndex = Atomics.load(ring.header, 0);
2545
+ const readIndex = Atomics.load(ring.header, 1);
2546
+ if (writeIndex - readIndex >= ring.capacity) {
2547
+ Atomics.add(ring.header, 4, 1);
2548
+ return false;
2549
+ }
2550
+ const offset = writeIndex % ring.capacity * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S;
2551
+ Atomics.store(ring.records, offset, clipId);
2552
+ Atomics.store(ring.records, offset + 1, pageIndex);
2553
+ Atomics.store(ring.header, 0, writeIndex + 1);
2554
+ return true;
2555
+ }
2556
+ function readSonareClipPageRequestRingBuffer(ring) {
2557
+ const readIndex = Atomics.load(ring.header, 1);
2558
+ const writeIndex = Atomics.load(ring.header, 0);
2559
+ const requests = [];
2560
+ for (let index = readIndex; index < writeIndex; index++) {
2561
+ const offset = index % ring.capacity * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S;
2562
+ requests.push({
2563
+ clipId: Atomics.load(ring.records, offset),
2564
+ pageIndex: Atomics.load(ring.records, offset + 1)
2565
+ });
2566
+ }
2567
+ Atomics.store(ring.header, 1, writeIndex);
2568
+ return { requests, dropped: Atomics.load(ring.header, 4) >>> 0 };
2569
+ }
1974
2570
  function createSonareEngineCommandRingBuffer(capacity = 128) {
1975
2571
  const clampedCapacity = Math.max(1, Math.floor(capacity));
1976
2572
  const sharedBuffer = new SharedArrayBuffer(
@@ -2111,51 +2707,82 @@ function engineRingFromSharedBuffer(sharedBuffer, recordBytes, fallbackCapacity)
2111
2707
  capacity
2112
2708
  };
2113
2709
  }
2710
+ function clipPageRequestRingFromSharedBuffer(sharedBuffer, fallbackCapacity) {
2711
+ const headerBytes = SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
2712
+ const header = new Int32Array(sharedBuffer, 0, SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS);
2713
+ const existingCapacity = Atomics.load(header, 2);
2714
+ const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
2715
+ const minBytes = sonareClipPageRequestRingBufferByteLength(capacity);
2716
+ if (sharedBuffer.byteLength < minBytes) {
2717
+ throw new Error("clipPageRequestSharedBuffer is too small for the requested ring capacity.");
2718
+ }
2719
+ Atomics.store(header, 2, capacity);
2720
+ Atomics.store(header, 3, SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S);
2721
+ return {
2722
+ header,
2723
+ records: new Uint32Array(
2724
+ sharedBuffer,
2725
+ headerBytes,
2726
+ capacity * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S
2727
+ ),
2728
+ capacity
2729
+ };
2730
+ }
2114
2731
  function recordOffset(index, capacity, recordBytes) {
2115
2732
  return index % capacity * recordBytes;
2116
2733
  }
2117
- function toBigInt64(value, fallback) {
2118
- if (typeof value === "bigint") {
2119
- return value;
2734
+ function toSafeInteger(value, fallback) {
2735
+ const resolved = typeof value === "bigint" ? Number(value) : value;
2736
+ if (resolved === void 0) {
2737
+ return fallback;
2120
2738
  }
2121
- if (typeof value === "number") {
2122
- return BigInt(Math.trunc(value));
2739
+ if (!Number.isSafeInteger(resolved)) {
2740
+ throw new RangeError("64-bit ring values must be safe integers");
2123
2741
  }
2124
- return fallback;
2742
+ return resolved;
2743
+ }
2744
+ function writeInt64Words(view, offset, value) {
2745
+ const integer = toSafeInteger(value, 0);
2746
+ view.setUint32(offset, integer >>> 0, true);
2747
+ view.setInt32(offset + 4, Math.floor(integer / 4294967296), true);
2748
+ }
2749
+ function readInt64Words(view, offset) {
2750
+ return view.getInt32(offset + 4, true) * 4294967296 + view.getUint32(offset, true);
2125
2751
  }
2126
2752
  function writeEngineCommandRecord(view, offset, command) {
2127
2753
  view.setUint32(offset, command.type, true);
2128
2754
  view.setUint32(offset + 4, command.targetId ?? 0, true);
2129
- view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
2755
+ writeInt64Words(view, offset + 8, toSafeInteger(command.sampleTime, -1));
2130
2756
  view.setFloat64(offset + 16, command.argFloat ?? 0, true);
2131
- view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
2757
+ writeInt64Words(view, offset + 24, toSafeInteger(command.argInt, 0));
2132
2758
  }
2133
2759
  function readEngineCommandRecord(view, offset) {
2134
2760
  return {
2135
2761
  type: view.getUint32(offset, true),
2136
2762
  targetId: view.getUint32(offset + 4, true),
2137
- sampleTime: Number(view.getBigInt64(offset + 8, true)),
2763
+ sampleTime: readInt64Words(view, offset + 8),
2138
2764
  argFloat: view.getFloat64(offset + 16, true),
2139
- argInt: Number(view.getBigInt64(offset + 24, true))
2765
+ argInt: readInt64Words(view, offset + 24)
2140
2766
  };
2141
2767
  }
2142
2768
  function writeEngineTelemetryRecord(view, offset, telemetry) {
2143
2769
  view.setUint32(offset, telemetry.type, true);
2144
2770
  view.setUint32(offset + 4, telemetry.error, true);
2145
- view.setBigInt64(offset + 8, BigInt(Math.trunc(telemetry.renderFrame)), true);
2146
- view.setBigInt64(offset + 16, BigInt(Math.trunc(telemetry.timelineSample)), true);
2147
- view.setBigInt64(offset + 24, BigInt(Math.trunc(telemetry.audibleTimelineSample)), true);
2771
+ writeInt64Words(view, offset + 8, telemetry.renderFrame);
2772
+ writeInt64Words(view, offset + 16, telemetry.timelineSample);
2773
+ writeInt64Words(view, offset + 24, telemetry.audibleTimelineSample);
2148
2774
  view.setInt32(offset + 32, telemetry.graphLatencySamplesQ8, true);
2149
2775
  view.setUint32(offset + 36, telemetry.value, true);
2150
- view.setBigInt64(offset + 40, 0n, true);
2776
+ view.setUint32(offset + 40, 0, true);
2777
+ view.setUint32(offset + 44, 0, true);
2151
2778
  }
2152
2779
  function readEngineTelemetryRecord(view, offset) {
2153
2780
  return {
2154
2781
  type: view.getUint32(offset, true),
2155
2782
  error: view.getUint32(offset + 4, true),
2156
- renderFrame: Number(view.getBigInt64(offset + 8, true)),
2157
- timelineSample: Number(view.getBigInt64(offset + 16, true)),
2158
- audibleTimelineSample: Number(view.getBigInt64(offset + 24, true)),
2783
+ renderFrame: readInt64Words(view, offset + 8),
2784
+ timelineSample: readInt64Words(view, offset + 16),
2785
+ audibleTimelineSample: readInt64Words(view, offset + 24),
2159
2786
  graphLatencySamplesQ8: view.getInt32(offset + 32, true),
2160
2787
  value: view.getUint32(offset + 36, true)
2161
2788
  };
@@ -2204,13 +2831,16 @@ function configureCapture(ctx, options) {
2204
2831
  ctx.postSync({ type: "syncCapture", ...config });
2205
2832
  }
2206
2833
  function armRecord(ctx, trackId, enabled) {
2834
+ if (trackId !== 0) {
2835
+ throw new RangeError("Capture is global; armRecord only accepts trackId 0");
2836
+ }
2207
2837
  if (enabled && !ctx.getCaptureConfig()) {
2208
2838
  throw new Error("Capture buffer is not configured");
2209
2839
  }
2210
2840
  ctx.offlineEngine.armCapture(enabled);
2211
- return ctx.realtimeNode.sendCommand({
2841
+ return ctx.sendCommand({
2212
2842
  type: 13 /* ArmRecord */,
2213
- targetId: ctx.resolveTargetId(trackId),
2843
+ targetId: 0,
2214
2844
  sampleTime: -1,
2215
2845
  argInt: enabled ? 1 : 0
2216
2846
  });
@@ -2219,7 +2849,7 @@ function punch(ctx, inPpq, outPpq) {
2219
2849
  const inSample = ctx.offlineEngine.sampleAtPpq(inPpq);
2220
2850
  const outSample = ctx.offlineEngine.sampleAtPpq(outPpq);
2221
2851
  ctx.offlineEngine.setCapturePunch(inSample, outSample, true);
2222
- return ctx.realtimeNode.sendCommand({
2852
+ return ctx.sendCommand({
2223
2853
  type: 14 /* Punch */,
2224
2854
  sampleTime: -1,
2225
2855
  argInt: inSample,
@@ -2245,7 +2875,7 @@ function addClip(ctx, trackId, buffer, startPpq, opts = {}) {
2245
2875
  const clip = {
2246
2876
  ...opts,
2247
2877
  id,
2248
- channels: buffer,
2878
+ ...Array.isArray(buffer) ? { channels: buffer } : { pageProvider: buffer },
2249
2879
  startPpq,
2250
2880
  trackId: ctx.resolveTargetId(trackId)
2251
2881
  };
@@ -2292,6 +2922,12 @@ function syncClipsDelta(ctx, upserts, removeIds) {
2292
2922
  for (const clip of upserts) {
2293
2923
  const prepared = clip.id === void 0 ? clip : preparedById.get(clip.id) ?? clip;
2294
2924
  const channels = prepared.channels;
2925
+ if (!channels && prepared.pageProvider !== void 0) {
2926
+ if (ctx.commitWorkletClipPageProvider(prepared)) {
2927
+ continue;
2928
+ }
2929
+ throw new Error("A pageProvider on SonareEngine must be created by attachOpfsClipStream().");
2930
+ }
2295
2931
  if (prepared.id === void 0 || prepared.warpMode !== "off" || !channels || channels.length === 0 || channels[0].length <= PREBAKED_CLIP_PAGE_THRESHOLD) {
2296
2932
  inlineUpserts.push(prepared);
2297
2933
  continue;
@@ -2495,7 +3131,7 @@ function isEngineSyncMessage(value) {
2495
3131
  if (!isRecord(value) || typeof value.type !== "string") {
2496
3132
  return false;
2497
3133
  }
2498
- return value.type === "syncClips" || value.type === "syncClipsDelta" || value.type === "syncClipPageProvider" || value.type === "syncClipPage" || value.type === "syncClipPageCommit" || value.type === "syncMidiClips" || value.type === "syncMarkers" || value.type === "syncMetronome" || value.type === "syncAutomation" || value.type === "syncTempo" || value.type === "syncMixer" || value.type === "syncCapture" || value.type === "syncTrackStripEqBand" || value.type === "syncMasterStripEqBand" || value.type === "syncTrackStripInsertBypassed" || value.type === "syncMasterStripInsertBypassed" || value.type === "syncTrackStripInsertParamByName" || value.type === "syncMasterStripInsertParamByName" || value.type === "syncBusStripInsertParamByName" || value.type === "syncTrackStripPan" || value.type === "syncTrackStripPanLaw" || value.type === "syncTrackStripPanMode" || value.type === "syncTrackStripDualPan" || value.type === "syncTrackStripChannelDelaySamples" || value.type === "syncBuiltinInstrument" || value.type === "syncSynthInstrument" || value.type === "syncSf2Instrument" || value.type === "syncLoadSoundFont" || value.type === "syncMidiFx" || value.type === "syncClearMidiFx" || value.type === "syncMidiNoteOn" || value.type === "syncMidiNoteOff" || value.type === "syncMidiCc" || value.type === "syncMidiSysex" || value.type === "syncMidiPanic" || value.type === "syncMidiDestinationExternal" || value.type === "syncExternalMidiClock";
3134
+ return value.type === "syncClips" || value.type === "syncClipsDelta" || value.type === "syncClipPageProvider" || value.type === "syncClipPage" || value.type === "syncClipPageClear" || value.type === "syncClipPageCommit" || value.type === "syncClipPageDestroy" || value.type === "syncMidiClips" || value.type === "syncMarkers" || value.type === "syncMetronome" || value.type === "syncAutomation" || value.type === "syncTempo" || value.type === "syncMixer" || value.type === "syncCapture" || value.type === "syncTrackStripEqBand" || value.type === "syncMasterStripEqBand" || value.type === "syncTrackStripInsertBypassed" || value.type === "syncMasterStripInsertBypassed" || value.type === "syncTrackStripInsertParamByName" || value.type === "syncMasterStripInsertParamByName" || value.type === "syncBusStripInsertParamByName" || value.type === "syncTrackStripPan" || value.type === "syncTrackStripPanLaw" || value.type === "syncTrackStripPanMode" || value.type === "syncTrackStripDualPan" || value.type === "syncTrackStripChannelDelaySamples" || value.type === "syncBuiltinInstrument" || value.type === "syncSynthInstrument" || value.type === "syncSf2Instrument" || value.type === "syncLoadSoundFont" || value.type === "syncMidiFx" || value.type === "syncClearMidiFx" || value.type === "syncMidiNoteOn" || value.type === "syncMidiNoteOff" || value.type === "syncMidiCc" || value.type === "syncMidiUmp" || value.type === "syncMidiSysex" || value.type === "syncMidiPanic" || value.type === "syncMidiDestinationExternal" || value.type === "syncExternalMidiClock" || value.type === "destroy";
2499
3135
  }
2500
3136
  function isEngineCaptureRequestMessage(value) {
2501
3137
  return isRecord(value) && value.type === "captureRequest" && typeof value.requestId === "number" && (value.op === "status" || value.op === "read" || value.op === "reset");
@@ -2521,20 +3157,51 @@ function isEngineTelemetryRecord(value) {
2521
3157
  function isExternalMidiBatchMessage(value) {
2522
3158
  return isRecord(value) && value.type === "externalMidi" && Array.isArray(value.events);
2523
3159
  }
3160
+ function isClipPageRequestMessage(value) {
3161
+ return isRecord(value) && value.type === "clipPageRequest" && Array.isArray(value.requests) && value.requests.every(
3162
+ (request) => isRecord(request) && typeof request.clipId === "number" && typeof request.pageIndex === "number"
3163
+ );
3164
+ }
2524
3165
  function isMeterSnapshot(value) {
2525
3166
  return isRecord(value) && value.type === "meter" && typeof value.frame === "number" && typeof value.peakDbL === "number" && typeof value.peakDbR === "number" && typeof value.rmsDbL === "number" && typeof value.rmsDbR === "number" && typeof value.correlation === "number" && (typeof value.targetId === "number" || value.targetId === void 0);
2526
3167
  }
2527
3168
 
2528
3169
  // src/worklet/engine-node.ts
3170
+ function isFiniteInteger(value) {
3171
+ if (value === void 0) {
3172
+ return true;
3173
+ }
3174
+ return typeof value === "bigint" || Number.isFinite(value) && Number.isSafeInteger(value);
3175
+ }
3176
+ function isValidCommandRecord(command) {
3177
+ const type = Number(command.type);
3178
+ if (!Number.isSafeInteger(type) || type < 0 /* SetParam */ || type > 17 /* SeekMarker */) {
3179
+ return false;
3180
+ }
3181
+ return (command.targetId === void 0 || Number.isSafeInteger(command.targetId)) && (command.argFloat === void 0 || Number.isFinite(command.argFloat)) && isFiniteInteger(command.argInt) && isFiniteInteger(command.sampleTime);
3182
+ }
3183
+ var AUDIO_WORKLET_RENDER_QUANTUM = 128;
3184
+ function workletBlockSize(blockSize) {
3185
+ const resolved = blockSize ?? AUDIO_WORKLET_RENDER_QUANTUM;
3186
+ if (!Number.isSafeInteger(resolved) || resolved < AUDIO_WORKLET_RENDER_QUANTUM) {
3187
+ throw new RangeError(
3188
+ `blockSize must be an integer of at least ${AUDIO_WORKLET_RENDER_QUANTUM} frames`
3189
+ );
3190
+ }
3191
+ return resolved;
3192
+ }
2529
3193
  var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2530
- constructor(node, capabilities, commandRing, telemetryRing, meterRing, scopeRing) {
3194
+ constructor(node, capabilities, commandRing, telemetryRing, meterRing, scopeRing, clipPageRequestRing, externalMidiRing) {
2531
3195
  this.telemetryReadIndex = 0;
2532
3196
  this.meterReadIndex = 0;
2533
3197
  this.scopeReadIndex = 0;
3198
+ this.clipPageRequestDroppedRead = 0;
2534
3199
  this.telemetryListeners = /* @__PURE__ */ new Set();
2535
3200
  this.meterListeners = /* @__PURE__ */ new Set();
2536
3201
  this.scopeListeners = /* @__PURE__ */ new Set();
2537
3202
  this.midiOutListeners = /* @__PURE__ */ new Set();
3203
+ this.clipPageRequestListeners = /* @__PURE__ */ new Set();
3204
+ this.syncErrorListeners = /* @__PURE__ */ new Set();
2538
3205
  this.captureRequestId = 1;
2539
3206
  this.captureRequests = /* @__PURE__ */ new Map();
2540
3207
  this.transportRequestId = 1;
@@ -2546,6 +3213,8 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2546
3213
  this.telemetryRing = telemetryRing;
2547
3214
  this.meterRing = meterRing;
2548
3215
  this.scopeRing = scopeRing;
3216
+ this.clipPageRequestRing = clipPageRequestRing;
3217
+ this.externalMidiRing = externalMidiRing;
2549
3218
  this.ready = new Promise((resolve, reject) => {
2550
3219
  this.resolveReady = resolve;
2551
3220
  this.rejectReady = reject;
@@ -2580,6 +3249,17 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2580
3249
  this.emitMeter(event.data);
2581
3250
  } else if (isExternalMidiBatchMessage(event.data)) {
2582
3251
  this.emitMidiOut(event.data.events);
3252
+ } else if (isClipPageRequestMessage(event.data)) {
3253
+ this.emitClipPageRequests(event.data);
3254
+ } else if (isRecord(event.data) && event.data.type === "syncError") {
3255
+ const syncError = {
3256
+ type: "syncError",
3257
+ syncType: String(event.data.syncType),
3258
+ message: String(event.data.message ?? "AudioWorklet sync failed")
3259
+ };
3260
+ for (const listener of this.syncErrorListeners) {
3261
+ listener(syncError);
3262
+ }
2583
3263
  } else if (isRecord(event.data) && event.data.type === "ready") {
2584
3264
  this.resolveReady();
2585
3265
  } else if (isRecord(event.data) && event.data.type === "error") {
@@ -2587,7 +3267,13 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2587
3267
  }
2588
3268
  };
2589
3269
  }
3270
+ /** Subscribes to control-plane sync rejections reported by the worklet. */
3271
+ onSyncError(listener) {
3272
+ this.syncErrorListeners.add(listener);
3273
+ return () => this.syncErrorListeners.delete(listener);
3274
+ }
2590
3275
  static async create(context, options = {}) {
3276
+ const blockSize = workletBlockSize(options.blockSize);
2591
3277
  const processorName = options.processorName ?? "sonare-realtime-engine-processor";
2592
3278
  const moduleUrl = options.moduleUrl;
2593
3279
  if (moduleUrl && context.audioWorklet?.addModule) {
@@ -2618,10 +3304,12 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2618
3304
  const meterRing = mode === "sab" ? createSonareMeterRingBuffer(options.meterRingCapacity ?? 128) : void 0;
2619
3305
  const scopeIntervalFrames = Math.max(0, Math.floor(options.scopeIntervalFrames ?? 0));
2620
3306
  const scopeRing = mode === "sab" && scopeIntervalFrames > 0 ? createSonareScopeRingBuffer(options.scopeRingCapacity ?? 64, options.scopeBands ?? 48) : void 0;
3307
+ const clipPageRequestRing = mode === "sab" ? createSonareClipPageRequestRingBuffer(options.clipPageRequestRingCapacity ?? 128) : void 0;
3308
+ const externalMidiRing = mode === "sab" ? createSonareExternalMidiRingBuffer(options.externalMidiRingCapacity ?? 256) : void 0;
2621
3309
  const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
2622
3310
  const processorOptions = {
2623
3311
  sampleRate: options.sampleRate ?? context.sampleRate,
2624
- blockSize: options.blockSize,
3312
+ blockSize,
2625
3313
  channelCount,
2626
3314
  commandSharedBuffer: commandRing?.sharedBuffer,
2627
3315
  commandRingCapacity: commandRing?.capacity,
@@ -2633,6 +3321,10 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2633
3321
  scopeRingCapacity: scopeRing?.capacity,
2634
3322
  scopeBands: scopeRing?.bands,
2635
3323
  scopeIntervalFrames: scopeRing ? scopeIntervalFrames : void 0,
3324
+ clipPageRequestSharedBuffer: clipPageRequestRing?.sharedBuffer,
3325
+ clipPageRequestRingCapacity: clipPageRequestRing?.capacity,
3326
+ externalMidiSharedBuffer: externalMidiRing?.sharedBuffer,
3327
+ externalMidiRingCapacity: externalMidiRing?.capacity,
2636
3328
  wasmBinary: options.wasmBinary,
2637
3329
  initialSyncMessages: options.initialSyncMessages,
2638
3330
  initialCommands: options.initialCommands
@@ -2652,16 +3344,23 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2652
3344
  sharedArrayBuffer,
2653
3345
  atomics,
2654
3346
  audioWorklet,
3347
+ clipPageRequestsRealtimeSafe: mode === "sab",
3348
+ externalMidiRealtimeSafe: mode === "sab",
2655
3349
  engineAbiVersion: detectedCapabilities?.engineAbiVersion,
2656
3350
  expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
2657
3351
  abiCompatible: detectedCapabilities?.abiCompatible,
2658
3352
  degradedReason,
2659
- readyMessage: moduleUrl !== void 0 && !options.nodeFactory
3353
+ // A processor posts ready/error irrespective of whether its module was
3354
+ // loaded here or registered by the host beforehand. Waiting in both
3355
+ // cases is the only way to surface a failed worklet-side WASM init.
3356
+ readyMessage: true
2660
3357
  },
2661
3358
  commandRing,
2662
3359
  telemetryRing,
2663
3360
  meterRing,
2664
- scopeRing
3361
+ scopeRing,
3362
+ clipPageRequestRing,
3363
+ externalMidiRing
2665
3364
  );
2666
3365
  }
2667
3366
  play(sampleTime = -1) {
@@ -2678,6 +3377,9 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2678
3377
  });
2679
3378
  }
2680
3379
  seekPpq(ppq, sampleTime = -1) {
3380
+ if (!Number.isFinite(ppq) || !Number.isSafeInteger(sampleTime)) {
3381
+ return false;
3382
+ }
2681
3383
  return this.sendCommand({
2682
3384
  type: 5 /* TransportSeekPpq */,
2683
3385
  sampleTime,
@@ -2685,7 +3387,7 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2685
3387
  });
2686
3388
  }
2687
3389
  sendCommand(command) {
2688
- if (this.destroyed) {
3390
+ if (this.destroyed || !isValidCommandRecord(command)) {
2689
3391
  return false;
2690
3392
  }
2691
3393
  if (this.commandRing) {
@@ -2759,22 +3461,73 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2759
3461
  }
2760
3462
  return read.scopes;
2761
3463
  }
3464
+ /** Drain lowered MIDI-1 records from the SAB ring on the main thread. */
3465
+ pollMidiOut() {
3466
+ if (!this.externalMidiRing) {
3467
+ return [];
3468
+ }
3469
+ const read = readSonareExternalMidiRingBuffer(this.externalMidiRing);
3470
+ const events = read.events.map((event) => {
3471
+ const bytes = [];
3472
+ for (let index = 0; index < event.byteCount; index++) {
3473
+ bytes.push(event.byteWord >>> 8 * index & 255);
3474
+ }
3475
+ return {
3476
+ destinationId: event.destinationId,
3477
+ renderFrame: event.renderFrame,
3478
+ bytes
3479
+ };
3480
+ });
3481
+ if (events.length > 0) {
3482
+ this.emitMidiOut(events);
3483
+ }
3484
+ return events;
3485
+ }
3486
+ /**
3487
+ * Drains bounded paged-clip misses from the SAB ring and forwards one batch
3488
+ * to subscribers. In postMessage mode the legacy handler remains available,
3489
+ * but that degraded path is not realtime-safe for OPFS streaming.
3490
+ */
3491
+ pollClipPageRequests() {
3492
+ if (!this.clipPageRequestRing) {
3493
+ return void 0;
3494
+ }
3495
+ const read = readSonareClipPageRequestRingBuffer(this.clipPageRequestRing);
3496
+ const dropped = read.dropped - this.clipPageRequestDroppedRead >>> 0;
3497
+ this.clipPageRequestDroppedRead = read.dropped;
3498
+ if (read.requests.length === 0 && dropped === 0) {
3499
+ return void 0;
3500
+ }
3501
+ const message = {
3502
+ type: "clipPageRequest",
3503
+ requests: read.requests,
3504
+ ...dropped > 0 ? { dropped } : {}
3505
+ };
3506
+ this.emitClipPageRequests(message);
3507
+ return message;
3508
+ }
2762
3509
  onTelemetry(callback) {
2763
3510
  this.telemetryListeners.add(callback);
3511
+ this.startRingPolling();
2764
3512
  return () => {
2765
3513
  this.telemetryListeners.delete(callback);
3514
+ this.stopRingPollingIfUnused();
2766
3515
  };
2767
3516
  }
2768
3517
  onMeter(callback) {
2769
3518
  this.meterListeners.add(callback);
3519
+ this.startRingPolling();
2770
3520
  return () => {
2771
3521
  this.meterListeners.delete(callback);
3522
+ this.stopRingPollingIfUnused();
2772
3523
  };
2773
3524
  }
2774
3525
  onScope(callback) {
2775
3526
  this.scopeListeners.add(callback);
3527
+ this.startRingPolling();
2776
3528
  return () => {
2777
3529
  this.scopeListeners.delete(callback);
3530
+ this.stopRingPollingIfUnused();
2778
3531
  };
2779
3532
  }
2780
3533
  /**
@@ -2784,8 +3537,19 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2784
3537
  */
2785
3538
  onMidiOut(callback) {
2786
3539
  this.midiOutListeners.add(callback);
3540
+ this.startRingPolling();
3541
+ return () => {
3542
+ this.midiOutListeners.delete(callback);
3543
+ };
3544
+ }
3545
+ /**
3546
+ * Subscribe to bounded batches of paged-clip misses from the worklet. The
3547
+ * callback runs on the main thread and may initiate asynchronous OPFS I/O.
3548
+ */
3549
+ onClipPageRequests(callback) {
3550
+ this.clipPageRequestListeners.add(callback);
2787
3551
  return () => {
2788
- this.midiOutListeners.delete(callback);
3552
+ this.clipPageRequestListeners.delete(callback);
2789
3553
  };
2790
3554
  }
2791
3555
  destroy() {
@@ -2793,7 +3557,11 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2793
3557
  return;
2794
3558
  }
2795
3559
  this.destroyed = true;
2796
- this.node.port.postMessage({ type: 3 /* TransportStop */, sampleTime: -1 });
3560
+ if (this.ringPollTimer !== void 0) {
3561
+ clearInterval(this.ringPollTimer);
3562
+ this.ringPollTimer = void 0;
3563
+ }
3564
+ this.node.port.postMessage({ type: "destroy" });
2797
3565
  this.node.disconnect();
2798
3566
  for (const pending of this.captureRequests.values()) {
2799
3567
  pending.reject(new Error("Realtime engine node is destroyed."));
@@ -2807,12 +3575,34 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2807
3575
  this.meterListeners.clear();
2808
3576
  this.scopeListeners.clear();
2809
3577
  this.midiOutListeners.clear();
3578
+ this.clipPageRequestListeners.clear();
2810
3579
  }
2811
3580
  emitTelemetry(telemetry) {
2812
3581
  for (const listener of this.telemetryListeners) {
2813
3582
  listener(telemetry);
2814
3583
  }
2815
3584
  }
3585
+ startRingPolling() {
3586
+ if (this.ringPollTimer !== void 0 || !this.telemetryRing && !this.meterRing && !this.scopeRing && !this.externalMidiRing) {
3587
+ return;
3588
+ }
3589
+ const poll = () => {
3590
+ if (!this.destroyed) {
3591
+ this.pollTelemetry();
3592
+ this.pollMeters();
3593
+ this.pollScope();
3594
+ this.pollMidiOut();
3595
+ }
3596
+ };
3597
+ poll();
3598
+ this.ringPollTimer = setInterval(poll, 16);
3599
+ }
3600
+ stopRingPollingIfUnused() {
3601
+ if (this.ringPollTimer !== void 0 && this.telemetryListeners.size === 0 && this.meterListeners.size === 0 && this.scopeListeners.size === 0 && this.midiOutListeners.size === 0) {
3602
+ clearInterval(this.ringPollTimer);
3603
+ this.ringPollTimer = void 0;
3604
+ }
3605
+ }
2816
3606
  emitMeter(meter) {
2817
3607
  for (const listener of this.meterListeners) {
2818
3608
  listener(meter);
@@ -2823,6 +3613,11 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2823
3613
  listener(events);
2824
3614
  }
2825
3615
  }
3616
+ emitClipPageRequests(message) {
3617
+ for (const listener of this.clipPageRequestListeners) {
3618
+ listener(message);
3619
+ }
3620
+ }
2826
3621
  emitScope(scope) {
2827
3622
  for (const listener of this.scopeListeners) {
2828
3623
  listener(scope);
@@ -2856,7 +3651,7 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2856
3651
  function setParam(ctx, nodeId, param, value) {
2857
3652
  const paramId = ctx.resolveParamId(nodeId, param);
2858
3653
  ctx.offlineEngine.setParameter(paramId, value);
2859
- return ctx.realtimeNode.sendCommand({
3654
+ return ctx.sendCommand({
2860
3655
  type: 0 /* SetParam */,
2861
3656
  targetId: paramId,
2862
3657
  sampleTime: -1,
@@ -2866,7 +3661,7 @@ function setParam(ctx, nodeId, param, value) {
2866
3661
  function setSoloMute(ctx, target, solo, mute) {
2867
3662
  const laneIndex = ctx.ensureTrackLane(target);
2868
3663
  ctx.offlineEngine.setSoloMute(laneIndex, solo, mute);
2869
- return ctx.realtimeNode.sendCommand({
3664
+ return ctx.sendCommand({
2870
3665
  type: 10 /* SetSoloMute */,
2871
3666
  targetId: laneIndex,
2872
3667
  sampleTime: -1,
@@ -2908,6 +3703,15 @@ function listParameters(ctx) {
2908
3703
  }
2909
3704
  return parameters;
2910
3705
  }
3706
+ function addParameter(ctx, info) {
3707
+ ctx.offlineEngine.addParameter(info);
3708
+ ctx.postSync({ type: "syncParameters", parameters: listParameters(ctx) });
3709
+ }
3710
+ function clearParameters(ctx) {
3711
+ ctx.offlineEngine.clearParameters();
3712
+ ctx.automationLanes.clear();
3713
+ ctx.postSync({ type: "syncParameters", parameters: [] });
3714
+ }
2911
3715
 
2912
3716
  // src/worklet/engine-strips.ts
2913
3717
  function trackIdFor(ctx, target) {
@@ -3022,6 +3826,11 @@ function pushMidiCc(ctx, trackId, group, channel, controller, value, renderFrame
3022
3826
  renderFrame
3023
3827
  });
3024
3828
  }
3829
+ function pushMidiUmp(ctx, trackId, word0, renderFrame) {
3830
+ const destinationId = ctx.resolveTargetId(trackId);
3831
+ ctx.offlineEngine.pushMidiUmp(destinationId, word0, renderFrame);
3832
+ ctx.postSync({ type: "syncMidiUmp", destinationId, word0, renderFrame });
3833
+ }
3025
3834
  function setBuiltinInstrument(ctx, trackId, config) {
3026
3835
  const destinationId = ctx.resolveTargetId(trackId);
3027
3836
  ctx.offlineEngine.setBuiltinInstrument(config, destinationId);
@@ -3082,11 +3891,6 @@ function setTempo(ctx, bpm) {
3082
3891
  ctx.setTempoSegments([{ startPpq: 0, bpm }]);
3083
3892
  ctx.offlineEngine.setTempo(bpm);
3084
3893
  postTempoSync(ctx);
3085
- ctx.realtimeNode.sendCommand({
3086
- type: 6 /* SetTempoMap */,
3087
- sampleTime: -1,
3088
- argFloat: bpm
3089
- });
3090
3894
  }
3091
3895
  function setTempoSegments(ctx, segments) {
3092
3896
  const copied = segments.map((segment) => ({ ...segment }));
@@ -3113,7 +3917,7 @@ function setTimeSignatureSegments(ctx, segments) {
3113
3917
  }
3114
3918
  function setLoop(ctx, startPpq, endPpq, enabled = true) {
3115
3919
  ctx.offlineEngine.setLoop(startPpq, endPpq, enabled);
3116
- return ctx.realtimeNode.sendCommand({
3920
+ return ctx.sendCommand({
3117
3921
  type: 7 /* SetLoop */,
3118
3922
  targetId: enabled ? 1 : 0,
3119
3923
  sampleTime: -1,
@@ -3134,6 +3938,19 @@ function cachedTransportState(ctx) {
3134
3938
  }
3135
3939
 
3136
3940
  // src/worklet/engine.ts
3941
+ var MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS = 256;
3942
+ function transferableAudioBuffers(channels) {
3943
+ const transfers = [];
3944
+ const seen = /* @__PURE__ */ new Set();
3945
+ for (const channel of channels) {
3946
+ const buffer = channel.buffer;
3947
+ if (buffer instanceof ArrayBuffer && !seen.has(buffer)) {
3948
+ seen.add(buffer);
3949
+ transfers.push(buffer);
3950
+ }
3951
+ }
3952
+ return transfers;
3953
+ }
3137
3954
  var SonareEngine = class _SonareEngine {
3138
3955
  constructor(context, realtimeNode, offlineEngine, sampleRate, offlineBlockSize, offlineChannelCount) {
3139
3956
  this.automationLanes = /* @__PURE__ */ new Map();
@@ -3157,6 +3974,11 @@ var SonareEngine = class _SonareEngine {
3157
3974
  this.nextMarkerId = 1;
3158
3975
  this.transportPlaying = false;
3159
3976
  this.pendingInstrumentSync = [];
3977
+ // One latest frontier per clip is sufficient: ClipPageStreamer expands it to
3978
+ // the bounded read window. A Map both coalesces repeated page misses and
3979
+ // places a hard cap on work queued while OPFS I/O is stalled.
3980
+ this.workletClipPageRequests = /* @__PURE__ */ new Map();
3981
+ this.workletPageProviderClipIds = /* @__PURE__ */ new Map();
3160
3982
  this.destroyed = false;
3161
3983
  this.context = context;
3162
3984
  this.realtimeNode = realtimeNode;
@@ -3166,10 +3988,17 @@ var SonareEngine = class _SonareEngine {
3166
3988
  this.sampleRate = sampleRate;
3167
3989
  this.offlineBlockSize = offlineBlockSize;
3168
3990
  this.offlineChannelCount = offlineChannelCount;
3991
+ this.unsubscribeWorkletClipRequests = this.realtimeNode.onClipPageRequests((message) => {
3992
+ for (const request of message.requests) {
3993
+ this.enqueueWorkletClipPageRequest(request);
3994
+ }
3995
+ this.pumpWorkletClipPages();
3996
+ });
3169
3997
  this.transport = buildTransportFacade({
3170
3998
  sampleRate: this.sampleRate,
3171
3999
  realtimeNode: this.realtimeNode,
3172
4000
  offlineEngine: this.offlineEngine,
4001
+ flushOfflineMirror: () => this.flushOfflineMirror(),
3173
4002
  setTransportPlaying: (playing) => {
3174
4003
  this.transportPlaying = playing;
3175
4004
  },
@@ -3187,8 +4016,14 @@ var SonareEngine = class _SonareEngine {
3187
4016
  Math.floor(options.offlineChannelCount ?? options.channelCount ?? 2)
3188
4017
  );
3189
4018
  const realtimeNode = await SonareRealtimeEngineNode.create(context, options);
4019
+ try {
4020
+ await realtimeNode.ready;
4021
+ } catch (error) {
4022
+ realtimeNode.destroy();
4023
+ throw error;
4024
+ }
3190
4025
  const offlineEngine = options.offlineEngine ?? new RealtimeEngine(sampleRate, blockSize);
3191
- return new _SonareEngine(
4026
+ const engine = new _SonareEngine(
3192
4027
  context,
3193
4028
  realtimeNode,
3194
4029
  offlineEngine,
@@ -3196,6 +4031,8 @@ var SonareEngine = class _SonareEngine {
3196
4031
  blockSize,
3197
4032
  channelCount
3198
4033
  );
4034
+ engine.syncParameters();
4035
+ return engine;
3199
4036
  }
3200
4037
  async suspend() {
3201
4038
  if (this.destroyed) {
@@ -3333,6 +4170,14 @@ var SonareEngine = class _SonareEngine {
3333
4170
  listParameters() {
3334
4171
  return listParameters(this.parameterContext);
3335
4172
  }
4173
+ /** Registers a custom parameter on the offline mirror and worklet engine. */
4174
+ addParameter(info) {
4175
+ addParameter(this.parameterContext, info);
4176
+ }
4177
+ /** Clears custom parameters and their automation lanes on both engines. */
4178
+ clearParameters() {
4179
+ clearParameters(this.parameterContext);
4180
+ }
3336
4181
  setSoloMute(target, solo, mute) {
3337
4182
  return setSoloMute(this.parameterContext, target, solo, mute);
3338
4183
  }
@@ -3468,6 +4313,76 @@ var SonareEngine = class _SonareEngine {
3468
4313
  setMasterChain(sceneJson) {
3469
4314
  this.setMasterStripJson(sceneJson);
3470
4315
  }
4316
+ /**
4317
+ * Creates and primes an OPFS-backed page provider for this live worklet
4318
+ * engine. Pass the returned `provider` to {@link addClip} in place of a
4319
+ * `Float32Array[]`; the `clipId` in `options` must equal that clip's explicit
4320
+ * `opts.id`. Subsequent cache misses are fetched on the main thread and
4321
+ * supplied back to the worklet through its bounded pull protocol.
4322
+ */
4323
+ async attachOpfsClipStream(options) {
4324
+ if (this.destroyed) {
4325
+ throw new Error("SonareEngine is destroyed.");
4326
+ }
4327
+ if (!this.capabilities.clipPageRequestsRealtimeSafe) {
4328
+ throw new Error(
4329
+ "OPFS clip streaming requires SharedArrayBuffer clip-page requests; the postMessage fallback is not realtime-safe."
4330
+ );
4331
+ }
4332
+ const { clipId, primePages = 1, ...providerOptions } = options;
4333
+ if ([...this.workletPageProviderClipIds.values()].includes(clipId)) {
4334
+ throw new Error(`An OPFS stream is already attached for clip ${clipId}.`);
4335
+ }
4336
+ const streamer = this.ensureWorkletClipStreamer();
4337
+ this.postSync({
4338
+ type: "syncClipPageProvider",
4339
+ clipId,
4340
+ numChannels: providerOptions.numChannels,
4341
+ numSamples: providerOptions.numSamples,
4342
+ pageFrames: providerOptions.pageFrames
4343
+ });
4344
+ let binding;
4345
+ binding = createOpfsClipPageProvider(this.offlineEngine, {
4346
+ ...providerOptions,
4347
+ onPageSupplied: (pageIndex, channels) => {
4348
+ this.postSync(
4349
+ { type: "syncClipPage", clipId, pageIndex, channels },
4350
+ transferableAudioBuffers(channels)
4351
+ );
4352
+ },
4353
+ onPageCleared: (pageIndex) => {
4354
+ this.postSync({ type: "syncClipPageClear", clipId, pageIndex });
4355
+ },
4356
+ onClose: () => {
4357
+ this.workletPageProviderClipIds.delete(binding.provider.id);
4358
+ this.postSync({ type: "syncClipPageDestroy", clipId });
4359
+ }
4360
+ });
4361
+ this.workletPageProviderClipIds.set(binding.provider.id, clipId);
4362
+ const lastPage = Math.ceil(providerOptions.numSamples / providerOptions.pageFrames) - 1;
4363
+ const primed = [];
4364
+ try {
4365
+ for (let page = 0; page < primePages && page <= lastPage; ++page) {
4366
+ if (await binding.supplyPage(page)) {
4367
+ primed.push(page);
4368
+ }
4369
+ }
4370
+ streamer.addSource(
4371
+ {
4372
+ clipId,
4373
+ binding,
4374
+ pageFrames: providerOptions.pageFrames,
4375
+ numSamples: providerOptions.numSamples
4376
+ },
4377
+ primed
4378
+ );
4379
+ this.startWorkletClipPagePolling();
4380
+ } catch (error) {
4381
+ binding.close();
4382
+ throw error;
4383
+ }
4384
+ return { binding, provider: binding.provider };
4385
+ }
3471
4386
  addClip(trackId, buffer, startPpq, opts = {}) {
3472
4387
  return addClip(this.clipContext, trackId, buffer, startPpq, opts);
3473
4388
  }
@@ -3529,9 +4444,59 @@ var SonareEngine = class _SonareEngine {
3529
4444
  pushMidiCc(trackId, group, channel, controller, value, renderFrame = -1) {
3530
4445
  pushMidiCc(this.stripContext, trackId, group, channel, controller, value, renderFrame);
3531
4446
  }
4447
+ pushMidiUmp(trackId, word0, renderFrame = -1) {
4448
+ pushMidiUmp(this.stripContext, trackId, word0, renderFrame);
4449
+ }
3532
4450
  pushMidiSysex(trackId, data, renderFrame = -1) {
3533
4451
  pushMidiSysex(this.stripContext, trackId, data, renderFrame);
3534
4452
  }
4453
+ bindMidiCc(channel, controller, paramId, options = {}) {
4454
+ const minValue = options.minValue ?? 0;
4455
+ const maxValue = options.maxValue ?? 1;
4456
+ this.offlineEngine.bindMidiCc(channel, controller, paramId, { minValue, maxValue });
4457
+ this.postSync({ type: "syncMidiCcBinding", channel, controller, paramId, minValue, maxValue });
4458
+ }
4459
+ setMidiInputSource(destinationId = 0) {
4460
+ this.offlineEngine.setMidiInputSource(destinationId);
4461
+ this.postSync({ type: "syncMidiInputSource", destinationId });
4462
+ }
4463
+ clearMidiInputSource() {
4464
+ this.offlineEngine.clearMidiInputSource();
4465
+ this.postSync({ type: "syncClearMidiInputSource" });
4466
+ }
4467
+ pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples = 0) {
4468
+ this.offlineEngine.pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples);
4469
+ this.postSync({
4470
+ type: "syncMidiInputNoteOn",
4471
+ group,
4472
+ channel,
4473
+ data0: note,
4474
+ data1: velocity,
4475
+ portTimeSamples
4476
+ });
4477
+ }
4478
+ pushMidiInputNoteOff(group, channel, note, velocity = 0, portTimeSamples = 0) {
4479
+ this.offlineEngine.pushMidiInputNoteOff(group, channel, note, velocity, portTimeSamples);
4480
+ this.postSync({
4481
+ type: "syncMidiInputNoteOff",
4482
+ group,
4483
+ channel,
4484
+ data0: note,
4485
+ data1: velocity,
4486
+ portTimeSamples
4487
+ });
4488
+ }
4489
+ pushMidiInputCc(group, channel, controller, value, portTimeSamples = 0) {
4490
+ this.offlineEngine.pushMidiInputCc(group, channel, controller, value, portTimeSamples);
4491
+ this.postSync({
4492
+ type: "syncMidiInputCc",
4493
+ group,
4494
+ channel,
4495
+ data0: controller,
4496
+ data1: value,
4497
+ portTimeSamples
4498
+ });
4499
+ }
3535
4500
  pushMidiPanic(renderFrame = -1) {
3536
4501
  this.offlineEngine.pushMidiPanic(renderFrame);
3537
4502
  this.postSync({ type: "syncMidiPanic", renderFrame });
@@ -3539,6 +4504,10 @@ var SonareEngine = class _SonareEngine {
3539
4504
  configureCapture(options) {
3540
4505
  configureCapture(this.captureContext, options);
3541
4506
  }
4507
+ /**
4508
+ * Arms the engine-global capture path. `trackId` is retained for source
4509
+ * compatibility and must be `0`; per-track capture is not implemented.
4510
+ */
3542
4511
  armRecord(trackId, enabled) {
3543
4512
  return armRecord(this.captureContext, trackId, enabled);
3544
4513
  }
@@ -3557,7 +4526,7 @@ var SonareEngine = class _SonareEngine {
3557
4526
  setMetronome(opts) {
3558
4527
  this.offlineEngine.setMetronome(opts);
3559
4528
  this.postSync({ type: "syncMetronome", config: opts });
3560
- this.realtimeNode.sendCommand({
4529
+ this.sendMirroredCommand({
3561
4530
  type: 15 /* SetMetronome */,
3562
4531
  sampleTime: -1,
3563
4532
  argInt: opts.enabled ? 1 : 0
@@ -3627,12 +4596,101 @@ var SonareEngine = class _SonareEngine {
3627
4596
  if (this.destroyed) {
3628
4597
  return;
3629
4598
  }
4599
+ this.unsubscribeWorkletClipRequests?.();
4600
+ this.unsubscribeWorkletClipRequests = void 0;
4601
+ this.workletClipStreamer?.close();
4602
+ this.workletClipStreamer = void 0;
4603
+ if (this.workletClipPagePollTimer !== void 0) {
4604
+ clearInterval(this.workletClipPagePollTimer);
4605
+ this.workletClipPagePollTimer = void 0;
4606
+ }
4607
+ this.workletClipPageRequests.clear();
3630
4608
  this.destroyed = true;
3631
4609
  this.transport.stop();
3632
4610
  this.realtimeNode.pollTelemetry();
3633
4611
  this.realtimeNode.destroy();
3634
4612
  this.offlineEngine.destroy();
3635
4613
  }
4614
+ ensureWorkletClipStreamer() {
4615
+ if (!this.workletClipStreamer) {
4616
+ this.workletClipStreamer = new ClipPageStreamer({
4617
+ popClipPageRequest: () => this.popWorkletClipPageRequest()
4618
+ });
4619
+ }
4620
+ return this.workletClipStreamer;
4621
+ }
4622
+ /**
4623
+ * SAB requests have no postMessage wake-up by design. Polling on the main
4624
+ * thread is therefore intentionally outside the audio callback; 8 ms keeps
4625
+ * the bounded OPFS prefetch frontier responsive without adding worklet GC.
4626
+ */
4627
+ startWorkletClipPagePolling() {
4628
+ if (this.workletClipPagePollTimer !== void 0) {
4629
+ return;
4630
+ }
4631
+ const poll = () => {
4632
+ if (!this.destroyed) {
4633
+ this.realtimeNode.pollClipPageRequests();
4634
+ }
4635
+ };
4636
+ poll();
4637
+ this.workletClipPagePollTimer = setInterval(poll, 8);
4638
+ }
4639
+ pumpWorkletClipPages() {
4640
+ const streamer = this.workletClipStreamer;
4641
+ if (!streamer || this.workletClipPump) {
4642
+ return;
4643
+ }
4644
+ this.workletClipPump = streamer.pump().catch((error) => {
4645
+ console.error("Sonare OPFS clip-page supply failed:", error);
4646
+ }).finally(() => {
4647
+ this.workletClipPump = void 0;
4648
+ if (this.workletClipPageRequests.size > 0) {
4649
+ this.pumpWorkletClipPages();
4650
+ }
4651
+ });
4652
+ }
4653
+ enqueueWorkletClipPageRequest(request) {
4654
+ if (!Number.isInteger(request.clipId) || request.clipId < 0 || !Number.isInteger(request.pageIndex) || (request.pageIndex ?? -1) < 0) {
4655
+ return;
4656
+ }
4657
+ this.workletClipPageRequests.delete(request.clipId);
4658
+ if (this.workletClipPageRequests.size >= MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS) {
4659
+ const oldest = this.workletClipPageRequests.keys().next().value;
4660
+ if (oldest !== void 0) {
4661
+ this.workletClipPageRequests.delete(oldest);
4662
+ }
4663
+ }
4664
+ this.workletClipPageRequests.set(request.clipId, request);
4665
+ }
4666
+ popWorkletClipPageRequest() {
4667
+ const entry = this.workletClipPageRequests.entries().next().value;
4668
+ if (!entry) {
4669
+ return null;
4670
+ }
4671
+ const [clipId, request] = entry;
4672
+ this.workletClipPageRequests.delete(clipId);
4673
+ return request;
4674
+ }
4675
+ commitWorkletClipPageProvider(clip) {
4676
+ const providerId = typeof clip.pageProvider === "object" && clip.pageProvider !== null ? clip.pageProvider.id : clip.pageProvider;
4677
+ if (providerId === void 0) {
4678
+ return false;
4679
+ }
4680
+ const clipId = this.workletPageProviderClipIds.get(providerId);
4681
+ if (clipId === void 0) {
4682
+ return false;
4683
+ }
4684
+ if (clip.id !== clipId) {
4685
+ throw new Error(`OPFS stream clipId ${clipId} must match addClip(..., { id: ${clipId} }).`);
4686
+ }
4687
+ this.postSync({
4688
+ type: "syncClipPageCommit",
4689
+ clipId,
4690
+ clip: { ...clip, channels: void 0, pageProvider: void 0 }
4691
+ });
4692
+ return true;
4693
+ }
3636
4694
  mixerLanes() {
3637
4695
  return mixerLanes(this.mixerContext);
3638
4696
  }
@@ -3665,12 +4723,27 @@ var SonareEngine = class _SonareEngine {
3665
4723
  if (this.destroyed) {
3666
4724
  return;
3667
4725
  }
3668
- if (transfer && transfer.length > 0) {
3669
- this.realtimeNode.node.port.postMessage(message, transfer);
3670
- } else {
3671
- this.realtimeNode.node.port.postMessage(message);
4726
+ try {
4727
+ if (transfer && transfer.length > 0) {
4728
+ this.realtimeNode.node.port.postMessage(message, transfer);
4729
+ } else {
4730
+ this.realtimeNode.node.port.postMessage(message);
4731
+ }
4732
+ } finally {
4733
+ this.flushOfflineMirror();
3672
4734
  }
3673
4735
  }
4736
+ // The offline engine is a control-thread mirror. It has no render loop to
4737
+ // drain its command ring, so drain it after each control operation. Never
4738
+ // call this from the worklet/audio path: it is intentionally control-only.
4739
+ flushOfflineMirror() {
4740
+ this.offlineEngine.flushControlCommands();
4741
+ }
4742
+ sendMirroredCommand(command) {
4743
+ const accepted = this.realtimeNode.sendCommand(command);
4744
+ this.flushOfflineMirror();
4745
+ return accepted;
4746
+ }
3674
4747
  // Collaborator surface handed to the mixer/routing free functions so they can
3675
4748
  // mutate the routing stores (held by reference), mirror into the offline
3676
4749
  // engine, post mixer-sync messages, and declare lanes/buses without a
@@ -3725,13 +4798,13 @@ var SonareEngine = class _SonareEngine {
3725
4798
  return {
3726
4799
  offlineEngine: this.offlineEngine,
3727
4800
  realtimeNode: this.realtimeNode,
4801
+ sendCommand: (command) => this.sendMirroredCommand(command),
3728
4802
  offlineChannelCount: this.offlineChannelCount,
3729
4803
  postSync: (message) => this.postSync(message),
3730
4804
  getCaptureConfig: () => this.captureConfig,
3731
4805
  setCaptureConfig: (config) => {
3732
4806
  this.captureConfig = config;
3733
- },
3734
- resolveTargetId: (target) => this.resolveTargetId(target)
4807
+ }
3735
4808
  };
3736
4809
  }
3737
4810
  // Collaborator surface handed to the parameter / automation-id resolution
@@ -3741,7 +4814,9 @@ var SonareEngine = class _SonareEngine {
3741
4814
  get parameterContext() {
3742
4815
  return {
3743
4816
  offlineEngine: this.offlineEngine,
3744
- realtimeNode: this.realtimeNode,
4817
+ sendCommand: (command) => this.sendMirroredCommand(command),
4818
+ postSync: (message) => this.postSync(message),
4819
+ automationLanes: this.automationLanes,
3745
4820
  trackLaneIds: this.trackLaneIds,
3746
4821
  resolveParamId: (nodeId, param) => this.resolveParamId(nodeId, param),
3747
4822
  ensureTrackLane: (target) => this.ensureTrackLane(target),
@@ -3755,6 +4830,7 @@ var SonareEngine = class _SonareEngine {
3755
4830
  return {
3756
4831
  offlineEngine: this.offlineEngine,
3757
4832
  realtimeNode: this.realtimeNode,
4833
+ sendCommand: (command) => this.sendMirroredCommand(command),
3758
4834
  postSync: (message) => this.postSync(message),
3759
4835
  getTempoBpm: () => this.tempoBpm,
3760
4836
  setTempoBpm: (bpm) => {
@@ -3789,7 +4865,8 @@ var SonareEngine = class _SonareEngine {
3789
4865
  allocateClipId: () => this.nextClipId++,
3790
4866
  postSync: (message, transfer) => this.postSync(message, transfer),
3791
4867
  ensureTrackLane: (target) => this.ensureTrackLane(target),
3792
- resolveTargetId: (target) => this.resolveTargetId(target)
4868
+ resolveTargetId: (target) => this.resolveTargetId(target),
4869
+ commitWorkletClipPageProvider: (clip) => this.commitWorkletClipPageProvider(clip)
3793
4870
  };
3794
4871
  }
3795
4872
  // Collaborator surface handed to the marker free functions so they can mutate
@@ -3804,7 +4881,7 @@ var SonareEngine = class _SonareEngine {
3804
4881
  this.nextMarkerId = value;
3805
4882
  },
3806
4883
  postSync: (message) => this.postSync(message),
3807
- sendCommand: (command) => this.realtimeNode.sendCommand(command),
4884
+ sendCommand: (command) => this.sendMirroredCommand(command),
3808
4885
  setLoop: (startPpq, endPpq, enabled) => this.setLoop(startPpq, endPpq, enabled)
3809
4886
  };
3810
4887
  }
@@ -3820,7 +4897,7 @@ var SonareEngine = class _SonareEngine {
3820
4897
  // sample-accurate smoothed-param command to the realtime runtime.
3821
4898
  sendSmoothedParam(paramId, value) {
3822
4899
  this.offlineEngine.setParameter(paramId, value);
3823
- return this.realtimeNode.sendCommand({
4900
+ return this.sendMirroredCommand({
3824
4901
  type: 1 /* SetParamSmoothed */,
3825
4902
  targetId: paramId,
3826
4903
  sampleTime: -1,
@@ -3830,6 +4907,12 @@ var SonareEngine = class _SonareEngine {
3830
4907
  resolveParamId(nodeId, param) {
3831
4908
  return resolveParamId(this.listParameters(), nodeId, param);
3832
4909
  }
4910
+ syncParameters() {
4911
+ const parameters = this.listParameters();
4912
+ if (parameters.length > 0) {
4913
+ this.postSync({ type: "syncParameters", parameters });
4914
+ }
4915
+ }
3833
4916
  resolveTargetId(target) {
3834
4917
  return resolveTargetId(target);
3835
4918
  }
@@ -3865,15 +4948,17 @@ var SonareEngine = class _SonareEngine {
3865
4948
  var DEFAULT_METRONOME_CONFIG = {
3866
4949
  beatGain: 0.35,
3867
4950
  accentGain: 0.7,
3868
- clickSamples: 0
4951
+ clickSamples: 0,
4952
+ clickSeconds: 0
3869
4953
  };
3870
4954
  function resolveMetronomeConfig(config) {
3871
4955
  const resolved = {
3872
4956
  beatGain: config.beatGain ?? DEFAULT_METRONOME_CONFIG.beatGain,
3873
4957
  accentGain: config.accentGain ?? DEFAULT_METRONOME_CONFIG.accentGain,
3874
- clickSamples: config.clickSamples ?? DEFAULT_METRONOME_CONFIG.clickSamples
4958
+ clickSamples: config.clickSamples ?? DEFAULT_METRONOME_CONFIG.clickSamples,
4959
+ clickSeconds: config.clickSeconds ?? DEFAULT_METRONOME_CONFIG.clickSeconds
3875
4960
  };
3876
- if (!Number.isFinite(resolved.beatGain) || resolved.beatGain < 0 || !Number.isFinite(resolved.accentGain) || resolved.accentGain < 0 || !Number.isInteger(resolved.clickSamples) || resolved.clickSamples < 0 || resolved.clickSamples > 384e3 || config.clickSeconds !== void 0 && (!Number.isFinite(config.clickSeconds) || config.clickSeconds < 0 || config.clickSeconds > 1)) {
4961
+ if (!Number.isFinite(resolved.beatGain) || resolved.beatGain < 0 || !Number.isFinite(resolved.accentGain) || resolved.accentGain < 0 || !Number.isInteger(resolved.clickSamples) || resolved.clickSamples < 0 || !Number.isFinite(resolved.clickSeconds) || resolved.clickSeconds < 0 || resolved.clickSamples > 384e3 || config.clickSeconds !== void 0 && (!Number.isFinite(config.clickSeconds) || config.clickSeconds < 0 || config.clickSeconds > 1)) {
3877
4962
  throw new RangeError("invalid metronome gains or click length");
3878
4963
  }
3879
4964
  return resolved;
@@ -3889,7 +4974,15 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
3889
4974
  this.metronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
3890
4975
  this.liveClips = /* @__PURE__ */ new Map();
3891
4976
  this.pagedClipProviders = /* @__PURE__ */ new Map();
4977
+ this.pagedClipPageFrames = /* @__PURE__ */ new Map();
3892
4978
  this.pendingPagedClips = /* @__PURE__ */ new Map();
4979
+ // The worklet drains at most this many distinct page misses per render
4980
+ // quantum. The fixed scalar buffers avoid retaining an unbounded queue on
4981
+ // the audio thread; any remaining native requests stay queued for the next
4982
+ // quantum and render silence meanwhile.
4983
+ this.clipPageRequestClipIds = new Float64Array(64);
4984
+ this.clipPageRequestPageIndices = new Float64Array(64);
4985
+ this.clipPageRequestOverflowReported = 0;
3893
4986
  this.sampleRate = options.sampleRate ?? 48e3;
3894
4987
  this.blockSize = options.blockSize ?? 128;
3895
4988
  this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
@@ -3906,7 +4999,21 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
3906
4999
  options.scopeRingCapacity,
3907
5000
  options.scopeBands
3908
5001
  ) : void 0;
3909
- this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
5002
+ this.clipPageRequestRing = options.clipPageRequestSharedBuffer ? clipPageRequestRingFromSharedBuffer(
5003
+ options.clipPageRequestSharedBuffer,
5004
+ options.clipPageRequestRingCapacity
5005
+ ) : void 0;
5006
+ this.externalMidiRing = options.externalMidiSharedBuffer ? externalMidiRingFromSharedBuffer(
5007
+ options.externalMidiSharedBuffer,
5008
+ options.externalMidiRingCapacity
5009
+ ) : void 0;
5010
+ this.engine = new RealtimeEngine(
5011
+ this.sampleRate,
5012
+ this.blockSize,
5013
+ 1024,
5014
+ 1024,
5015
+ this.channelCount
5016
+ );
3910
5017
  this.engine.prepareChannels(this.channelCount, this.blockSize);
3911
5018
  this.channelBuffers = new Array(this.channelCount);
3912
5019
  for (let ch = 0; ch < this.channelCount; ch++) {
@@ -3971,6 +5078,7 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
3971
5078
  target.fill(0);
3972
5079
  }
3973
5080
  }
5081
+ this.publishClipPageRequests();
3974
5082
  this.publishTelemetry();
3975
5083
  this.publishMeters();
3976
5084
  this.publishScope();
@@ -3984,7 +5092,7 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
3984
5092
  }
3985
5093
  receiveCommand(command) {
3986
5094
  if (!this.closed) {
3987
- this.applyCommand(command);
5095
+ this.safeApplyCommand(command);
3988
5096
  }
3989
5097
  }
3990
5098
  // Applies an out-of-band control-plane sync message on the AudioWorklet
@@ -3995,35 +5103,64 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
3995
5103
  if (this.closed) {
3996
5104
  return;
3997
5105
  }
5106
+ try {
5107
+ this.applySync(message);
5108
+ } catch (error) {
5109
+ this.transport?.postMessage?.({
5110
+ type: "syncError",
5111
+ syncType: message.type,
5112
+ message: error instanceof Error ? error.message : String(error)
5113
+ });
5114
+ }
5115
+ }
5116
+ applySync(message) {
3998
5117
  switch (message.type) {
3999
- case "syncClips":
5118
+ case "destroy":
5119
+ this.destroy();
5120
+ return;
5121
+ case "syncClips": {
5122
+ this.engine.setClips(message.clips);
5123
+ this.clearPagedClipProviders();
4000
5124
  this.liveClips.clear();
4001
5125
  for (const clip of message.clips) {
4002
5126
  if (clip.id !== void 0) {
4003
5127
  this.liveClips.set(clip.id, clip);
4004
5128
  }
4005
5129
  }
4006
- this.engine.setClips(message.clips);
4007
5130
  break;
4008
- case "syncClipsDelta":
5131
+ }
5132
+ case "syncClipsDelta": {
5133
+ const nextClips = new Map(this.liveClips);
4009
5134
  for (const clipId of message.removeIds) {
4010
- this.liveClips.delete(clipId);
5135
+ nextClips.delete(clipId);
4011
5136
  }
4012
5137
  for (const clip of message.upserts) {
4013
5138
  if (clip.id !== void 0) {
4014
- this.liveClips.set(clip.id, clip);
5139
+ nextClips.set(clip.id, clip);
4015
5140
  }
4016
5141
  }
4017
- this.engine.setClips(Array.from(this.liveClips.values()));
5142
+ this.engine.setClips(Array.from(nextClips.values()));
5143
+ for (const clipId of message.removeIds) {
5144
+ this.removePagedClipProvider(clipId);
5145
+ }
5146
+ this.liveClips.clear();
5147
+ for (const [clipId, clip] of nextClips) {
5148
+ this.liveClips.set(clipId, clip);
5149
+ }
4018
5150
  break;
5151
+ }
4019
5152
  case "syncClipPageProvider": {
4020
5153
  const provider = this.engine.createClipPageProvider(
4021
5154
  message.numChannels,
4022
5155
  message.numSamples,
4023
5156
  message.pageFrames
4024
5157
  );
5158
+ this.removePagedClipProvider(message.clipId);
4025
5159
  this.pagedClipProviders.set(message.clipId, provider.id);
4026
- this.pendingPagedClips.set(message.clipId, message.clip);
5160
+ this.pagedClipPageFrames.set(message.clipId, message.pageFrames);
5161
+ if (message.clip) {
5162
+ this.pendingPagedClips.set(message.clipId, message.clip);
5163
+ }
4027
5164
  break;
4028
5165
  }
4029
5166
  case "syncClipPage": {
@@ -4033,16 +5170,34 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4033
5170
  }
4034
5171
  break;
4035
5172
  }
5173
+ case "syncClipPageClear": {
5174
+ const providerId = this.pagedClipProviders.get(message.clipId);
5175
+ if (providerId !== void 0) {
5176
+ this.engine.clearClipPage(providerId, message.pageIndex);
5177
+ }
5178
+ break;
5179
+ }
4036
5180
  case "syncClipPageCommit": {
4037
5181
  const providerId = this.pagedClipProviders.get(message.clipId);
4038
- const clip = this.pendingPagedClips.get(message.clipId);
5182
+ const clip = message.clip ?? this.pendingPagedClips.get(message.clipId);
4039
5183
  if (providerId !== void 0 && clip) {
4040
- this.liveClips.set(message.clipId, { ...clip, pageProvider: providerId });
5184
+ const nextClip = { ...clip, pageProvider: providerId };
5185
+ const nextClips = new Map(this.liveClips);
5186
+ nextClips.set(message.clipId, nextClip);
5187
+ this.engine.setClips(Array.from(nextClips.values()));
5188
+ this.liveClips.set(message.clipId, nextClip);
4041
5189
  this.pendingPagedClips.delete(message.clipId);
4042
- this.engine.setClips(Array.from(this.liveClips.values()));
4043
5190
  }
4044
5191
  break;
4045
5192
  }
5193
+ case "syncClipPageDestroy": {
5194
+ const nextClips = new Map(this.liveClips);
5195
+ nextClips.delete(message.clipId);
5196
+ this.engine.setClips(Array.from(nextClips.values()));
5197
+ this.liveClips.delete(message.clipId);
5198
+ this.removePagedClipProvider(message.clipId);
5199
+ break;
5200
+ }
4046
5201
  case "syncMidiClips":
4047
5202
  this.engine.setMidiClips(message.clips);
4048
5203
  break;
@@ -4050,12 +5205,21 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4050
5205
  this.engine.setMarkers(message.markers);
4051
5206
  break;
4052
5207
  case "syncMetronome":
4053
- this.metronomeConfig = resolveMetronomeConfig(message.config);
4054
- this.engine.setMetronome(message.config);
5208
+ {
5209
+ const config = resolveMetronomeConfig(message.config);
5210
+ this.engine.setMetronome(message.config);
5211
+ this.metronomeConfig = config;
5212
+ }
4055
5213
  break;
4056
5214
  case "syncAutomation":
4057
5215
  this.engine.setAutomationLane(message.paramId, message.points);
4058
5216
  break;
5217
+ case "syncParameters":
5218
+ this.engine.clearParameters();
5219
+ for (const info of message.parameters) {
5220
+ this.engine.addParameter(info);
5221
+ }
5222
+ break;
4059
5223
  case "syncTempo":
4060
5224
  if (message.tempoSegments) {
4061
5225
  this.engine.setTempoSegments(message.tempoSegments);
@@ -4202,6 +5366,9 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4202
5366
  message.renderFrame
4203
5367
  );
4204
5368
  break;
5369
+ case "syncMidiUmp":
5370
+ this.engine.pushMidiUmp(message.destinationId, message.word0, message.renderFrame);
5371
+ break;
4205
5372
  case "syncMidiSysex":
4206
5373
  this.engine.pushMidiSysex(message.destinationId, message.data, message.renderFrame);
4207
5374
  break;
@@ -4214,6 +5381,45 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4214
5381
  case "syncExternalMidiClock":
4215
5382
  this.engine.setExternalMidiClockEnabled(message.enabled);
4216
5383
  break;
5384
+ case "syncMidiInputSource":
5385
+ this.engine.setMidiInputSource(message.destinationId ?? 0);
5386
+ break;
5387
+ case "syncClearMidiInputSource":
5388
+ this.engine.clearMidiInputSource();
5389
+ break;
5390
+ case "syncMidiCcBinding":
5391
+ this.engine.bindMidiCc(message.channel, message.controller, message.paramId, {
5392
+ minValue: message.minValue,
5393
+ maxValue: message.maxValue
5394
+ });
5395
+ break;
5396
+ case "syncMidiInputNoteOn":
5397
+ this.engine.pushMidiInputNoteOn(
5398
+ message.group,
5399
+ message.channel,
5400
+ message.data0,
5401
+ message.data1,
5402
+ message.portTimeSamples
5403
+ );
5404
+ break;
5405
+ case "syncMidiInputNoteOff":
5406
+ this.engine.pushMidiInputNoteOff(
5407
+ message.group,
5408
+ message.channel,
5409
+ message.data0,
5410
+ message.data1,
5411
+ message.portTimeSamples
5412
+ );
5413
+ break;
5414
+ case "syncMidiInputCc":
5415
+ this.engine.pushMidiInputCc(
5416
+ message.group,
5417
+ message.channel,
5418
+ message.data0,
5419
+ message.data1,
5420
+ message.portTimeSamples
5421
+ );
5422
+ break;
4217
5423
  }
4218
5424
  }
4219
5425
  receiveCaptureRequest(message) {
@@ -4294,6 +5500,7 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4294
5500
  }
4295
5501
  destroy() {
4296
5502
  if (!this.closed) {
5503
+ this.clearPagedClipProviders();
4297
5504
  this.engine.destroy();
4298
5505
  this.closed = true;
4299
5506
  }
@@ -4307,7 +5514,22 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4307
5514
  if (!command) {
4308
5515
  return;
4309
5516
  }
5517
+ this.safeApplyCommand(command);
5518
+ }
5519
+ }
5520
+ safeApplyCommand(command) {
5521
+ try {
4310
5522
  this.applyCommand(command);
5523
+ } catch {
5524
+ this.publishTelemetryRecord({
5525
+ type: 1 /* Error */,
5526
+ error: 19 /* InvalidCommand */,
5527
+ renderFrame: 0,
5528
+ timelineSample: 0,
5529
+ audibleTimelineSample: 0,
5530
+ graphLatencySamplesQ8: 0,
5531
+ value: Number(command.type)
5532
+ });
4311
5533
  }
4312
5534
  }
4313
5535
  applyCommand(command) {
@@ -4339,9 +5561,6 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4339
5561
  case 5 /* TransportSeekPpq */:
4340
5562
  this.engine.seekPpq(Number(command.argFloat ?? 0), sampleTime);
4341
5563
  break;
4342
- case 6 /* SetTempoMap */:
4343
- this.engine.setTempo(Number(command.argFloat ?? 120));
4344
- break;
4345
5564
  case 7 /* SetLoop */:
4346
5565
  this.engine.setLoop(
4347
5566
  Number(command.argFloat ?? 0),
@@ -4364,7 +5583,8 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4364
5583
  enabled: Boolean(command.argInt),
4365
5584
  beatGain: this.metronomeConfig.beatGain,
4366
5585
  accentGain: this.metronomeConfig.accentGain,
4367
- clickSamples: this.metronomeConfig.clickSamples
5586
+ clickSamples: this.metronomeConfig.clickSamples,
5587
+ clickSeconds: this.metronomeConfig.clickSeconds
4368
5588
  });
4369
5589
  break;
4370
5590
  case 17 /* SeekMarker */:
@@ -4392,10 +5612,34 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4392
5612
  }
4393
5613
  }
4394
5614
  publishTelemetry() {
5615
+ const ring = this.telemetryRing;
5616
+ if (ring) {
5617
+ for (let count = 0; count < 64 && this.engine.popTelemetryToScratch(); count++) {
5618
+ this.writeTelemetryScratch(ring);
5619
+ }
5620
+ return;
5621
+ }
4395
5622
  for (const item of this.engine.drainTelemetry(64)) {
4396
5623
  this.publishTelemetryRecord(telemetryFromEngine(item));
4397
5624
  }
4398
5625
  }
5626
+ writeTelemetryScratch(ring) {
5627
+ const writeIndex = Atomics.load(ring.header, 0);
5628
+ const offset = writeIndex % ring.capacity * SONARE_ENGINE_TELEMETRY_RECORD_BYTES;
5629
+ ring.view.setUint32(offset, this.engine.telemetryScratchType(), true);
5630
+ ring.view.setUint32(offset + 4, this.engine.telemetryScratchError(), true);
5631
+ writeInt64Words(ring.view, offset + 8, this.engine.telemetryScratchRenderFrame());
5632
+ writeInt64Words(ring.view, offset + 16, this.engine.telemetryScratchTimelineSample());
5633
+ writeInt64Words(ring.view, offset + 24, this.engine.telemetryScratchAudibleTimelineSample());
5634
+ ring.view.setInt32(offset + 32, this.engine.telemetryScratchGraphLatencySamplesQ8(), true);
5635
+ ring.view.setUint32(offset + 36, this.engine.telemetryScratchValue(), true);
5636
+ ring.view.setUint32(offset + 40, 0, true);
5637
+ ring.view.setUint32(offset + 44, 0, true);
5638
+ Atomics.store(ring.header, 0, writeIndex + 1);
5639
+ if (writeIndex + 1 > ring.capacity) {
5640
+ Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
5641
+ }
5642
+ }
4399
5643
  publishTelemetryRecord(record) {
4400
5644
  if (this.telemetryRing) {
4401
5645
  writeSonareEngineTelemetryRingBuffer(this.telemetryRing, record);
@@ -4418,6 +5662,19 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4418
5662
  if (this.meterIntervalFrames <= 0 || !this.transport && !this.meterRing) {
4419
5663
  return;
4420
5664
  }
5665
+ if (this.meterRing) {
5666
+ for (let count = 0; count < 64 && this.engine.popMeterTelemetryToScratch(); count++) {
5667
+ const frame = this.engine.meterScratchRenderFrame();
5668
+ if (frame !== this.lastMeterFrame && frame - this.lastMeterFrame < this.meterIntervalFrames) {
5669
+ continue;
5670
+ }
5671
+ if (frame !== this.lastMeterFrame) {
5672
+ this.lastMeterFrame = frame;
5673
+ }
5674
+ this.writeMeterScratch(this.meterRing);
5675
+ }
5676
+ return;
5677
+ }
4421
5678
  for (const item of this.engine.drainMeterTelemetry(64)) {
4422
5679
  const meter = meterFromEngine(item);
4423
5680
  if (meter.frame !== this.lastMeterFrame && meter.frame - this.lastMeterFrame < this.meterIntervalFrames) {
@@ -4434,6 +5691,18 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4434
5691
  }
4435
5692
  }
4436
5693
  }
5694
+ writeMeterScratch(ring) {
5695
+ const writeIndex = Atomics.load(ring.header, 0);
5696
+ const offset = writeIndex % ring.capacity * SONARE_METER_RING_RECORD_FLOATS;
5697
+ const frame = Number(this.engine.meterScratchRenderFrame());
5698
+ ring.records[offset] = encodeFrameLo(frame);
5699
+ ring.records[offset + 1] = encodeFrameHi(frame);
5700
+ ring.records[offset + 2] = this.engine.meterScratchTargetId();
5701
+ for (let field = 0; field < 11; field++) {
5702
+ ring.records[offset + 3 + field] = this.engine.meterScratchValue(field);
5703
+ }
5704
+ Atomics.store(ring.header, 0, writeIndex + 1);
5705
+ }
4437
5706
  writeMeterRing(meter) {
4438
5707
  const ring = this.meterRing;
4439
5708
  if (!ring) {
@@ -4465,45 +5734,160 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4465
5734
  if (!ring) {
4466
5735
  return;
4467
5736
  }
4468
- for (const item of this.engine.drainScopeTelemetry(64)) {
4469
- this.writeScopeRing(ring, item);
5737
+ for (let count = 0; count < 64 && this.engine.popScopeTelemetryToScratch(); count++) {
5738
+ this.writeScopeScratch(ring);
5739
+ }
5740
+ }
5741
+ writeScopeScratch(ring) {
5742
+ const writeIndex = Atomics.load(ring.header, 0);
5743
+ const base = writeIndex % ring.capacity * ring.recordFloats;
5744
+ const frame = Number(this.engine.scopeScratchRenderFrame());
5745
+ ring.records[base] = encodeFrameLo(frame);
5746
+ ring.records[base + 1] = encodeFrameHi(frame);
5747
+ ring.records[base + 2] = this.engine.scopeScratchTargetId();
5748
+ const bandCount = Math.min(ring.bands, this.engine.scopeScratchBandCount());
5749
+ ring.records[base + 3] = bandCount;
5750
+ const pointCount = Math.min(ring.maxPoints, this.engine.scopeScratchPointCount());
5751
+ ring.records[base + 4] = pointCount;
5752
+ const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
5753
+ for (let i = 0; i < bandCount; i++) {
5754
+ ring.records[bandsBase + i] = this.engine.scopeScratchBand(i);
5755
+ }
5756
+ const pointsBase = bandsBase + ring.bands;
5757
+ for (let i = 0; i < pointCount; i++) {
5758
+ ring.records[pointsBase + 2 * i] = this.engine.scopeScratchPointLeft(i);
5759
+ ring.records[pointsBase + 2 * i + 1] = this.engine.scopeScratchPointRight(i);
4470
5760
  }
5761
+ Atomics.store(ring.header, 0, writeIndex + 1);
4471
5762
  }
4472
5763
  // Drains queued external-MIDI events (already lowered to MIDI 1.0 bytes) and
4473
5764
  // forwards them to the main thread for delivery to Web MIDI output ports.
4474
5765
  // One batch per render block; skipped entirely when nothing is queued, so an
4475
5766
  // all-internal project never allocates or posts here.
4476
5767
  publishExternalMidi() {
5768
+ const ring = this.externalMidiRing;
5769
+ if (ring) {
5770
+ for (let count = 0; count < 256 && this.engine.popExternalMidiToScratch(); count++) {
5771
+ pushSonareExternalMidiRingBuffer(
5772
+ ring,
5773
+ this.engine.externalMidiScratchDestinationId(),
5774
+ this.engine.externalMidiScratchRenderFrame(),
5775
+ this.engine.externalMidiScratchByteWord(),
5776
+ this.engine.externalMidiScratchByteCount()
5777
+ );
5778
+ this.engine.consumeExternalMidiScratch();
5779
+ }
5780
+ return;
5781
+ }
4477
5782
  if (!this.transport?.postMessage) {
4478
5783
  return;
4479
5784
  }
5785
+ if (this.engine.externalMidiPendingCount() === 0) {
5786
+ return;
5787
+ }
4480
5788
  const events = this.engine.drainExternalMidi(256);
4481
5789
  if (events.length === 0) {
4482
5790
  return;
4483
5791
  }
4484
5792
  this.transport.postMessage({ type: "externalMidi", events });
4485
5793
  }
4486
- writeScopeRing(ring, record) {
4487
- const writeIndex = Atomics.load(ring.header, 0);
4488
- const base = writeIndex % ring.capacity * ring.recordFloats;
4489
- ring.records[base] = encodeFrameLo(record.renderFrame);
4490
- ring.records[base + 1] = encodeFrameHi(record.renderFrame);
4491
- ring.records[base + 2] = record.targetId;
4492
- const bandCount = Math.min(ring.bands, record.bands.length);
4493
- ring.records[base + 3] = bandCount;
4494
- const pointCount = Math.min(ring.maxPoints, record.points.length);
4495
- ring.records[base + 4] = pointCount;
4496
- const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
4497
- for (let i = 0; i < bandCount; i++) {
4498
- ring.records[bandsBase + i] = record.bands[i];
5794
+ /**
5795
+ * Forward a bounded batch of native page misses to the main thread. This
5796
+ * deliberately runs after audio rendering: a cache miss is silence for this
5797
+ * block, and OPFS I/O must never delay `process()`.
5798
+ */
5799
+ publishClipPageRequests() {
5800
+ const ring = this.clipPageRequestRing;
5801
+ const transport = this.transport;
5802
+ if (!ring && !transport?.postMessage) {
5803
+ return;
4499
5804
  }
4500
- const pointsBase = bandsBase + ring.bands;
4501
- for (let i = 0; i < pointCount; i++) {
4502
- const point = record.points[i];
4503
- ring.records[pointsBase + 2 * i] = point.left;
4504
- ring.records[pointsBase + 2 * i + 1] = point.right;
5805
+ let count = 0;
5806
+ let drained = 0;
5807
+ while (drained < this.clipPageRequestClipIds.length) {
5808
+ drained += 1;
5809
+ let clipId;
5810
+ let sample;
5811
+ if (ring) {
5812
+ if (!this.engine.popClipPageRequestToScratch()) {
5813
+ break;
5814
+ }
5815
+ clipId = this.engine.clipPageRequestScratchClipId();
5816
+ sample = this.engine.clipPageRequestScratchSample();
5817
+ } else {
5818
+ const request = this.engine.popClipPageRequest();
5819
+ if (!request) {
5820
+ break;
5821
+ }
5822
+ clipId = request.clipId;
5823
+ sample = request.sample;
5824
+ }
5825
+ const pageFrames = this.pagedClipPageFrames.get(clipId);
5826
+ if (!pageFrames) {
5827
+ continue;
5828
+ }
5829
+ const pageIndex = Math.floor(sample / pageFrames);
5830
+ let duplicate = false;
5831
+ for (let i = 0; i < count; ++i) {
5832
+ if (this.clipPageRequestClipIds[i] === clipId && this.clipPageRequestPageIndices[i] === pageIndex) {
5833
+ duplicate = true;
5834
+ break;
5835
+ }
5836
+ }
5837
+ if (!duplicate) {
5838
+ this.clipPageRequestClipIds[count] = clipId;
5839
+ this.clipPageRequestPageIndices[count] = pageIndex;
5840
+ count += 1;
5841
+ }
5842
+ }
5843
+ const overflowCount = this.engine.clipPageRequestOverflowCount();
5844
+ const dropped = overflowCount - this.clipPageRequestOverflowReported >>> 0;
5845
+ this.clipPageRequestOverflowReported = overflowCount;
5846
+ if (ring) {
5847
+ if (dropped > 0) {
5848
+ Atomics.add(ring.header, 4, dropped);
5849
+ }
5850
+ for (let i = 0; i < count; ++i) {
5851
+ pushSonareClipPageRequestRingBuffer(
5852
+ ring,
5853
+ this.clipPageRequestClipIds[i],
5854
+ this.clipPageRequestPageIndices[i]
5855
+ );
5856
+ }
5857
+ return;
5858
+ }
5859
+ if (count === 0 && dropped === 0) {
5860
+ return;
5861
+ }
5862
+ if (!transport?.postMessage) {
5863
+ return;
5864
+ }
5865
+ const requests = new Array(count);
5866
+ for (let i = 0; i < count; ++i) {
5867
+ requests[i] = {
5868
+ clipId: this.clipPageRequestClipIds[i],
5869
+ pageIndex: this.clipPageRequestPageIndices[i]
5870
+ };
5871
+ }
5872
+ transport.postMessage({
5873
+ type: "clipPageRequest",
5874
+ requests,
5875
+ ...dropped > 0 ? { dropped } : {}
5876
+ });
5877
+ }
5878
+ removePagedClipProvider(clipId) {
5879
+ const providerId = this.pagedClipProviders.get(clipId);
5880
+ if (providerId !== void 0) {
5881
+ this.engine.destroyClipPageProvider(providerId);
5882
+ }
5883
+ this.pagedClipProviders.delete(clipId);
5884
+ this.pagedClipPageFrames.delete(clipId);
5885
+ this.pendingPagedClips.delete(clipId);
5886
+ }
5887
+ clearPagedClipProviders() {
5888
+ for (const clipId of this.pagedClipProviders.keys()) {
5889
+ this.removePagedClipProvider(clipId);
4505
5890
  }
4506
- Atomics.store(ring.header, 0, writeIndex + 1);
4507
5891
  }
4508
5892
  commandRingFromSharedBuffer(sharedBuffer, fallbackCapacity) {
4509
5893
  const ring = engineRingFromSharedBuffer(
@@ -4536,6 +5920,7 @@ function registerSonareRealtimeEngineWorkletProcessor(name = "sonare-realtime-en
4536
5920
  constructor(options) {
4537
5921
  super();
4538
5922
  this.pendingMessages = [];
5923
+ this.pendingMessagesOverflowed = false;
4539
5924
  const port = this.port;
4540
5925
  const processorOptions = options?.processorOptions ?? {};
4541
5926
  void this.initializeEmbind(processorOptions, port);
@@ -4543,6 +5928,8 @@ function registerSonareRealtimeEngineWorkletProcessor(name = "sonare-realtime-en
4543
5928
  if (!this.bridge) {
4544
5929
  if (this.pendingMessages.length < 1024) {
4545
5930
  this.pendingMessages.push(event.data);
5931
+ } else {
5932
+ this.pendingMessagesOverflowed = true;
4546
5933
  }
4547
5934
  return;
4548
5935
  }
@@ -4604,6 +5991,9 @@ function registerSonareRealtimeEngineWorkletProcessor(name = "sonare-realtime-en
4604
5991
  moduleFactory
4605
5992
  });
4606
5993
  }
5994
+ if (this.pendingMessagesOverflowed) {
5995
+ throw new Error("AudioWorklet initialization message buffer overflowed.");
5996
+ }
4607
5997
  this.bridge = new SonareRealtimeEngineWorkletProcessor(options, {
4608
5998
  postMessage: (message) => port?.postMessage?.(message),
4609
5999
  onMeter: (meter) => port?.postMessage?.(meter)
@@ -4919,6 +6309,7 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
4919
6309
  this.changer.prepare(this.sampleRate, this.blockSize, this.channelCount);
4920
6310
  this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
4921
6311
  this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
6312
+ this.bufferGeneration = this.changer.bufferGeneration();
4922
6313
  this.planarChannels = [];
4923
6314
  if (this.channelCount > 1) {
4924
6315
  for (let ch = 0; ch < this.channelCount; ch++) {
@@ -4927,19 +6318,16 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
4927
6318
  }
4928
6319
  }
4929
6320
  /**
4930
- * Handles a control-plane message from the main thread. Runs on the
4931
- * AudioWorklet global scope but OUTSIDE of `process()` (i.e. outside the
4932
- * realtime audio callback), so it is safe to perform JSON parsing and
4933
- * DSP coefficient recomputation here. `setConfig` MUST NOT be deferred
4934
- * into `process()` because that would block the audio thread for longer
4935
- * than one render quantum (e.g. 128 samples / 44.1 kHz = ~2.9 ms).
6321
+ * Handles a control-plane message from the main thread. AudioWorklet port
6322
+ * handlers run on the same audio rendering thread as `process()`: this path
6323
+ * therefore accepts only a pre-normalized POD and never parses JSON.
4936
6324
  */
4937
6325
  receiveMessage(message) {
4938
6326
  if (this.destroyed) {
4939
6327
  return;
4940
6328
  }
4941
6329
  if (message.type === "setConfig") {
4942
- this.changer.setConfig(message.preset);
6330
+ this.changer.setPodConfig(message.config);
4943
6331
  } else if (message.type === "reset") {
4944
6332
  this.changer.reset();
4945
6333
  } else if (message.type === "destroy") {
@@ -4951,7 +6339,7 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
4951
6339
  if (this.destroyed || !output || output.length === 0) {
4952
6340
  return !this.destroyed;
4953
6341
  }
4954
- if (this.monoInput.byteLength === 0) {
6342
+ if (this.bufferGeneration !== this.changer.bufferGeneration() || this.monoInput.byteLength === 0) {
4955
6343
  this.reacquireBuffers();
4956
6344
  }
4957
6345
  const input = inputs[0];
@@ -4968,10 +6356,7 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
4968
6356
  } else {
4969
6357
  this.monoInput.fill(0, 0, frames2);
4970
6358
  }
4971
- this.changer.processMonoInto(
4972
- this.monoInput.subarray(0, frames2),
4973
- this.monoOutput.subarray(0, frames2)
4974
- );
6359
+ this.changer.processPreparedMono(frames2);
4975
6360
  output[0].set(this.monoOutput.subarray(0, frames2));
4976
6361
  return true;
4977
6362
  }
@@ -5017,6 +6402,7 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
5017
6402
  this.planarChannels[ch] = this.changer.getPlanarChannelBuffer(ch, this.blockSize);
5018
6403
  }
5019
6404
  }
6405
+ this.bufferGeneration = this.changer.bufferGeneration();
5020
6406
  }
5021
6407
  /**
5022
6408
  * Returns the number of frames we can actually process given the
@@ -5089,9 +6475,13 @@ function registerSonareRealtimeVoiceChangerWorkletProcessor(name = "sonare-realt
5089
6475
  scope.registerProcessor(name, RegisteredSonareRealtimeVoiceChangerWorkletProcessor);
5090
6476
  }
5091
6477
  export {
6478
+ SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS,
6479
+ SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S,
5092
6480
  SONARE_ENGINE_COMMAND_RECORD_BYTES,
5093
6481
  SONARE_ENGINE_RING_HEADER_INTS,
5094
6482
  SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
6483
+ SONARE_EXTERNAL_MIDI_RING_HEADER_INTS,
6484
+ SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S,
5095
6485
  SONARE_METER_RING_HEADER_INTS,
5096
6486
  SONARE_METER_RING_RECORD_FLOATS,
5097
6487
  SONARE_SCOPE_RING_HEADER_INTS,
@@ -5104,8 +6494,11 @@ export {
5104
6494
  SonareRealtimeEngineWorkletProcessor,
5105
6495
  SonareRealtimeVoiceChangerWorkletProcessor,
5106
6496
  SonareWorkletProcessor,
6497
+ attachOpfsClipStream,
6498
+ createSonareClipPageRequestRingBuffer,
5107
6499
  createSonareEngineCommandRingBuffer,
5108
6500
  createSonareEngineTelemetryRingBuffer,
6501
+ createSonareExternalMidiRingBuffer,
5109
6502
  createSonareMeterRingBuffer,
5110
6503
  createSonareScopeRingBuffer,
5111
6504
  createSonareSpectrumRingBuffer,
@@ -5115,16 +6508,22 @@ export {
5115
6508
  init,
5116
6509
  isInitialized,
5117
6510
  popSonareEngineCommandRingBuffer,
6511
+ pushSonareClipPageRequestRingBuffer,
5118
6512
  pushSonareEngineCommandRingBuffer,
6513
+ pushSonareExternalMidiRingBuffer,
6514
+ readSonareClipPageRequestRingBuffer,
5119
6515
  readSonareEngineTelemetryRingBuffer,
6516
+ readSonareExternalMidiRingBuffer,
5120
6517
  readSonareMeterRingBuffer,
5121
6518
  readSonareScopeRingBuffer,
5122
6519
  readSonareSpectrumRingBuffer,
5123
6520
  registerSonareRealtimeEngineWorkletProcessor,
5124
6521
  registerSonareRealtimeVoiceChangerWorkletProcessor,
5125
6522
  registerSonareWorkletProcessor,
6523
+ sonareClipPageRequestRingBufferByteLength,
5126
6524
  sonareEngineCommandRingBufferByteLength,
5127
6525
  sonareEngineTelemetryRingBufferByteLength,
6526
+ sonareExternalMidiRingBufferByteLength,
5128
6527
  sonareMeterRingBufferByteLength,
5129
6528
  sonareScopeRingBufferByteLength,
5130
6529
  sonareSpectrumRingBufferByteLength,